feat: 增强聊天功能和 markdown 渲染
- 优化聊天 UI 组件交互体验 - 扩展 markdown 渲染功能支持 - 更新类型定义 - 重构 LLM 聊天处理逻辑 - 更新模型提供商种子数据
This commit is contained in:
@@ -26,6 +26,12 @@ func main() {
|
||||
defer pool.Close()
|
||||
|
||||
// 插入阿里云百炼(通义千问)配置
|
||||
apiKey := os.Getenv("QWEN_API_KEY")
|
||||
if apiKey == "" {
|
||||
log.Println("⚠️ QWEN_API_KEY 未设置,跳过初始化千问")
|
||||
return
|
||||
}
|
||||
|
||||
sql := `
|
||||
INSERT INTO model_providers (
|
||||
name,
|
||||
@@ -38,7 +44,7 @@ func main() {
|
||||
) VALUES (
|
||||
'阿里云百炼 (通义千问)',
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
'sk-c0c5174892c44ff48d587cd040fbdd40',
|
||||
$1,
|
||||
'[
|
||||
{"id": "qwen-plus", "name": "通义千问-Plus", "type": "chat"},
|
||||
{"id": "qwen-turbo", "name": "通义千问-Turbo", "type": "chat"},
|
||||
@@ -57,7 +63,7 @@ func main() {
|
||||
ON CONFLICT DO NOTHING
|
||||
`
|
||||
|
||||
_, err = pool.Exec(ctx, sql)
|
||||
_, err = pool.Exec(ctx, sql, apiKey)
|
||||
if err != nil {
|
||||
log.Fatalf("插入数据失败: %v", err)
|
||||
}
|
||||
|
||||
+351
-140
@@ -52,6 +52,14 @@ type llmChatRequest struct {
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
}
|
||||
|
||||
// knowledgeChunk 知识库检索结果结构,包含精确来源信息
|
||||
type knowledgeChunk struct {
|
||||
ID string `json:"id"`
|
||||
DocName string `json:"doc_name"`
|
||||
Content string `json:"content"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
type appCfg struct {
|
||||
SystemPrompt string
|
||||
Model string
|
||||
@@ -186,48 +194,83 @@ func cleanQueryForSearch(query string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *LLMChatHandler) retrieveKnowledge(ctx context.Context, kbID, query string, limit int) (string, error) {
|
||||
func (h *LLMChatHandler) retrieveKnowledge(ctx context.Context, kbID, query string, limit int) ([]knowledgeChunk, string, error) {
|
||||
// 混合检索策略:优先向量搜索,降级到关键词搜索
|
||||
var parts []string
|
||||
// limit 参数:0 表示不限制,>0 表示最多返回 limit 个
|
||||
var allChunks []knowledgeChunk
|
||||
seenIDs := make(map[string]bool)
|
||||
|
||||
// 如果 limit <= 0,设置为一个很大的数以实现"有几个算几个"
|
||||
searchLimit := limit
|
||||
if searchLimit <= 0 {
|
||||
searchLimit = 999999
|
||||
}
|
||||
|
||||
// 1. 尝试向量语义搜索(基于 knowledge_chunks 表)
|
||||
if h.embedder != nil && h.embedder.IsConfigured() {
|
||||
vectorResults := h.vectorSearch(ctx, kbID, query, limit)
|
||||
if len(vectorResults) > 0 {
|
||||
parts = append(parts, vectorResults...)
|
||||
log.Debug().Int("vector_results", len(vectorResults)).Msg("vector search completed")
|
||||
vectorChunks := h.vectorSearch(ctx, kbID, query, searchLimit)
|
||||
if len(vectorChunks) > 0 {
|
||||
allChunks = append(allChunks, vectorChunks...)
|
||||
for _, c := range vectorChunks {
|
||||
seenIDs[c.ID] = true
|
||||
}
|
||||
log.Debug().Int("vector_results", len(vectorChunks)).Msg("vector search completed")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 关键词搜索补充(从 knowledge_chunks 或 knowledge_documents)
|
||||
keywordResults := h.keywordSearch(ctx, kbID, query, limit)
|
||||
for _, kr := range keywordResults {
|
||||
// 去重:检查是否已在向量结果中
|
||||
duplicate := false
|
||||
for _, existing := range parts {
|
||||
if existing == kr {
|
||||
duplicate = true
|
||||
break
|
||||
// 2. 关键词搜索补充(去重)
|
||||
// 只在向量搜索不足时补充关键词结果
|
||||
if len(allChunks) < searchLimit {
|
||||
remainingLimit := searchLimit - len(allChunks)
|
||||
keywordChunks := h.keywordSearch(ctx, kbID, query, remainingLimit)
|
||||
for _, c := range keywordChunks {
|
||||
if !seenIDs[c.ID] {
|
||||
allChunks = append(allChunks, c)
|
||||
seenIDs[c.ID] = true
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
parts = append(parts, kr)
|
||||
}
|
||||
|
||||
if len(allChunks) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// 构建带标注的上下文字符串,供 LLM 使用
|
||||
ctxText := h.buildChunkContext(allChunks)
|
||||
|
||||
// 提取来源列表
|
||||
sources := make([]string, len(allChunks))
|
||||
for i, c := range allChunks {
|
||||
sources[i] = c.DocName
|
||||
}
|
||||
|
||||
return allChunks, ctxText, nil
|
||||
}
|
||||
|
||||
// buildChunkContext 构建知识库上下文,每个 chunk 都附带 chunk_id 标注
|
||||
// 格式:[chunk:id] 文档名
|
||||
// 内容...
|
||||
func (h *LLMChatHandler) buildChunkContext(chunks []knowledgeChunk) string {
|
||||
if len(chunks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是从知识库检索到的相关法规原文,每个编号对应一段原文,生成回答时请在该内容对应的句子末尾标注 [[chunk:编号]]:\n\n")
|
||||
|
||||
for i, chunk := range chunks {
|
||||
sb.WriteString(fmt.Sprintf("[[chunk:%d]] 【%s · 相似度%.0f%%】\n%s\n",
|
||||
i, chunk.DocName, chunk.Similarity*100, chunk.Content))
|
||||
if i < len(chunks)-1 {
|
||||
sb.WriteString("\n---\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 限制总结果数
|
||||
if len(parts) > limit {
|
||||
parts = parts[:limit]
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return strings.Join(parts, "\n\n---\n\n"), nil
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// vectorSearch 向量语义搜索(基于 knowledge_chunks + pgvector)
|
||||
func (h *LLMChatHandler) vectorSearch(ctx context.Context, kbID, query string, limit int) []string {
|
||||
func (h *LLMChatHandler) vectorSearch(ctx context.Context, kbID, query string, limit int) []knowledgeChunk {
|
||||
queryEmbedding, err := h.embedder.GetEmbedding(ctx, query)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("query embedding failed, falling back to keyword search")
|
||||
@@ -237,15 +280,15 @@ func (h *LLMChatHandler) vectorSearch(ctx context.Context, kbID, query string, l
|
||||
vecStr := float32SliceToVectorStr(queryEmbedding)
|
||||
|
||||
rows, err := h.pool.Query(ctx, `
|
||||
SELECT kc.content, kd.name,
|
||||
SELECT kc.id, kc.content, kd.name,
|
||||
1 - (kc.embedding <=> $2::vector) AS similarity
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||
WHERE kc.kb_id = $1
|
||||
AND kc.embedding IS NOT NULL
|
||||
AND 1 - (kc.embedding <=> $2::vector) > 0.3
|
||||
AND 1 - (kc.embedding <=> $2::vector) > 0.1
|
||||
ORDER BY kc.embedding <=> $2::vector
|
||||
LIMIT $3`,
|
||||
LIMIT CASE WHEN $3 <= 0 THEN 999999 ELSE $3 END`,
|
||||
kbID, vecStr, limit)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("vector search query failed")
|
||||
@@ -253,20 +296,19 @@ func (h *LLMChatHandler) vectorSearch(ctx context.Context, kbID, query string, l
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []string
|
||||
var chunks []knowledgeChunk
|
||||
for rows.Next() {
|
||||
var content, docName string
|
||||
var similarity float64
|
||||
if err := rows.Scan(&content, &docName, &similarity); err != nil {
|
||||
var chunk knowledgeChunk
|
||||
if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName, &chunk.Similarity); err != nil {
|
||||
continue
|
||||
}
|
||||
trimmed := content
|
||||
if len([]rune(trimmed)) > 2000 {
|
||||
trimmed = string([]rune(trimmed)[:2000]) + "..."
|
||||
// 截断过长内容
|
||||
if len([]rune(chunk.Content)) > 2000 {
|
||||
chunk.Content = string([]rune(chunk.Content)[:2000]) + "..."
|
||||
}
|
||||
results = append(results, fmt.Sprintf("【%s · 相似度%.0f%%】\n%s", docName, similarity*100, trimmed))
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
return results
|
||||
return chunks
|
||||
}
|
||||
|
||||
// enhanceCitations 后处理:自动为回答添加来源标注徽章,确保100%显示
|
||||
@@ -487,40 +529,195 @@ func (h *LLMChatHandler) generateSourceSummary(hasKnowledge bool, knowledgeSourc
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// extractKnowledgeSources 从知识库检索结果中提取文献名称
|
||||
func (h *LLMChatHandler) extractKnowledgeSources(knowledgeContext string) []string {
|
||||
if knowledgeContext == "" {
|
||||
return nil
|
||||
// enhanceCitationsWithChunks 基于 chunk 映射精确标注来源
|
||||
// 工作原理:
|
||||
// 1. LLM 生成回答时使用 [[chunk:N]] 标注引用了哪段知识库原文
|
||||
// 2. 后处理将 [[chunk:N]] 转换为 [[知识库:文档名]]
|
||||
// 3. 未标注的句子添加 [[AI建议]]
|
||||
func (h *LLMChatHandler) enhanceCitationsWithChunks(response string, hasKnowledge bool, chunks []knowledgeChunk) string {
|
||||
if response == "" {
|
||||
return response
|
||||
}
|
||||
|
||||
var sources []string
|
||||
seen := make(map[string]bool)
|
||||
// 构建 chunk index → 文档名的映射
|
||||
chunkMap := make(map[int]string)
|
||||
docSet := make(map[string]bool)
|
||||
for i, c := range chunks {
|
||||
chunkMap[i] = c.DocName
|
||||
docSet[c.DocName] = true
|
||||
}
|
||||
|
||||
// 1. 将 [[chunk:N]] 转换为 [[知识库:文档名]],同时清理无效索引
|
||||
result := response
|
||||
// 先替换有效的 chunk 索引
|
||||
for i, docName := range chunkMap {
|
||||
// 替换 [[chunk:N]] 为 [[知识库:文档名]]
|
||||
chunkMarker := fmt.Sprintf("[[chunk:%d]]", i)
|
||||
kbMarker := fmt.Sprintf("[[知识库:%s]]", docName)
|
||||
result = strings.ReplaceAll(result, chunkMarker, kbMarker)
|
||||
}
|
||||
// 清理所有无效的 [[chunk:N]](N >= chunks 长度)
|
||||
for i := len(chunks); i < 100; i++ {
|
||||
invalidMarker := fmt.Sprintf("[[chunk:%d]]", i)
|
||||
// 替换为后备文本(由前端处理)
|
||||
result = strings.ReplaceAll(result, invalidMarker, "[[知识库:来源资料]]")
|
||||
}
|
||||
|
||||
// 2. 检查是否已有标注
|
||||
hasKBCitation := strings.Contains(result, "[[知识库:")
|
||||
hasAICitation := strings.Contains(result, "[[AI建议]]")
|
||||
|
||||
// 如果完全没有标注,进行智能补充
|
||||
if !hasKBCitation && !hasAICitation {
|
||||
result = h.addCitationsToResponseWithChunks(result, hasKnowledge, chunkMap)
|
||||
} else if hasKBCitation && !hasAICitation {
|
||||
// 只有知识库标注,补充 AI 建议标注
|
||||
result = h.addAICitationToSuggestions(result)
|
||||
}
|
||||
// 如果已有 AI 建议标注,不再自动添加(让 LLM 自己决定)
|
||||
|
||||
// 3. 确保末尾有来源说明块
|
||||
if !strings.Contains(result, "**来源说明**") && !strings.Contains(result, "> **来源说明**") {
|
||||
result += h.generateSourceSummaryFromChunks(hasKnowledge, chunks)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// generateSourceSummaryFromChunks 基于 chunks 生成来源说明块
|
||||
func (h *LLMChatHandler) generateSourceSummaryFromChunks(hasKnowledge bool, chunks []knowledgeChunk) string {
|
||||
if !hasKnowledge || len(chunks) == 0 {
|
||||
return "\n\n---\n\n> **来源说明**\n>\n> **AI建议:**\n> - 以上内容为AI建议,仅供参考\n"
|
||||
}
|
||||
|
||||
var summary strings.Builder
|
||||
summary.WriteString("\n\n---\n\n")
|
||||
summary.WriteString("> **来源说明**\n>\n")
|
||||
summary.WriteString("> **知识库引用:**\n")
|
||||
|
||||
// 按文档分组
|
||||
docChunks := make(map[string][]knowledgeChunk)
|
||||
for _, c := range chunks {
|
||||
docChunks[c.DocName] = append(docChunks[c.DocName], c)
|
||||
}
|
||||
|
||||
for docName, cs := range docChunks {
|
||||
// 显示每个文档的摘要(第一段内容的前100字)
|
||||
content := cs[0].Content
|
||||
if len([]rune(content)) > 100 {
|
||||
content = string([]rune(content)[:100]) + "..."
|
||||
}
|
||||
fmt.Fprintf(&summary, "> - 【%s · 相似度%.0f%%】:%s\n", docName, cs[0].Similarity*100, content)
|
||||
}
|
||||
|
||||
summary.WriteString(">\n")
|
||||
summary.WriteString("> **AI建议:**\n")
|
||||
summary.WriteString("> - 流程说明和注意事项\n")
|
||||
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// addCitationsToResponseWithChunks 为完全没有标注的回答添加来源标注
|
||||
func (h *LLMChatHandler) addCitationsToResponseWithChunks(response string, hasKnowledge bool, chunkMap map[int]string) string {
|
||||
lines := strings.Split(response, "\n")
|
||||
var enhanced []string
|
||||
var inCodeBlock bool
|
||||
var inQuoteBlock 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
|
||||
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 == "" {
|
||||
enhanced = append(enhanced, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过标题行
|
||||
if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "## ") {
|
||||
enhanced = append(enhanced, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过来源说明等特殊行
|
||||
if (strings.Contains(trimmed, "来源说明") || strings.Contains(trimmed, "免责声明")) &&
|
||||
!strings.Contains(trimmed, "依据") && !strings.Contains(trimmed, "分析") &&
|
||||
!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] == '.')
|
||||
|
||||
needsCitation := (strings.HasSuffix(trimmed, "。") || strings.HasSuffix(trimmed, ".") ||
|
||||
strings.HasSuffix(trimmed, "!") || strings.HasSuffix(trimmed, "!") ||
|
||||
strings.HasSuffix(trimmed, "?") || strings.HasSuffix(trimmed, "?") ||
|
||||
isListItem) ||
|
||||
(len(trimmed) > 5 && !strings.HasPrefix(trimmed, "【") && !strings.HasPrefix(trimmed, "---"))
|
||||
|
||||
if needsCitation {
|
||||
// 检查是否已有标注
|
||||
if strings.Contains(line, "[[知识库:") || strings.Contains(line, "[[AI建议]]") {
|
||||
enhanced = append(enhanced, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据内容特征判断标注类型
|
||||
citation := " [[AI建议]]"
|
||||
if hasKnowledge && len(chunkMap) > 0 {
|
||||
// 短句/事实陈述 → 知识库,长句/建议性内容 → AI建议
|
||||
if len(trimmed) > 100 || strings.Contains(trimmed, "建议") ||
|
||||
strings.Contains(trimmed, "注意") || strings.Contains(trimmed, "可以") ||
|
||||
strings.Contains(trimmed, "分析") || strings.Contains(trimmed, "风险") {
|
||||
citation = " [[AI建议]]"
|
||||
} else {
|
||||
// 找最相关的 chunk(使用第一个,因为没有更精确的匹配信息)
|
||||
for _, docName := range chunkMap {
|
||||
citation = fmt.Sprintf(" [[知识库:%s]]", docName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enhanced = append(enhanced, strings.TrimRight(line, " \t")+citation)
|
||||
} else {
|
||||
enhanced = append(enhanced, line)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
return strings.Join(enhanced, "\n")
|
||||
}
|
||||
|
||||
// keywordSearch 关键词搜索(降级方案,搜索 chunks 和 documents)
|
||||
func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string, limit int) []string {
|
||||
func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string, limit int) []knowledgeChunk {
|
||||
keywords := cleanQueryForSearch(query)
|
||||
if len(keywords) == 0 {
|
||||
return nil
|
||||
@@ -542,7 +739,7 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
||||
args = append(args, limit)
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT kc.content, kd.name
|
||||
SELECT kc.id, kc.content, kd.name
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||
WHERE kc.kb_id = $1
|
||||
@@ -553,20 +750,20 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
||||
rows, err := h.pool.Query(ctx, sql, args...)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var results []string
|
||||
var chunks []knowledgeChunk
|
||||
for rows.Next() {
|
||||
var content, docName string
|
||||
if err := rows.Scan(&content, &docName); err != nil {
|
||||
var chunk knowledgeChunk
|
||||
if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName); err != nil {
|
||||
continue
|
||||
}
|
||||
trimmed := content
|
||||
if len([]rune(trimmed)) > 2000 {
|
||||
trimmed = string([]rune(trimmed)[:2000]) + "..."
|
||||
if len([]rune(chunk.Content)) > 2000 {
|
||||
chunk.Content = string([]rune(chunk.Content)[:2000]) + "..."
|
||||
}
|
||||
results = append(results, fmt.Sprintf("【%s】\n%s", docName, trimmed))
|
||||
chunk.Similarity = 0.5 // 关键词搜索默认相似度
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results
|
||||
if len(chunks) > 0 {
|
||||
return chunks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +780,7 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
||||
args2 = append(args2, limit)
|
||||
|
||||
sql2 := fmt.Sprintf(`
|
||||
SELECT name, content
|
||||
SELECT id, name, content
|
||||
FROM knowledge_documents
|
||||
WHERE kb_id = $1
|
||||
AND content IS NOT NULL AND content != ''
|
||||
@@ -597,19 +794,23 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
||||
}
|
||||
defer rows2.Close()
|
||||
|
||||
var results []string
|
||||
var chunks []knowledgeChunk
|
||||
for rows2.Next() {
|
||||
var name, content string
|
||||
if err := rows2.Scan(&name, &content); err != nil {
|
||||
var id, name, content string
|
||||
if err := rows2.Scan(&id, &name, &content); err != nil {
|
||||
continue
|
||||
}
|
||||
trimmed := content
|
||||
if len([]rune(trimmed)) > 3000 {
|
||||
trimmed = string([]rune(trimmed)[:3000]) + "..."
|
||||
if len([]rune(content)) > 3000 {
|
||||
content = string([]rune(content)[:3000]) + "..."
|
||||
}
|
||||
results = append(results, fmt.Sprintf("【%s】\n%s", name, trimmed))
|
||||
chunks = append(chunks, knowledgeChunk{
|
||||
ID: id,
|
||||
DocName: name,
|
||||
Content: content,
|
||||
Similarity: 0.3,
|
||||
})
|
||||
}
|
||||
return results
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (h *LLMChatHandler) loadConversationHistory(ctx context.Context, appID, userID, convID string, maxTurns int) []llm.Message {
|
||||
@@ -643,13 +844,14 @@ func (h *LLMChatHandler) loadConversationHistory(ctx context.Context, appID, use
|
||||
return history
|
||||
}
|
||||
|
||||
func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, hasKB bool, history []llm.Message, userMessage string, sameOrgApps ...[]sameOrgApp) []llm.Message {
|
||||
// buildMessagesWithChunks 构建消息列表,支持 chunk 编号引用
|
||||
func (h *LLMChatHandler) buildMessagesWithChunks(systemPrompt, knowledgeContext string, hasKB bool, history []llm.Message, userMessage string, orgApps []sameOrgApp, _ []knowledgeChunk) []llm.Message {
|
||||
var msgs []llm.Message
|
||||
|
||||
finalSystem := systemPrompt
|
||||
|
||||
// 注入同机构应用路由表(用于超范围引导跳转)
|
||||
if len(sameOrgApps) > 0 && len(sameOrgApps[0]) > 0 {
|
||||
if len(orgApps) > 0 {
|
||||
finalSystem += "\n\n## 超范围引导(必须遵守)\n\n"
|
||||
finalSystem += "当用户的问题不在本应用的处理范围内时,你必须:\n"
|
||||
finalSystem += "1. 明确告知用户该问题不在本应用处理范围内\n"
|
||||
@@ -657,7 +859,7 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
||||
finalSystem += " [[推荐应用:应用名称:应用slug]]\n"
|
||||
finalSystem += "3. 绝不可对不属于本应用职责的问题强行生成回答\n\n"
|
||||
finalSystem += "本机构可用的应用列表:\n"
|
||||
for _, app := range sameOrgApps[0] {
|
||||
for _, app := range orgApps {
|
||||
finalSystem += fmt.Sprintf("- %s(slug: %s)\n", app.Name, app.Slug)
|
||||
}
|
||||
finalSystem += "\n推荐示例:建议使用 [[推荐应用:法律咨询助手:legal-consult]] 来处理此类问题。\n"
|
||||
@@ -698,69 +900,53 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
||||
你的回答中**每一句话、每一个观点、每一个列表项**都必须在句子末尾标注来源徽章。这是最高优先级要求,必须100%执行,不允许遗漏。
|
||||
|
||||
**格式1:知识库引用(蓝色徽章)**
|
||||
在引用知识库内容的句子末尾加:[[知识库:文献名称]]
|
||||
在引用知识库原文时,句子末尾加:[[chunk:数字]]
|
||||
例如:[[chunk:0]] 表示来自编号为0的知识库原文
|
||||
|
||||
示例:
|
||||
- 居住证办理需要身份证、居住证明和近期照片 [[知识库:户口登记管理规定]]
|
||||
- 办理时限为15个工作日 [[知识库:户口登记管理规定:第十二条]]
|
||||
⚠️ **关键限制:只能引用存在的chunk索引!**
|
||||
检查过程中只能根据实际检索到的知识库原文内容来标注chunk索引。绝对不能编造或推测不存在的chunk索引号。如果知识库中只检索到3个chunks(编号0-2),就只能使用 [[chunk:0]]、[[chunk:1]]、[[chunk:2]],绝对禁止虚构 [[chunk:3]]、[[chunk:4]] 等索引。
|
||||
|
||||
**🔥 严格要求:**
|
||||
- 你的每一句话都必须来自提供的知识库chunks或AI推理
|
||||
- 如果你引用的信息不在任何chunk中,就必须标注 [[AI建议]]
|
||||
- 绝对禁止编造知识库中不存在的内容或使用不存在的chunk索引
|
||||
- 如果知识库的chunks与用户问题关联度不高,要诚实地说明,而不是强行拼凑
|
||||
|
||||
**格式2:AI分析补充(橙色徽章)**
|
||||
任何解读、分析、建议、注意事项等非知识库原文的内容,句末加:[[AI建议]]
|
||||
|
||||
示例:
|
||||
- 建议您提前准备齐全材料,以免多次往返 [[AI建议]]
|
||||
- 如有疑问可先电话咨询当地派出所 [[AI建议]]
|
||||
|
||||
### 📝 完整示例(必须参照此格式)
|
||||
|
||||
**用户提问:** "居住证办理条件是什么?多久能拿到?"
|
||||
**用户提问:** "居住证办理条件是什么?"
|
||||
|
||||
**标准回答格式:**
|
||||
**❌ 错误格式(绝对禁止):**
|
||||
- ~~在居住地居住半年以上(居住证管理办法.pdf)~~ ❌ 不能用文档名
|
||||
- ~~有合法稳定就业(知识库)~~ ❌ 不能用泛指
|
||||
- ~~连续就读~~ ❌ 不能不标注
|
||||
|
||||
## 居住证办理条件及办理时限
|
||||
**✅ 正确格式(必须遵守):**
|
||||
|
||||
### 办理条件
|
||||
## 居住证办理条件
|
||||
|
||||
在居住地居住半年以上,同时满足以下条件之一 [[知识库:户口登记管理规定]]:
|
||||
在居住地居住半年以上,同时满足以下条件之一 [[chunk:0]]:
|
||||
|
||||
- 有合法稳定就业 [[知识库:户口登记管理规定]]
|
||||
- 有合法稳定住所 [[知识库:户口登记管理规定]]
|
||||
- 连续就读 [[知识库:户口登记管理规定]]
|
||||
|
||||
所需材料包括 [[知识库:户口登记管理规定]]:
|
||||
- 身份证
|
||||
- 居住证明(租房合同/房产证/单位证明)
|
||||
- 近期照片
|
||||
|
||||
### 办理时限
|
||||
|
||||
办理居住证的时限为**15个工作日** [[知识库:户口登记管理规定]]。具体流程如下 [[AI建议]]:
|
||||
|
||||
1. 到居住地的任一户籍派出所提交申请材料 [[AI建议]]
|
||||
2. 派出所审核材料,符合条件的予以受理 [[AI建议]]
|
||||
3. 派出所将相关信息录入系统并报上级审核 [[AI建议]]
|
||||
4. 审核通过后,居住证将在15个工作日内制作完成并发放 [[AI建议]]
|
||||
- 有合法稳定就业 [[chunk:0]]
|
||||
- 有合法稳定住所 [[chunk:0]]
|
||||
- 连续就读 [[chunk:0]]
|
||||
|
||||
### 注意事项
|
||||
|
||||
请确保提供的材料真实有效,并按要求准备齐全 [[AI建议]]。如有任何疑问或材料不齐全的情况,建议及时与当地户籍派出所联系确认 [[AI建议]]。
|
||||
请确保提供的材料真实有效 [[AI建议]]。如有疑问可先电话咨询当地派出所 [[AI建议]]。
|
||||
|
||||
---
|
||||
|
||||
> **来源说明**
|
||||
>
|
||||
> **知识库引用:**
|
||||
> - 【户口登记管理规定】:第五条规定,办理居住证需在居住地居住半年以上,并满足合法稳定就业、合法稳定住所或连续就读条件之一;需提供身份证、居住证明和近期照片。第十二条规定,办理时限为自受理之日起15个工作日内制作完成并发放。
|
||||
>
|
||||
> **AI建议:**
|
||||
> - 办理流程的四个步骤说明
|
||||
> - 材料准备的注意事项和建议
|
||||
> - 【xxx法规】:原文内容摘录
|
||||
|
||||
### ✅ 输出前必检项(每次回答前自查)
|
||||
- [ ] 正文中所有知识库引用都标注了 [[知识库:文献名称]]
|
||||
- [ ] 正文中所有AI分析都标注了 [[AI建议]]
|
||||
- [ ] 末尾有完整的来源汇总块
|
||||
- [ ] 来源汇总中列出了知识库原文摘录
|
||||
> **AI建议:**
|
||||
> - 办理流程说明和注意事项
|
||||
|
||||
### 🚨 关键提醒
|
||||
- 如果你的回答中有任何句子、列表项、问题没有标注来源,系统会自动补充标注
|
||||
@@ -769,7 +955,8 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
||||
|
||||
`
|
||||
if knowledgeContext != "" {
|
||||
finalSystem += "### 📚 知识库检索结果\n\n以下是从知识库中检索到的相关文献,请优先基于这些内容回答,并在每个引用处标注 [[知识库:文献名称]]:\n\n" + knowledgeContext
|
||||
// 知识库上下文已在 retrieveKnowledge.buildChunkContext 中格式化为 [chunk:N] 格式
|
||||
finalSystem += "### 📚 知识库检索结果\n\n" + knowledgeContext + "\n"
|
||||
} else {
|
||||
finalSystem += "### 📚 知识库检索结果\n\n⚠️ 当前知识库中未检索到与用户问题直接相关的文献。请使用AI知识回答,**每句话后都必须标注 [[AI建议]]**。\n"
|
||||
}
|
||||
@@ -813,7 +1000,15 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
||||
}
|
||||
|
||||
msgs = append(msgs, history...)
|
||||
msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: userMessage})
|
||||
|
||||
// 🔥 强制约束:直接在用户消息末尾追加标注要求,让LLM无法忽略
|
||||
finalUserMessage := userMessage
|
||||
if hasKB {
|
||||
finalUserMessage += "\n\n---\n⚠️ 重要:你的回答中每一句话都必须在句末标注来源:\n- 引用知识库用 [[chunk:N]](N为chunk编号)\n- AI推理用 [[AI建议]]\n严格执行,不允许遗漏!"
|
||||
}
|
||||
|
||||
msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: finalUserMessage})
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
@@ -844,11 +1039,14 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
|
||||
var chunks []knowledgeChunk
|
||||
var knowledgeCtx string
|
||||
var kbSources []string
|
||||
if hasKB {
|
||||
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
|
||||
kbSources = h.extractKnowledgeSources(knowledgeCtx)
|
||||
var err error
|
||||
chunks, knowledgeCtx, err = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 10000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("knowledge retrieval failed")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载同机构应用列表,用于超范围引导跳转
|
||||
@@ -880,7 +1078,7 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
llmReq := &llm.ChatRequest{
|
||||
Model: modelToUse,
|
||||
Messages: h.buildMessages(cfg.SystemPrompt, knowledgeCtx, hasKB, history, req.Message, orgApps),
|
||||
Messages: h.buildMessagesWithChunks(cfg.SystemPrompt, knowledgeCtx, hasKB, history, req.Message, orgApps, chunks),
|
||||
Temperature: cfg.Temp,
|
||||
MaxTokens: cfg.MaxTok,
|
||||
Stream: true,
|
||||
@@ -911,7 +1109,12 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
||||
var modelName string
|
||||
var fullResponse strings.Builder
|
||||
|
||||
firstEvent := map[string]string{"conversation_id": convID, "message_id": msgID}
|
||||
// 首包注入 chunks 映射表,供前端流式渲染时实时替换 [[chunk:N]]
|
||||
firstEvent := map[string]any{
|
||||
"conversation_id": convID,
|
||||
"message_id": msgID,
|
||||
"chunks": chunks,
|
||||
}
|
||||
data, _ := json.Marshal(firstEvent)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
@@ -940,8 +1143,8 @@ 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)
|
||||
// 后处理:自动增强来源标注(使用 chunk 映射精确标注)
|
||||
enhancedResponse := h.enhanceCitationsWithChunks(fullResponse.String(), hasKB, chunks)
|
||||
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
|
||||
@@ -971,11 +1174,14 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
|
||||
var chunks []knowledgeChunk
|
||||
var knowledgeCtx string
|
||||
var kbSources []string
|
||||
if hasKB {
|
||||
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
|
||||
kbSources = h.extractKnowledgeSources(knowledgeCtx)
|
||||
var err error
|
||||
chunks, knowledgeCtx, err = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 10000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("knowledge retrieval failed")
|
||||
}
|
||||
}
|
||||
|
||||
// 加载同机构应用列表,用于超范围引导跳转
|
||||
@@ -996,7 +1202,7 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
llmReq := &llm.ChatRequest{
|
||||
Model: modelToUse,
|
||||
Messages: h.buildMessages(cfg.SystemPrompt, knowledgeCtx, hasKB, nil, req.Message, orgApps),
|
||||
Messages: h.buildMessagesWithChunks(cfg.SystemPrompt, knowledgeCtx, hasKB, nil, req.Message, orgApps, chunks),
|
||||
Temperature: cfg.Temp,
|
||||
MaxTokens: cfg.MaxTok,
|
||||
Stream: true,
|
||||
@@ -1028,7 +1234,12 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
|
||||
var modelName string
|
||||
var fullResponse strings.Builder
|
||||
|
||||
firstEvent := map[string]string{"conversation_id": convID, "message_id": msgID}
|
||||
// 首包注入 chunks 映射表,供前端流式渲染时实时替换 [[chunk:N]]
|
||||
firstEvent := map[string]any{
|
||||
"conversation_id": convID,
|
||||
"message_id": msgID,
|
||||
"chunks": chunks,
|
||||
}
|
||||
data, _ := json.Marshal(firstEvent)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
@@ -1057,8 +1268,8 @@ 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)
|
||||
// 后处理:自动增强来源标注(使用 chunk 映射精确标注)
|
||||
enhancedResponse := h.enhanceCitationsWithChunks(fullResponse.String(), hasKB, chunks)
|
||||
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
|
||||
|
||||
Reference in New Issue
Block a user