diff --git a/apps/web/src/components/app-ui/chatbot-ui.tsx b/apps/web/src/components/app-ui/chatbot-ui.tsx index 8fc29b9..422859f 100644 --- a/apps/web/src/components/app-ui/chatbot-ui.tsx +++ b/apps/web/src/components/app-ui/chatbot-ui.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback, memo } from "react"; import { useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { App, Conversation, Message } from "@/lib/types"; +import type { App, Conversation, Message, Chunk } from "@/lib/types"; import api, { streamChat } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -43,9 +43,11 @@ import { useAuthStore } from "@/stores/auth"; const ChatMessage = memo(function ChatMessage({ msg, onCopy, + chunks, }: { msg: Message; onCopy: (text: string) => void; + chunks: Chunk[]; }) { if (msg.role === "user") { return ( @@ -70,7 +72,7 @@ const ChatMessage = memo(function ChatMessage({
- +
{msg.content && (
@@ -112,6 +114,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const [fileContent, setFileContent] = useState(null); const [fileName, setFileName] = useState(null); + const [chunks, setChunks] = useState([]); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const abortRef = useRef(null); @@ -269,6 +272,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) { setInput(""); setFileContent(null); setFileName(null); + setChunks([]); setIsStreaming(true); const controller = new AbortController(); @@ -302,6 +306,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) { const event = JSON.parse(raw); if (event.conversation_id) setConversationId(event.conversation_id); + // 解析首包的 chunks 映射表 + if (event.chunks) { + console.log("[SSE] Received chunks:", event.chunks); + setChunks(event.chunks as Chunk[]); + } if (event.answer) { accumulated += event.answer; const snap = accumulated; @@ -687,7 +696,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
{messages.map((msg) => ( - + ))} {messages.length <= 1 && suggestedPrompts.length > 0 && ( diff --git a/apps/web/src/components/ui/gov-markdown.tsx b/apps/web/src/components/ui/gov-markdown.tsx index a25f520..950c284 100644 --- a/apps/web/src/components/ui/gov-markdown.tsx +++ b/apps/web/src/components/ui/gov-markdown.tsx @@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react"; import { useRouter } from "next/navigation"; +import type { Chunk } from "@/lib/types"; const mdComponents: Components = { h1: ({ children }) => ( @@ -153,6 +154,7 @@ function AppLinkBadge({ slug, name }: { slug: string; name: string }) { interface GovMarkdownProps { content: string; className?: string; + chunks?: Chunk[]; } function stripOuterCodeFence(text: string): string { @@ -164,22 +166,50 @@ function stripOuterCodeFence(text: string): string { /** * 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染 + * + * 支持的标注格式: + * - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名) + * - [[知识库:文档名]] → 知识库引用徽章 + * - [[AI建议]] → AI建议徽章 + * - [[推荐应用:名称:slug]] → 可点击跳转链接 */ -function preprocessCitations(content: string): string { - return content - // 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]] - .replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`) - // AI建议:标准格式 [[AI建议]] - .replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)") - // AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后) - .replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)") - // AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后) - .replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)") - // 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接 - .replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`); +function preprocessCitations(content: string, chunks?: Chunk[]): string { + let result = content; + console.log("[preprocessCitations] chunks:", chunks); + + // 先处理 chunk 编号引用 [[chunk:N]] + result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => { + const i = parseInt(idx, 10); + if (chunks && chunks[i]) { + // 使用 chunks 映射表,转换为带文档名的知识库引用 + const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段"; + console.log(`[preprocessCitations] chunk[${i}]:`, docName); + return `[知识库:${docName}](#cite-kb)`; + } + // 无映射时显示后备文本 + console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length); + return `[知识库片段](#cite-kb)`; + }); + + // 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]] + result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`); + + // AI建议:标准格式 [[AI建议]] + result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)"); + + // AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后) + result = result.replace(/^(\s*\>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)"); + + // AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后) + result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)"); + + // 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接 + result = result.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`); + + return result; } -const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdownProps) { +const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) { if (!content) { return (
@@ -188,7 +218,7 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
); } - const cleaned = preprocessCitations(stripOuterCodeFence(content)); + const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks); return (
@@ -198,4 +228,4 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow ); }); -export default GovMarkdown; +export default GovMarkdown; \ No newline at end of file diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index c030d83..8ecdf1e 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -93,6 +93,14 @@ export interface Message { content: string; } +// 知识库片段映射,来自 SSE 首包 +export interface Chunk { + id: string; + doc_name: string; + content: string; + similarity: number; +} + export interface DeleteTarget { type: "single" | "batch"; id?: string; diff --git a/server/cmd/seed-providers/main.go b/server/cmd/seed-providers/main.go index d92c688..c842468 100644 --- a/server/cmd/seed-providers/main.go +++ b/server/cmd/seed-providers/main.go @@ -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) } diff --git a/server/internal/handler/chat_llm.go b/server/internal/handler/chat_llm.go index 52bd5e0..b31d51b 100644 --- a/server/internal/handler/chat_llm.go +++ b/server/internal/handler/chat_llm.go @@ -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)