package handler import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/enterprise-ai-platform/server/internal/middleware" "github.com/enterprise-ai-platform/server/internal/response" "github.com/enterprise-ai-platform/server/pkg/embedding" "github.com/enterprise-ai-platform/server/pkg/llm" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "github.com/rs/zerolog/log" ) type LLMChatHandler struct { pool *pgxpool.Pool manager *llm.Manager provider string // 保留作为 fallback,当数据库查询失败时使用 rdb *redis.Client workerURL string embedder *embedding.Client } func NewLLMChatHandler(pool *pgxpool.Pool, manager *llm.Manager, defaultProvider string, rdb *redis.Client, workerURL string, embedder *embedding.Client) *LLMChatHandler { // 设置数据库连接池到 manager,让它可以从数据库读取 providers manager.SetPool(pool) return &LLMChatHandler{pool: pool, manager: manager, provider: defaultProvider, rdb: rdb, workerURL: workerURL, embedder: embedder} } // getProviderWithModel 获取当前激活的 provider 及其默认模型 func (h *LLMChatHandler) getProviderWithModel(ctx context.Context) (llm.Provider, string, error) { // 尝试从数据库获取激活的 provider 和默认模型 provider, defaultModel, err := h.manager.GetActiveProviderWithModel(ctx) if err == nil { return provider, defaultModel, nil } // 降级到配置的 fallback provider provider, err = h.manager.GetProvider(h.provider) return provider, "", err // 降级时返回空模型,使用应用配置 } type llmChatRequest struct { Message string `json:"message"` 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 Temp float64 MaxTok int KnowledgeBaseID *string AppType string OrgID *string AppName string } // sameOrgApp 同机构其他应用信息,用于超范围引导跳转 type sameOrgApp struct { Name string Slug string } func (h *LLMChatHandler) loadAppConfig(ctx context.Context, appID string) (*appCfg, error) { var cfg appCfg err := h.pool.QueryRow(ctx, `SELECT COALESCE(app_config->>'system_prompt', ''), COALESCE(app_config->>'model', ''), COALESCE(temperature, 0.7), COALESCE(max_tokens, 4096), knowledge_base_id::text, COALESCE(dify_app_type, ''), org_id::text, COALESCE(name, '') FROM applications WHERE id = $1 AND status = 'approved'`, appID, ).Scan(&cfg.SystemPrompt, &cfg.Model, &cfg.Temp, &cfg.MaxTok, &cfg.KnowledgeBaseID, &cfg.AppType, &cfg.OrgID, &cfg.AppName) if err != nil { return nil, err } return &cfg, nil } // loadSameOrgApps 查询同机构内其他应用(排除当前应用),用于超范围引导 func (h *LLMChatHandler) loadSameOrgApps(ctx context.Context, orgID *string, currentAppID string) []sameOrgApp { if orgID == nil || *orgID == "" { return nil } rows, err := h.pool.Query(ctx, `SELECT name, slug FROM applications WHERE org_id = $1 AND id != $2 AND status = 'approved' ORDER BY name`, *orgID, currentAppID) if err != nil { return nil } defer rows.Close() var apps []sameOrgApp for rows.Next() { var a sameOrgApp if rows.Scan(&a.Name, &a.Slug) == nil { apps = append(apps, a) } } return apps } // cleanQueryForSearch 去除标点符号和常见语气词,提取有效关键词 func cleanQueryForSearch(query string) []string { // 去除中英文标点和常见语气助词 cleaned := query for _, ch := range []string{ "?", "?", "!", "!", "。", ".", ",", ",", "、", ":", ":", ";", ";", "(", ")", "(", ")", "《", "》", "【", "】", "\n", "\t", "\u201c", "\u201d", "\u2018", "\u2019", } { cleaned = strings.ReplaceAll(cleaned, ch, " ") } cleaned = strings.TrimSpace(cleaned) // 去除常见语气词和停用词 stopWords := []string{ "是什么", "是啥", "有哪些", "怎么样", "怎么办", "的", "了", "在", "和", "与", "或", "等", "中", "为", "被", "关于", "请问", "什么", "如何", "怎么", "哪些", "哪个", } for _, s := range stopWords { if strings.HasSuffix(cleaned, s) && len([]rune(cleaned)) > len([]rune(s))+2 { cleaned = strings.TrimSuffix(cleaned, s) } } // 按空格拆分 tokens := strings.Fields(cleaned) // 对中文长token按常见法律/政策术语边界拆分 var result []string for _, tok := range tokens { runes := []rune(tok) if len(runes) < 2 { continue } result = append(result, tok) // 长关键词用滑动窗口拆分为2-4字的短语,提高检索召回率 if len(runes) >= 4 { for size := 2; size <= 4 && size <= len(runes); size++ { for i := 0; i+size <= len(runes); i++ { sub := string(runes[i : i+size]) // 去重且过滤停用词 isDup := false for _, r := range result { if r == sub { isDup = true break } } isStop := false for _, sw := range stopWords { if sub == sw { isStop = true break } } if !isDup && !isStop { result = append(result, sub) } } } } } // 限制关键词数量,避免查询过于复杂 if len(result) > 8 { result = result[:8] } if len(result) == 0 && len([]rune(cleaned)) >= 2 { result = append(result, cleaned) } return result } func (h *LLMChatHandler) retrieveKnowledge(ctx context.Context, kbID, query string, limit int) ([]knowledgeChunk, string, error) { // 混合检索策略:优先向量搜索,降级到关键词搜索 // 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() { 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. 关键词搜索补充(去重) // 只在向量搜索不足时补充关键词结果 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 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") } } return sb.String() } // vectorSearch 向量语义搜索(基于 knowledge_chunks + pgvector) 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") return nil } vecStr := float32SliceToVectorStr(queryEmbedding) rows, err := h.pool.Query(ctx, ` 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.1 ORDER BY kc.embedding <=> $2::vector 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") return nil } defer rows.Close() var chunks []knowledgeChunk for rows.Next() { var chunk knowledgeChunk if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName, &chunk.Similarity); err != nil { continue } // 截断过长内容 if len([]rune(chunk.Content)) > 2000 { chunk.Content = string([]rune(chunk.Content)[:2000]) + "..." } chunks = append(chunks, chunk) } return chunks } // 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建议]]") var result string // 如果完全没有标注,进行智能补充 if !hasKBCitation && !hasAICitation { result = h.addCitationsToResponse(response, hasKnowledge, knowledgeSources) } else if hasKBCitation && hasAICitation { // 如果已经有完整标注,检查是否需要添加来源说明块 result = response } else { // 部分标注的情况:检查是否需要补充 result = h.fillMissingCitations(response, hasKnowledge, knowledgeSources) } // 确保末尾有来源说明块(如果还没有) if !strings.Contains(result, "**来源说明**") && !strings.Contains(result, "> **来源说明**") { result += h.generateSourceSummary(hasKnowledge, knowledgeSources) } return result } // 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 == "" { 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 } // 跳过已有标注的"知识库引用"和"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, "?") || isListItem) || (len(trimmed) > 5 && !strings.HasPrefix(trimmed, "【") && !strings.HasPrefix(trimmed, "---")) if needsCitation { // 决定使用哪种标注 citation := " [[AI建议]]" if hasKnowledge && len(knowledgeSources) > 0 { // 如果内容较长且像是说明性内容,用AI建议 // 如果内容较短且像是事实陈述,用知识库 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] + "]]" } } // 添加标注(如果行尾还没有) 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) } } return strings.Join(enhanced, "\n") } // 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 { fmt.Fprintf(&summary, "> - 【%s】\n", source) } summary.WriteString(">\n") summary.WriteString("> **AI建议:**\n") summary.WriteString("> - 流程说明和注意事项\n") } else { summary.WriteString("> **AI建议:**\n") summary.WriteString("> - 以上内容为AI建议,仅供参考\n") } return summary.String() } // 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 } // 构建 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 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 == "" { 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 strings.Join(enhanced, "\n") } // keywordSearch 关键词搜索(降级方案,搜索 chunks 和 documents) func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string, limit int) []knowledgeChunk { keywords := cleanQueryForSearch(query) if len(keywords) == 0 { return nil } // 先搜索 chunks 表 var conditions []string var args []any args = append(args, kbID) // $1 for _, kw := range keywords { idx := len(args) + 1 placeholder := fmt.Sprintf("$%d", idx) args = append(args, "%"+kw+"%") conditions = append(conditions, fmt.Sprintf("kc.content ILIKE %s", placeholder)) } limitIdx := len(args) + 1 args = append(args, limit) sql := fmt.Sprintf(` 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 AND (%s) ORDER BY kc.created_at DESC LIMIT $%d`, strings.Join(conditions, " OR "), limitIdx) rows, err := h.pool.Query(ctx, sql, args...) if err == nil { defer rows.Close() var chunks []knowledgeChunk for rows.Next() { var chunk knowledgeChunk if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName); err != nil { continue } if len([]rune(chunk.Content)) > 2000 { chunk.Content = string([]rune(chunk.Content)[:2000]) + "..." } chunk.Similarity = 0.5 // 关键词搜索默认相似度 chunks = append(chunks, chunk) } if len(chunks) > 0 { return chunks } } // 降级:搜索 knowledge_documents 原文(没有分片的旧数据) args2 := []any{kbID} var conditions2 []string for _, kw := range keywords { idx := len(args2) + 1 placeholder := fmt.Sprintf("$%d", idx) args2 = append(args2, "%"+kw+"%") conditions2 = append(conditions2, fmt.Sprintf("(name ILIKE %s OR content ILIKE %s)", placeholder, placeholder)) } limitIdx2 := len(args2) + 1 args2 = append(args2, limit) sql2 := fmt.Sprintf(` SELECT id, name, content FROM knowledge_documents WHERE kb_id = $1 AND content IS NOT NULL AND content != '' AND (%s) ORDER BY created_at DESC LIMIT $%d`, strings.Join(conditions2, " OR "), limitIdx2) rows2, err := h.pool.Query(ctx, sql2, args2...) if err != nil { return nil } defer rows2.Close() var chunks []knowledgeChunk for rows2.Next() { var id, name, content string if err := rows2.Scan(&id, &name, &content); err != nil { continue } if len([]rune(content)) > 3000 { content = string([]rune(content)[:3000]) + "..." } chunks = append(chunks, knowledgeChunk{ ID: id, DocName: name, Content: content, Similarity: 0.3, }) } return chunks } func (h *LLMChatHandler) loadConversationHistory(ctx context.Context, appID, userID, convID string, maxTurns int) []llm.Message { rows, err := h.pool.Query(ctx, ` SELECT user_message, COALESCE(ai_response, '') FROM app_usage_logs WHERE app_id = $1 AND user_id = $2 AND conversation_id = $3 ORDER BY created_at ASC`, appID, userID, convID) if err != nil { return nil } defer rows.Close() var history []llm.Message for rows.Next() { var userMsg, aiResp string if err := rows.Scan(&userMsg, &aiResp); err != nil { continue } if userMsg != "" { history = append(history, llm.Message{Role: llm.RoleUser, Content: userMsg}) } if aiResp != "" { history = append(history, llm.Message{Role: llm.RoleAssistant, Content: aiResp}) } } if maxTurns > 0 && len(history) > maxTurns*2 { history = history[len(history)-maxTurns*2:] } return history } // 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(orgApps) > 0 { finalSystem += "\n\n## 超范围引导(必须遵守)\n\n" finalSystem += "当用户的问题不在本应用的处理范围内时,你必须:\n" finalSystem += "1. 明确告知用户该问题不在本应用处理范围内\n" finalSystem += "2. 推荐本机构内更合适的应用,使用以下格式(系统会自动渲染为可点击的跳转链接):\n" finalSystem += " [[推荐应用:应用名称:应用slug]]\n" finalSystem += "3. 绝不可对不属于本应用职责的问题强行生成回答\n\n" finalSystem += "本机构可用的应用列表:\n" for _, app := range orgApps { finalSystem += fmt.Sprintf("- %s(slug: %s)\n", app.Name, app.Slug) } finalSystem += "\n推荐示例:建议使用 [[推荐应用:法律咨询助手:legal-consult]] 来处理此类问题。\n" finalSystem += "注意:如果用户的问题不属于本机构任何应用的范围(如需要联系其他政府部门),则直接用文字说明应联系的部门,不使用上述格式。\n" } // 通用红线规则:适用于所有应用 finalSystem += ` ## 绝对红线(所有应用必须遵守) 1. **禁止编造事实**:不得虚构任何调查结果、检查记录、走访情况、证据材料等事实性内容。所有事实描述必须且只能来自用户提供的输入内容或知识库检索结果。如果用户未提供相关事实,应明确标注「(待补充)」或提示用户补充,绝不可凭空捏造。 2. **禁止虚构法规条文**:只能引用知识库中存在的法规原文或用户明确提供的法规信息,不得杜撰法条内容、编号或文件名称。如知识库中未检索到相关法规,应注明「(建议补充相关法规依据)」。 3. **管辖权与职责范围判断(必须首先执行)**:收到用户输入后,必须先判断该问题是否属于当前应用的职责范围。判断依据为上方的功能介绍和系统定位。 - **属于职责范围**:正常处理并生成回答。 - **不属于职责范围**:必须明确告知用户该问题不在本应用处理范围内,说明原因,并推荐合适的处理渠道或机构。常见分流指引: - 消费纠纷/合同纠纷 → 市场监管部门(12315)或法院民事诉讼 - 劳动争议 → 劳动仲裁委员会 - 刑事案件 → 公安机关 - 民事侵权 → 法院民事诉讼 - 行政复议/行政诉讼 → 对应上级行政机关或法院 - 信访事项 → 对应信访部门 - 税务问题 → 税务机关 - 医疗纠纷 → 卫健部门或医调委 - **绝不可**:对不属于本应用职责的问题强行生成专业回答,这会误导用户。 ` if hasKB { finalSystem += ` ## 🔴 最高优先级规则:来源徽章标注(必须100%执行) 本系统会将 [[知识库:xxx]] 和 [[AI建议]] 自动渲染为彩色徽章,显示在回答内容中。用户通过徽章可以清楚看到每句话的出处。 ### ⚠️ 强制要求(不允许任何例外) 你的回答中**每一句话、每一个观点、每一个列表项**都必须在句子末尾标注来源徽章。这是最高优先级要求,必须100%执行,不允许遗漏。 **格式1:知识库引用(蓝色徽章)** 在引用知识库原文时,句子末尾加:[[chunk:数字]] 例如:[[chunk:0]] 表示来自编号为0的知识库原文 ⚠️ **关键限制:只能引用存在的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建议]] ### 📝 完整示例(必须参照此格式) **用户提问:** "居住证办理条件是什么?" **❌ 错误格式(绝对禁止):** - ~~在居住地居住半年以上(居住证管理办法.pdf)~~ ❌ 不能用文档名 - ~~有合法稳定就业(知识库)~~ ❌ 不能用泛指 - ~~连续就读~~ ❌ 不能不标注 **✅ 正确格式(必须遵守):** ## 居住证办理条件 在居住地居住半年以上,同时满足以下条件之一 [[chunk:0]]: - 有合法稳定就业 [[chunk:0]] - 有合法稳定住所 [[chunk:0]] - 连续就读 [[chunk:0]] ### 注意事项 请确保提供的材料真实有效 [[AI建议]]。如有疑问可先电话咨询当地派出所 [[AI建议]]。 --- > **来源说明** > > **知识库引用:** > - 【xxx法规】:原文内容摘录 > **AI建议:** > - 办理流程说明和注意事项 ### 🚨 关键提醒 - 如果你的回答中有任何句子、列表项、问题没有标注来源,系统会自动补充标注 - 为了避免系统自动补充,你必须在生成回答时就添加所有标注 - 特别是对于清单、问卷、检查表等列表格式的内容,每一项都必须标注 ` if knowledgeContext != "" { // 知识库上下文已在 retrieveKnowledge.buildChunkContext 中格式化为 [chunk:N] 格式 finalSystem += "### 📚 知识库检索结果\n\n" + knowledgeContext + "\n" } else { finalSystem += "### 📚 知识库检索结果\n\n⚠️ 当前知识库中未检索到与用户问题直接相关的文献。请使用AI知识回答,**每句话后都必须标注 [[AI建议]]**。\n" } } else { finalSystem += ` ## 来源标注规则 你的回答内容全部来自AI模型的自身知识。请在重要观点后标注 [[AI建议]],并在回答末尾附加: > **来源说明** > - 以上内容为AI建议,仅供参考,请以官方文件和专业意见为准。 ` } // 通用专业标准和输出质量要求 finalSystem += ` ## 专业标准(最佳实践者角色) 你是本领域经验最丰富的专业人员,必须遵守: 1. **专业深度**:回答必须有专业深度,不泛泛而谈,使用本领域的专业术语 2. **逻辑结构**:按"问题分析 → 依据引用 → 结论建议"层层递进 3. **明确意见**:在合规前提下给出明确的意见和建议,不模棱两可 4. **实操可行**:建议必须具体、可执行,考虑实际操作可行性 5. **风险预判**:主动识别并提示潜在风险 6. **信息不足时**:主动向用户确认缺失信息,而非自行假设 ## 输出质量标准 1. **结构化输出**:使用 Markdown 标题、列表、表格组织内容,禁止输出大段无格式纯文字 2. **重要信息高亮**:关键结论、风险提示、注意事项使用 **加粗** 标注 3. **法规引用格式**:统一为「《法规名》第X条第X款」格式 4. **完整性自检**:输出前自检是否遗漏关键要素 ` if finalSystem != "" { msgs = append(msgs, llm.Message{Role: llm.RoleSystem, Content: finalSystem}) } msgs = append(msgs, history...) // 🔥 强制约束:直接在用户消息末尾追加标注要求,让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 } func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) var req llmChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.BadRequest(w, "无效的请求格式") return } if req.Message == "" { response.BadRequest(w, "消息不能为空") return } cfg, err := h.loadAppConfig(r.Context(), appID) if err != nil { response.NotFound(w, "应用不存在或未上架") return } // PPT 生成应用走专用管线 if cfg.AppType == "ppt_generator" { h.handlePPTChat(w, r, appID, userID.String(), req.Message, req.ConversationID) return } hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != "" var chunks []knowledgeChunk var knowledgeCtx string if hasKB { 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") } } // 加载同机构应用列表,用于超范围引导跳转 orgApps := h.loadSameOrgApps(r.Context(), cfg.OrgID, appID) convID := req.ConversationID isNewConv := convID == "" if isNewConv { convID = uuid.New().String() } var history []llm.Message if !isNewConv { history = h.loadConversationHistory(r.Context(), appID, userID.String(), convID, 10) } // 获取激活的 provider 和默认模型 provider, defaultModel, err := h.getProviderWithModel(r.Context()) if err != nil { response.InternalError(w, "获取模型提供商失败") return } // 使用优先级:Provider 默认模型 > 应用配置模型 modelToUse := defaultModel if modelToUse == "" { modelToUse = cfg.Model // 降级时使用应用配置 } llmReq := &llm.ChatRequest{ Model: modelToUse, Messages: h.buildMessagesWithChunks(cfg.SystemPrompt, knowledgeCtx, hasKB, history, req.Message, orgApps, chunks), Temperature: cfg.Temp, MaxTokens: cfg.MaxTok, Stream: true, } startTime := time.Now() body, err := h.manager.ChatStream(r.Context(), provider, llmReq) if err != nil { response.Error(w, http.StatusBadGateway, 50202, "模型服务不可用: "+err.Error()) return } defer body.Close() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { response.InternalError(w, "Streaming not supported") return } msgID := uuid.New().String() var totalTokens int var modelName string var fullResponse strings.Builder // 首包注入 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() transform := llm.TransformOpenAIStream if h.provider == "anthropic" { transform = llm.TransformAnthropicStream } _ = transform(body, func(event llm.StreamEvent) { if event.MessageID == "" { event.MessageID = msgID } if event.Answer != "" { fullResponse.WriteString(event.Answer) } if event.Usage != nil { totalTokens = event.Usage.TotalTokens modelName = event.Usage.Model } data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() }) fmt.Fprintf(w, "data: [DONE]\n\n") flusher.Flush() // 后处理:自动增强来源标注(使用 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) if isNewConv { go h.generateConversationName(appID, userID.String(), convID, req.Message) } } func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) var req llmChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.BadRequest(w, "无效的请求格式") return } if req.Message == "" { response.BadRequest(w, "消息不能为空") return } cfg, err := h.loadAppConfig(r.Context(), appID) if err != nil { response.NotFound(w, "应用不存在或未上架") return } hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != "" var chunks []knowledgeChunk var knowledgeCtx string if hasKB { 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") } } // 加载同机构应用列表,用于超范围引导跳转 orgApps := h.loadSameOrgApps(r.Context(), cfg.OrgID, appID) // 获取激活的 provider 和默认模型 provider, defaultModel, err := h.getProviderWithModel(r.Context()) if err != nil { response.InternalError(w, "获取模型提供商失败") return } // 使用优先级:Provider 默认模型 > 应用配置模型 modelToUse := defaultModel if modelToUse == "" { modelToUse = cfg.Model // 降级时使用应用配置 } llmReq := &llm.ChatRequest{ Model: modelToUse, Messages: h.buildMessagesWithChunks(cfg.SystemPrompt, knowledgeCtx, hasKB, nil, req.Message, orgApps, chunks), Temperature: cfg.Temp, MaxTokens: cfg.MaxTok, Stream: true, } startTime := time.Now() convID := uuid.New().String() body, err := h.manager.ChatStream(r.Context(), provider, llmReq) if err != nil { response.Error(w, http.StatusBadGateway, 50202, "模型服务不可用: "+err.Error()) return } defer body.Close() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { response.InternalError(w, "Streaming not supported") return } msgID := uuid.New().String() var totalTokens int var modelName string var fullResponse strings.Builder // 首包注入 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() transform := llm.TransformOpenAIStream if h.provider == "anthropic" { transform = llm.TransformAnthropicStream } _ = transform(body, func(event llm.StreamEvent) { if event.MessageID == "" { event.MessageID = msgID } if event.Answer != "" { fullResponse.WriteString(event.Answer) } if event.Usage != nil { totalTokens = event.Usage.TotalTokens modelName = event.Usage.Model } data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() }) fmt.Fprintf(w, "data: [DONE]\n\n") flusher.Flush() // 后处理:自动增强来源标注(使用 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) go h.generateConversationName(appID, userID.String(), convID, req.Message) } func (h *LLMChatHandler) Conversations(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) rows, err := h.pool.Query(r.Context(), ` SELECT conversation_id, MIN(created_at) AS first_at, MAX(created_at) AS last_at, COUNT(*) AS msg_count, (SELECT COALESCE(user_message, '') FROM app_usage_logs u2 WHERE u2.conversation_id = u.conversation_id AND u2.user_message != '' ORDER BY u2.created_at LIMIT 1) AS first_msg FROM app_usage_logs u WHERE app_id = $1 AND user_id = $2 AND conversation_id IS NOT NULL GROUP BY conversation_id ORDER BY last_at DESC LIMIT 50`, appID, userID) if err != nil { response.InternalError(w, "查询对话列表失败") return } defer rows.Close() customNames := make(map[string]string) nameRows, err := h.pool.Query(r.Context(), `SELECT conversation_id, name FROM conversation_names WHERE app_id = $1 AND user_id = $2`, appID, userID) if err == nil { defer nameRows.Close() for nameRows.Next() { var cid, n string if nameRows.Scan(&cid, &n) == nil { customNames[cid] = n } } } var convs []map[string]any for rows.Next() { var convID string var firstAt, lastAt time.Time var msgCount int var firstMsg *string if err := rows.Scan(&convID, &firstAt, &lastAt, &msgCount, &firstMsg); err != nil { continue } name := "新对话" if cn, ok := customNames[convID]; ok && cn != "" { name = cn } else if firstMsg != nil && *firstMsg != "" { name = *firstMsg runes := []rune(name) if len(runes) > 30 { name = string(runes[:30]) + "..." } } convs = append(convs, map[string]any{ "id": convID, "name": name, "created_at": firstAt, "updated_at": lastAt, "msg_count": msgCount, }) } if convs == nil { convs = []map[string]any{} } response.JSON(w, http.StatusOK, map[string]any{"data": convs}) } func (h *LLMChatHandler) RenameConversation(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") convID := chi.URLParam(r, "convId") userID := middleware.GetUserID(r.Context()) var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.BadRequest(w, "无效请求格式") return } name := strings.TrimSpace(req.Name) if name == "" { response.BadRequest(w, "名称不能为空") return } runes := []rune(name) if len(runes) > 50 { name = string(runes[:50]) } _, err := h.pool.Exec(r.Context(), ` INSERT INTO conversation_names (app_id, user_id, conversation_id, name, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (app_id, user_id, conversation_id) DO UPDATE SET name = EXCLUDED.name, updated_at = now()`, appID, userID, convID, name) if err != nil { response.InternalError(w, "重命名失败") return } response.JSON(w, http.StatusOK, map[string]string{"message": "已重命名", "name": name}) } func (h *LLMChatHandler) Messages(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") convID := chi.URLParam(r, "convId") userID := middleware.GetUserID(r.Context()) rows, err := h.pool.Query(r.Context(), ` SELECT user_message, COALESCE(ai_response, ''), created_at FROM app_usage_logs WHERE app_id = $1 AND user_id = $2 AND conversation_id = $3 ORDER BY created_at ASC`, appID, userID, convID) if err != nil { response.InternalError(w, "查询消息失败") return } defer rows.Close() var msgs []map[string]any for rows.Next() { var userMsg, aiResp string var createdAt time.Time if err := rows.Scan(&userMsg, &aiResp, &createdAt); err != nil { continue } if userMsg != "" { msgs = append(msgs, map[string]any{ "id": fmt.Sprintf("u-%d", createdAt.UnixMilli()), "role": "user", "content": userMsg, "created_at": createdAt, }) } if aiResp != "" { msgs = append(msgs, map[string]any{ "id": fmt.Sprintf("a-%d", createdAt.UnixMilli()), "role": "assistant", "content": aiResp, "created_at": createdAt, }) } } if msgs == nil { msgs = []map[string]any{} } response.JSON(w, http.StatusOK, map[string]any{"data": msgs}) } func (h *LLMChatHandler) DeleteConversation(w http.ResponseWriter, r *http.Request) { convID := chi.URLParam(r, "convId") userID := middleware.GetUserID(r.Context()) appID := chi.URLParam(r, "id") _, err := h.pool.Exec(r.Context(), `DELETE FROM app_usage_logs WHERE conversation_id = $1 AND user_id = $2 AND app_id = $3`, convID, userID, appID) if err != nil { response.InternalError(w, "删除对话失败") return } response.JSON(w, http.StatusOK, map[string]string{"message": "已删除"}) } func (h *LLMChatHandler) BatchDeleteConversations(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "id") userID := middleware.GetUserID(r.Context()) var req struct { ConversationIDs []string `json:"conversation_ids"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.ConversationIDs) == 0 { response.BadRequest(w, "请提供要删除的对话ID列表") return } ids := make([]any, len(req.ConversationIDs)) placeholders := make([]string, len(req.ConversationIDs)) for i, id := range req.ConversationIDs { ids[i] = id placeholders[i] = fmt.Sprintf("$%d", i+3) } query := fmt.Sprintf( `DELETE FROM app_usage_logs WHERE app_id = $1 AND user_id = $2 AND conversation_id IN (%s)`, strings.Join(placeholders, ",")) args := append([]any{appID, userID}, ids...) result, err := h.pool.Exec(r.Context(), query, args...) if err != nil { response.InternalError(w, "批量删除失败") return } response.JSON(w, http.StatusOK, map[string]any{ "message": "已删除", "deleted": result.RowsAffected(), }) } func (h *LLMChatHandler) Feedback(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, map[string]string{"message": "反馈已收到"}) } // ==================== PPT 生成聊天处理 ==================== func (h *LLMChatHandler) handlePPTChat(w http.ResponseWriter, r *http.Request, appID, userID, message, existingConvID string) { convID := existingConvID if convID == "" { convID = uuid.New().String() } msgID := uuid.New().String() taskID := uuid.New().String() // 解析用户消息,提取标题和内容 title, sourceType, sourceContent := h.parsePPTMessage(message) configJSON, _ := json.Marshal(map[string]any{ "format": "ppt169", "page_count": 10, "style": "general", "language": "zh", }) // 写入 ppt_tasks 表 _, err := h.pool.Exec(r.Context(), `INSERT INTO ppt_tasks (id, user_id, title, source_type, source_content, config) VALUES ($1, $2, $3, $4, $5, $6)`, taskID, userID, title, sourceType, sourceContent, configJSON, ) if err != nil { response.InternalError(w, "创建 PPT 任务失败: "+err.Error()) return } // 推送到 Redis 队列 taskMsg, _ := json.Marshal(map[string]string{"task_id": taskID}) h.rdb.LPush(r.Context(), "ppt:tasks", taskMsg) // 设置 SSE 流式响应 w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { response.InternalError(w, "Streaming not supported") return } // 发送首个事件(conversation_id + message_id) firstEvent := map[string]string{"conversation_id": convID, "message_id": msgID} data, _ := json.Marshal(firstEvent) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() // 发送初始消息 var fullResponse strings.Builder h.sendPPTEvent(w, flusher, &fullResponse, msgID, "📊 PPT 生成任务已创建,正在处理中...\n\n") startTime := time.Now() lastStatus := "" lastProgress := 0 // 轮询任务状态 ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() timeout := time.After(10 * time.Minute) for { select { case <-r.Context().Done(): return case <-timeout: h.sendPPTEvent(w, flusher, &fullResponse, msgID, "\n\n⏱️ 任务超时,请稍后在任务列表中查看结果。") goto done case <-ticker.C: status, progress, statusMsg := h.pollPPTStatus(r.Context(), taskID) if status != lastStatus || progress != lastProgress { lastStatus = status lastProgress = progress progressBar := h.formatProgress(progress) line := fmt.Sprintf("**[%d%%]** %s %s\n", progress, progressBar, statusMsg) h.sendPPTEvent(w, flusher, &fullResponse, msgID, line) } if status == "completed" { downloadURL := fmt.Sprintf("/api/v1/ppt/tasks/%s/download", taskID) finalMsg := fmt.Sprintf("\n\n✅ **PPT 生成完成!**\n\n📥 [点击下载 PPTX 文件](%s)\n\n> 提示:也可在「PPT 任务列表」中找到此文件。", downloadURL) h.sendPPTEvent(w, flusher, &fullResponse, msgID, finalMsg) goto done } if status == "failed" { h.sendPPTEvent(w, flusher, &fullResponse, msgID, "\n\n❌ **PPT 生成失败**,请检查输入内容后重试。") goto done } } } done: fmt.Fprintf(w, "data: [DONE]\n\n") flusher.Flush() duration := time.Since(startTime).Milliseconds() go h.recordUsage(appID, userID, convID, message, fullResponse.String(), 0, "ppt-generator", duration) } func (h *LLMChatHandler) sendPPTEvent(w http.ResponseWriter, flusher http.Flusher, fullResp *strings.Builder, msgID, text string) { fullResp.WriteString(text) event := map[string]any{ "event": "message", "answer": text, "message_id": msgID, } data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } func (h *LLMChatHandler) parsePPTMessage(message string) (title, sourceType, sourceContent string) { sourceType = "text" sourceContent = message // 检测 URL if strings.HasPrefix(message, "http://") || strings.HasPrefix(message, "https://") { lines := strings.SplitN(message, "\n", 2) sourceType = "url" sourceContent = strings.TrimSpace(lines[0]) if len(lines) > 1 { title = strings.TrimSpace(lines[1]) } if title == "" { title = "网页内容PPT" } return } // 从文本中提取标题(取第一行或前30字) lines := strings.SplitN(message, "\n", 2) title = strings.TrimSpace(lines[0]) runes := []rune(title) if len(runes) > 30 { title = string(runes[:30]) } if title == "" { title = "AI生成PPT" } return } func (h *LLMChatHandler) pollPPTStatus(ctx context.Context, taskID string) (status string, progress int, statusMsg string) { // 先查 Redis key := "ppt:status:" + taskID cached, err := h.rdb.HGetAll(ctx, key).Result() if err == nil && len(cached) > 0 { status = cached["status"] fmt.Sscanf(cached["progress"], "%d", &progress) statusMsg = cached["message"] return } // 回退到数据库 var dbStatus string var dbProgress int var dbMsg *string err = h.pool.QueryRow(ctx, `SELECT status, progress, status_message FROM ppt_tasks WHERE id = $1`, taskID, ).Scan(&dbStatus, &dbProgress, &dbMsg) if err != nil { return "pending", 0, "等待处理..." } status = dbStatus progress = dbProgress if dbMsg != nil { statusMsg = *dbMsg } return } func (h *LLMChatHandler) formatProgress(progress int) string { filled := progress / 5 if filled > 20 { filled = 20 } empty := 20 - filled return "▓" + strings.Repeat("█", filled) + strings.Repeat("░", empty) + "▓" } // ==================== 通用工具方法 ==================== func (h *LLMChatHandler) recordUsage(appID, userID, convID, userMessage, aiResponse string, tokens int, model string, durationMs int64) { ctx := context.Background() _, _ = h.pool.Exec(ctx, ` INSERT INTO app_usage_logs (app_id, user_id, conversation_id, user_message, ai_response, total_tokens, model_name, duration_ms, client_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'web')`, appID, userID, convID, userMessage, aiResponse, tokens, model, durationMs) _, _ = h.pool.Exec(ctx, `UPDATE applications SET usage_count = usage_count + 1 WHERE id = $1`, appID) } // generateConversationName 使用LLM为新对话生成简短标题 func (h *LLMChatHandler) generateConversationName(appID, userID, convID, userMessage string) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // 检查是否已有自定义名称 var existing string err := h.pool.QueryRow(ctx, `SELECT name FROM conversation_names WHERE app_id=$1 AND user_id=$2 AND conversation_id=$3`, appID, userID, convID).Scan(&existing) if err == nil && existing != "" { return } // 截取用户消息前200字符用于生成标题 msg := userMessage runes := []rune(msg) if len(runes) > 200 { msg = string(runes[:200]) } nameReq := &llm.ChatRequest{ Model: "", Messages: []llm.Message{ {Role: "system", Content: "请用10个字以内为以下对话内容生成一个简短标题。只输出标题文字,不要引号、标点或解释。"}, {Role: "user", Content: msg}, }, Temperature: 0.3, MaxTokens: 30, Stream: false, } // 获取激活的 provider 和默认模型 provider, defaultModel, err := h.getProviderWithModel(ctx) if err != nil { log.Warn().Err(err).Msg("get provider for title generation failed") return } // 使用 Provider 默认模型 if defaultModel != "" { nameReq.Model = defaultModel } result, err := h.manager.Chat(ctx, provider, nameReq) if err != nil { return } name := strings.TrimSpace(result.Content) nameRunes := []rune(name) if len(nameRunes) > 20 { name = string(nameRunes[:20]) } if name == "" { return } _, _ = h.pool.Exec(ctx, ` INSERT INTO conversation_names (app_id, user_id, conversation_id, name, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (app_id, user_id, conversation_id) DO UPDATE SET name = EXCLUDED.name, updated_at = now()`, appID, userID, convID, name) } // GenerateSuggestions 生成追问建议(由 LLM 智能生成) func (h *LLMChatHandler) GenerateSuggestions(ctx context.Context, conversationID, appID string, messages []map[string]string) ([]string, error) { if len(messages) == 0 { return nil, nil } // 构建对话历史上下文 var historyBuilder strings.Builder for i, msg := range messages { role := "用户" if msg["role"] == "assistant" { role = "助手" } historyBuilder.WriteString(fmt.Sprintf("%d. %s:%s\n", i+1, role, msg["content"])) } systemPrompt := `你是一个智能政务问答助手。请根据对话历史,生成4个追问问题,帮助用户深入了解相关内容。 要求: 1. 问题必须与对话内容紧密相关 2. 每个问题不超过20个字 3. 问题要有深度,引导用户进一步思考 4. 避免重复或过于宽泛的问题 5. 直接输出问题,用换行分隔,不要编号,不要解释` suggestReq := &llm.ChatRequest{ Model: "", Messages: []llm.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: "对话历史:\n" + historyBuilder.String()}, }, Temperature: 0.7, MaxTokens: 200, Stream: false, } // 获取激活的 provider provider, defaultModel, err := h.getProviderWithModel(ctx) if err != nil { return nil, err } if defaultModel != "" { suggestReq.Model = defaultModel } result, err := h.manager.Chat(ctx, provider, suggestReq) if err != nil { return nil, err } // 解析结果:按换行分割 suggestions := strings.Split(strings.TrimSpace(result.Content), "\n") var validSuggestions []string for _, s := range suggestions { s = strings.TrimSpace(s) // 过滤空行和过长的行 if len(s) > 0 && len([]rune(s)) <= 25 { validSuggestions = append(validSuggestions, s) } } // 最多返回4个 if len(validSuggestions) > 4 { validSuggestions = validSuggestions[:4] } return validSuggestions, nil } // GetSuggestions HTTP handler:获取追问建议 func (h *LLMChatHandler) GetSuggestions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() appID := chi.URLParam(r, "id") if appID == "" { response.Error(w, http.StatusBadRequest, 40001, "缺少应用ID") return } var req struct { ConversationID string `json:"conversation_id"` Messages []map[string]string `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, 40002, "请求格式错误") return } suggestions, err := h.GenerateSuggestions(ctx, req.ConversationID, appID, req.Messages) if err != nil { log.Warn().Err(err).Msg("generate suggestions failed") response.Error(w, http.StatusInternalServerError, 50001, "生成追问失败") return } response.JSON(w, http.StatusOK, suggestions) }