feat: 增强聊天功能和 markdown 渲染

- 优化聊天 UI 组件交互体验
- 扩展 markdown 渲染功能支持
- 更新类型定义
- 重构 LLM 聊天处理逻辑
- 更新模型提供商种子数据
This commit is contained in:
freedakgmail
2026-06-23 00:46:08 +08:00
parent 95dee5a70e
commit d31bdd1261
5 changed files with 424 additions and 160 deletions
+8 -2
View File
@@ -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
View File
@@ -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("- %sslug: %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)