@@ -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 )