feat: 增强聊天功能和 markdown 渲染
- 优化聊天 UI 组件交互体验 - 扩展 markdown 渲染功能支持 - 更新类型定义 - 重构 LLM 聊天处理逻辑 - 更新模型提供商种子数据
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { App, Conversation, Message } from "@/lib/types";
|
import type { App, Conversation, Message, Chunk } from "@/lib/types";
|
||||||
import api, { streamChat } from "@/lib/api";
|
import api, { streamChat } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -43,9 +43,11 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
const ChatMessage = memo(function ChatMessage({
|
const ChatMessage = memo(function ChatMessage({
|
||||||
msg,
|
msg,
|
||||||
onCopy,
|
onCopy,
|
||||||
|
chunks,
|
||||||
}: {
|
}: {
|
||||||
msg: Message;
|
msg: Message;
|
||||||
onCopy: (text: string) => void;
|
onCopy: (text: string) => void;
|
||||||
|
chunks: Chunk[];
|
||||||
}) {
|
}) {
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +72,7 @@ const ChatMessage = memo(function ChatMessage({
|
|||||||
<div className="flex justify-start group/msg">
|
<div className="flex justify-start group/msg">
|
||||||
<div className="max-w-[85%]">
|
<div className="max-w-[85%]">
|
||||||
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
||||||
<GovMarkdown content={msg.content} />
|
<GovMarkdown content={msg.content} chunks={chunks} />
|
||||||
</div>
|
</div>
|
||||||
{msg.content && (
|
{msg.content && (
|
||||||
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||||
@@ -112,6 +114,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [chunks, setChunks] = useState<Chunk[]>([]);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -269,6 +272,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
setInput("");
|
setInput("");
|
||||||
setFileContent(null);
|
setFileContent(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
|
setChunks([]);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -302,6 +306,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
const event = JSON.parse(raw);
|
const event = JSON.parse(raw);
|
||||||
if (event.conversation_id)
|
if (event.conversation_id)
|
||||||
setConversationId(event.conversation_id);
|
setConversationId(event.conversation_id);
|
||||||
|
// 解析首包的 chunks 映射表
|
||||||
|
if (event.chunks) {
|
||||||
|
console.log("[SSE] Received chunks:", event.chunks);
|
||||||
|
setChunks(event.chunks as Chunk[]);
|
||||||
|
}
|
||||||
if (event.answer) {
|
if (event.answer) {
|
||||||
accumulated += event.answer;
|
accumulated += event.answer;
|
||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
@@ -687,7 +696,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} />
|
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} chunks={chunks} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
|||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Chunk } from "@/lib/types";
|
||||||
|
|
||||||
const mdComponents: Components = {
|
const mdComponents: Components = {
|
||||||
h1: ({ children }) => (
|
h1: ({ children }) => (
|
||||||
@@ -153,6 +154,7 @@ function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
|
|||||||
interface GovMarkdownProps {
|
interface GovMarkdownProps {
|
||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
chunks?: Chunk[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripOuterCodeFence(text: string): string {
|
function stripOuterCodeFence(text: string): string {
|
||||||
@@ -164,22 +166,50 @@ function stripOuterCodeFence(text: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
||||||
|
*
|
||||||
|
* 支持的标注格式:
|
||||||
|
* - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名)
|
||||||
|
* - [[知识库:文档名]] → 知识库引用徽章
|
||||||
|
* - [[AI建议]] → AI建议徽章
|
||||||
|
* - [[推荐应用:名称:slug]] → 可点击跳转链接
|
||||||
*/
|
*/
|
||||||
function preprocessCitations(content: string): string {
|
function preprocessCitations(content: string, chunks?: Chunk[]): string {
|
||||||
return content
|
let result = content;
|
||||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
console.log("[preprocessCitations] chunks:", chunks);
|
||||||
.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`)
|
|
||||||
// AI建议:标准格式 [[AI建议]]
|
// 先处理 chunk 编号引用 [[chunk:N]]
|
||||||
.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)")
|
result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => {
|
||||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
const i = parseInt(idx, 10);
|
||||||
.replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)")
|
if (chunks && chunks[i]) {
|
||||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
// 使用 chunks 映射表,转换为带文档名的知识库引用
|
||||||
.replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)")
|
const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段";
|
||||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
console.log(`[preprocessCitations] chunk[${i}]:`, docName);
|
||||||
.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
return `[知识库:${docName}](#cite-kb)`;
|
||||||
|
}
|
||||||
|
// 无映射时显示后备文本
|
||||||
|
console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length);
|
||||||
|
return `[知识库片段](#cite-kb)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||||
|
result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`);
|
||||||
|
|
||||||
|
// AI建议:标准格式 [[AI建议]]
|
||||||
|
result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||||
|
result = result.replace(/^(\s*\>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||||
|
result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||||
|
result = result.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdownProps) {
|
const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
||||||
@@ -188,7 +218,7 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const cleaned = preprocessCitations(stripOuterCodeFence(content));
|
const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks);
|
||||||
return (
|
return (
|
||||||
<div className={`gov-markdown ${className || ""}`}>
|
<div className={`gov-markdown ${className || ""}`}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||||
@@ -198,4 +228,4 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default GovMarkdown;
|
export default GovMarkdown;
|
||||||
@@ -93,6 +93,14 @@ export interface Message {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 知识库片段映射,来自 SSE 首包
|
||||||
|
export interface Chunk {
|
||||||
|
id: string;
|
||||||
|
doc_name: string;
|
||||||
|
content: string;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeleteTarget {
|
export interface DeleteTarget {
|
||||||
type: "single" | "batch";
|
type: "single" | "batch";
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ func main() {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
// 插入阿里云百炼(通义千问)配置
|
// 插入阿里云百炼(通义千问)配置
|
||||||
|
apiKey := os.Getenv("QWEN_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Println("⚠️ QWEN_API_KEY 未设置,跳过初始化千问")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sql := `
|
sql := `
|
||||||
INSERT INTO model_providers (
|
INSERT INTO model_providers (
|
||||||
name,
|
name,
|
||||||
@@ -38,7 +44,7 @@ func main() {
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
'阿里云百炼 (通义千问)',
|
'阿里云百炼 (通义千问)',
|
||||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
'sk-c0c5174892c44ff48d587cd040fbdd40',
|
$1,
|
||||||
'[
|
'[
|
||||||
{"id": "qwen-plus", "name": "通义千问-Plus", "type": "chat"},
|
{"id": "qwen-plus", "name": "通义千问-Plus", "type": "chat"},
|
||||||
{"id": "qwen-turbo", "name": "通义千问-Turbo", "type": "chat"},
|
{"id": "qwen-turbo", "name": "通义千问-Turbo", "type": "chat"},
|
||||||
@@ -57,7 +63,7 @@ func main() {
|
|||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = pool.Exec(ctx, sql)
|
_, err = pool.Exec(ctx, sql, apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("插入数据失败: %v", err)
|
log.Fatalf("插入数据失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-140
@@ -52,6 +52,14 @@ type llmChatRequest struct {
|
|||||||
ConversationID string `json:"conversation_id,omitempty"`
|
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 {
|
type appCfg struct {
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
Model string
|
Model string
|
||||||
@@ -186,48 +194,83 @@ func cleanQueryForSearch(query string) []string {
|
|||||||
return result
|
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 表)
|
// 1. 尝试向量语义搜索(基于 knowledge_chunks 表)
|
||||||
if h.embedder != nil && h.embedder.IsConfigured() {
|
if h.embedder != nil && h.embedder.IsConfigured() {
|
||||||
vectorResults := h.vectorSearch(ctx, kbID, query, limit)
|
vectorChunks := h.vectorSearch(ctx, kbID, query, searchLimit)
|
||||||
if len(vectorResults) > 0 {
|
if len(vectorChunks) > 0 {
|
||||||
parts = append(parts, vectorResults...)
|
allChunks = append(allChunks, vectorChunks...)
|
||||||
log.Debug().Int("vector_results", len(vectorResults)).Msg("vector search completed")
|
for _, c := range vectorChunks {
|
||||||
|
seenIDs[c.ID] = true
|
||||||
|
}
|
||||||
|
log.Debug().Int("vector_results", len(vectorChunks)).Msg("vector search completed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 关键词搜索补充(从 knowledge_chunks 或 knowledge_documents)
|
// 2. 关键词搜索补充(去重)
|
||||||
keywordResults := h.keywordSearch(ctx, kbID, query, limit)
|
// 只在向量搜索不足时补充关键词结果
|
||||||
for _, kr := range keywordResults {
|
if len(allChunks) < searchLimit {
|
||||||
// 去重:检查是否已在向量结果中
|
remainingLimit := searchLimit - len(allChunks)
|
||||||
duplicate := false
|
keywordChunks := h.keywordSearch(ctx, kbID, query, remainingLimit)
|
||||||
for _, existing := range parts {
|
for _, c := range keywordChunks {
|
||||||
if existing == kr {
|
if !seenIDs[c.ID] {
|
||||||
duplicate = true
|
allChunks = append(allChunks, c)
|
||||||
break
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制总结果数
|
return sb.String()
|
||||||
if len(parts) > limit {
|
|
||||||
parts = parts[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n\n---\n\n"), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// vectorSearch 向量语义搜索(基于 knowledge_chunks + pgvector)
|
// 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)
|
queryEmbedding, err := h.embedder.GetEmbedding(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("query embedding failed, falling back to keyword search")
|
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)
|
vecStr := float32SliceToVectorStr(queryEmbedding)
|
||||||
|
|
||||||
rows, err := h.pool.Query(ctx, `
|
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
|
1 - (kc.embedding <=> $2::vector) AS similarity
|
||||||
FROM knowledge_chunks kc
|
FROM knowledge_chunks kc
|
||||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||||
WHERE kc.kb_id = $1
|
WHERE kc.kb_id = $1
|
||||||
AND kc.embedding IS NOT NULL
|
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
|
ORDER BY kc.embedding <=> $2::vector
|
||||||
LIMIT $3`,
|
LIMIT CASE WHEN $3 <= 0 THEN 999999 ELSE $3 END`,
|
||||||
kbID, vecStr, limit)
|
kbID, vecStr, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("vector search query failed")
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
var results []string
|
var chunks []knowledgeChunk
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var content, docName string
|
var chunk knowledgeChunk
|
||||||
var similarity float64
|
if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName, &chunk.Similarity); err != nil {
|
||||||
if err := rows.Scan(&content, &docName, &similarity); err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trimmed := content
|
// 截断过长内容
|
||||||
if len([]rune(trimmed)) > 2000 {
|
if len([]rune(chunk.Content)) > 2000 {
|
||||||
trimmed = string([]rune(trimmed)[: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%显示
|
// enhanceCitations 后处理:自动为回答添加来源标注徽章,确保100%显示
|
||||||
@@ -487,40 +529,195 @@ func (h *LLMChatHandler) generateSourceSummary(hasKnowledge bool, knowledgeSourc
|
|||||||
return summary.String()
|
return summary.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractKnowledgeSources 从知识库检索结果中提取文献名称
|
// enhanceCitationsWithChunks 基于 chunk 映射精确标注来源
|
||||||
func (h *LLMChatHandler) extractKnowledgeSources(knowledgeContext string) []string {
|
// 工作原理:
|
||||||
if knowledgeContext == "" {
|
// 1. LLM 生成回答时使用 [[chunk:N]] 标注引用了哪段知识库原文
|
||||||
return nil
|
// 2. 后处理将 [[chunk:N]] 转换为 [[知识库:文档名]]
|
||||||
|
// 3. 未标注的句子添加 [[AI建议]]
|
||||||
|
func (h *LLMChatHandler) enhanceCitationsWithChunks(response string, hasKnowledge bool, chunks []knowledgeChunk) string {
|
||||||
|
if response == "" {
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
var sources []string
|
// 构建 chunk index → 文档名的映射
|
||||||
seen := make(map[string]bool)
|
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 {
|
for _, line := range lines {
|
||||||
if strings.Contains(line, "【") && strings.Contains(line, "】") {
|
trimmed := strings.TrimSpace(line)
|
||||||
start := strings.Index(line, "【")
|
|
||||||
end := strings.Index(line, "】")
|
// 检测代码块
|
||||||
if start < end && start >= 0 {
|
if strings.HasPrefix(trimmed, "```") {
|
||||||
source := line[start+len("【") : end]
|
inCodeBlock = !inCodeBlock
|
||||||
// 去除相似度等后缀
|
enhanced = append(enhanced, line)
|
||||||
if idx := strings.Index(source, " ·"); idx > 0 {
|
continue
|
||||||
source = source[:idx]
|
}
|
||||||
}
|
|
||||||
if !seen[source] && source != "" {
|
if inCodeBlock {
|
||||||
sources = append(sources, source)
|
enhanced = append(enhanced, line)
|
||||||
seen[source] = true
|
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)
|
// 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)
|
keywords := cleanQueryForSearch(query)
|
||||||
if len(keywords) == 0 {
|
if len(keywords) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -542,7 +739,7 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
|||||||
args = append(args, limit)
|
args = append(args, limit)
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
sql := fmt.Sprintf(`
|
||||||
SELECT kc.content, kd.name
|
SELECT kc.id, kc.content, kd.name
|
||||||
FROM knowledge_chunks kc
|
FROM knowledge_chunks kc
|
||||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||||
WHERE kc.kb_id = $1
|
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...)
|
rows, err := h.pool.Query(ctx, sql, args...)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var results []string
|
var chunks []knowledgeChunk
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var content, docName string
|
var chunk knowledgeChunk
|
||||||
if err := rows.Scan(&content, &docName); err != nil {
|
if err := rows.Scan(&chunk.ID, &chunk.Content, &chunk.DocName); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trimmed := content
|
if len([]rune(chunk.Content)) > 2000 {
|
||||||
if len([]rune(trimmed)) > 2000 {
|
chunk.Content = string([]rune(chunk.Content)[:2000]) + "..."
|
||||||
trimmed = string([]rune(trimmed)[:2000]) + "..."
|
|
||||||
}
|
}
|
||||||
results = append(results, fmt.Sprintf("【%s】\n%s", docName, trimmed))
|
chunk.Similarity = 0.5 // 关键词搜索默认相似度
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
}
|
}
|
||||||
if len(results) > 0 {
|
if len(chunks) > 0 {
|
||||||
return results
|
return chunks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +780,7 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
|||||||
args2 = append(args2, limit)
|
args2 = append(args2, limit)
|
||||||
|
|
||||||
sql2 := fmt.Sprintf(`
|
sql2 := fmt.Sprintf(`
|
||||||
SELECT name, content
|
SELECT id, name, content
|
||||||
FROM knowledge_documents
|
FROM knowledge_documents
|
||||||
WHERE kb_id = $1
|
WHERE kb_id = $1
|
||||||
AND content IS NOT NULL AND content != ''
|
AND content IS NOT NULL AND content != ''
|
||||||
@@ -597,19 +794,23 @@ func (h *LLMChatHandler) keywordSearch(ctx context.Context, kbID, query string,
|
|||||||
}
|
}
|
||||||
defer rows2.Close()
|
defer rows2.Close()
|
||||||
|
|
||||||
var results []string
|
var chunks []knowledgeChunk
|
||||||
for rows2.Next() {
|
for rows2.Next() {
|
||||||
var name, content string
|
var id, name, content string
|
||||||
if err := rows2.Scan(&name, &content); err != nil {
|
if err := rows2.Scan(&id, &name, &content); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
trimmed := content
|
if len([]rune(content)) > 3000 {
|
||||||
if len([]rune(trimmed)) > 3000 {
|
content = string([]rune(content)[:3000]) + "..."
|
||||||
trimmed = string([]rune(trimmed)[: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 {
|
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
|
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
|
var msgs []llm.Message
|
||||||
|
|
||||||
finalSystem := systemPrompt
|
finalSystem := systemPrompt
|
||||||
|
|
||||||
// 注入同机构应用路由表(用于超范围引导跳转)
|
// 注入同机构应用路由表(用于超范围引导跳转)
|
||||||
if len(sameOrgApps) > 0 && len(sameOrgApps[0]) > 0 {
|
if len(orgApps) > 0 {
|
||||||
finalSystem += "\n\n## 超范围引导(必须遵守)\n\n"
|
finalSystem += "\n\n## 超范围引导(必须遵守)\n\n"
|
||||||
finalSystem += "当用户的问题不在本应用的处理范围内时,你必须:\n"
|
finalSystem += "当用户的问题不在本应用的处理范围内时,你必须:\n"
|
||||||
finalSystem += "1. 明确告知用户该问题不在本应用处理范围内\n"
|
finalSystem += "1. 明确告知用户该问题不在本应用处理范围内\n"
|
||||||
@@ -657,7 +859,7 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
|||||||
finalSystem += " [[推荐应用:应用名称:应用slug]]\n"
|
finalSystem += " [[推荐应用:应用名称:应用slug]]\n"
|
||||||
finalSystem += "3. 绝不可对不属于本应用职责的问题强行生成回答\n\n"
|
finalSystem += "3. 绝不可对不属于本应用职责的问题强行生成回答\n\n"
|
||||||
finalSystem += "本机构可用的应用列表:\n"
|
finalSystem += "本机构可用的应用列表:\n"
|
||||||
for _, app := range sameOrgApps[0] {
|
for _, app := range orgApps {
|
||||||
finalSystem += fmt.Sprintf("- %s(slug: %s)\n", app.Name, app.Slug)
|
finalSystem += fmt.Sprintf("- %s(slug: %s)\n", app.Name, app.Slug)
|
||||||
}
|
}
|
||||||
finalSystem += "\n推荐示例:建议使用 [[推荐应用:法律咨询助手:legal-consult]] 来处理此类问题。\n"
|
finalSystem += "\n推荐示例:建议使用 [[推荐应用:法律咨询助手:legal-consult]] 来处理此类问题。\n"
|
||||||
@@ -698,69 +900,53 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
|||||||
你的回答中**每一句话、每一个观点、每一个列表项**都必须在句子末尾标注来源徽章。这是最高优先级要求,必须100%执行,不允许遗漏。
|
你的回答中**每一句话、每一个观点、每一个列表项**都必须在句子末尾标注来源徽章。这是最高优先级要求,必须100%执行,不允许遗漏。
|
||||||
|
|
||||||
**格式1:知识库引用(蓝色徽章)**
|
**格式1:知识库引用(蓝色徽章)**
|
||||||
在引用知识库内容的句子末尾加:[[知识库:文献名称]]
|
在引用知识库原文时,句子末尾加:[[chunk:数字]]
|
||||||
|
例如:[[chunk:0]] 表示来自编号为0的知识库原文
|
||||||
|
|
||||||
示例:
|
⚠️ **关键限制:只能引用存在的chunk索引!**
|
||||||
- 居住证办理需要身份证、居住证明和近期照片 [[知识库:户口登记管理规定]]
|
检查过程中只能根据实际检索到的知识库原文内容来标注chunk索引。绝对不能编造或推测不存在的chunk索引号。如果知识库中只检索到3个chunks(编号0-2),就只能使用 [[chunk:0]]、[[chunk:1]]、[[chunk:2]],绝对禁止虚构 [[chunk:3]]、[[chunk:4]] 等索引。
|
||||||
- 办理时限为15个工作日 [[知识库:户口登记管理规定:第十二条]]
|
|
||||||
|
**🔥 严格要求:**
|
||||||
|
- 你的每一句话都必须来自提供的知识库chunks或AI推理
|
||||||
|
- 如果你引用的信息不在任何chunk中,就必须标注 [[AI建议]]
|
||||||
|
- 绝对禁止编造知识库中不存在的内容或使用不存在的chunk索引
|
||||||
|
- 如果知识库的chunks与用户问题关联度不高,要诚实地说明,而不是强行拼凑
|
||||||
|
|
||||||
**格式2:AI分析补充(橙色徽章)**
|
**格式2:AI分析补充(橙色徽章)**
|
||||||
任何解读、分析、建议、注意事项等非知识库原文的内容,句末加:[[AI建议]]
|
任何解读、分析、建议、注意事项等非知识库原文的内容,句末加:[[AI建议]]
|
||||||
|
|
||||||
示例:
|
|
||||||
- 建议您提前准备齐全材料,以免多次往返 [[AI建议]]
|
|
||||||
- 如有疑问可先电话咨询当地派出所 [[AI建议]]
|
|
||||||
|
|
||||||
### 📝 完整示例(必须参照此格式)
|
### 📝 完整示例(必须参照此格式)
|
||||||
|
|
||||||
**用户提问:** "居住证办理条件是什么?多久能拿到?"
|
**用户提问:** "居住证办理条件是什么?"
|
||||||
|
|
||||||
**标准回答格式:**
|
**❌ 错误格式(绝对禁止):**
|
||||||
|
- ~~在居住地居住半年以上(居住证管理办法.pdf)~~ ❌ 不能用文档名
|
||||||
|
- ~~有合法稳定就业(知识库)~~ ❌ 不能用泛指
|
||||||
|
- ~~连续就读~~ ❌ 不能不标注
|
||||||
|
|
||||||
## 居住证办理条件及办理时限
|
**✅ 正确格式(必须遵守):**
|
||||||
|
|
||||||
### 办理条件
|
## 居住证办理条件
|
||||||
|
|
||||||
在居住地居住半年以上,同时满足以下条件之一 [[知识库:户口登记管理规定]]:
|
在居住地居住半年以上,同时满足以下条件之一 [[chunk:0]]:
|
||||||
|
|
||||||
- 有合法稳定就业 [[知识库:户口登记管理规定]]
|
- 有合法稳定就业 [[chunk:0]]
|
||||||
- 有合法稳定住所 [[知识库:户口登记管理规定]]
|
- 有合法稳定住所 [[chunk:0]]
|
||||||
- 连续就读 [[知识库:户口登记管理规定]]
|
- 连续就读 [[chunk:0]]
|
||||||
|
|
||||||
所需材料包括 [[知识库:户口登记管理规定]]:
|
|
||||||
- 身份证
|
|
||||||
- 居住证明(租房合同/房产证/单位证明)
|
|
||||||
- 近期照片
|
|
||||||
|
|
||||||
### 办理时限
|
|
||||||
|
|
||||||
办理居住证的时限为**15个工作日** [[知识库:户口登记管理规定]]。具体流程如下 [[AI建议]]:
|
|
||||||
|
|
||||||
1. 到居住地的任一户籍派出所提交申请材料 [[AI建议]]
|
|
||||||
2. 派出所审核材料,符合条件的予以受理 [[AI建议]]
|
|
||||||
3. 派出所将相关信息录入系统并报上级审核 [[AI建议]]
|
|
||||||
4. 审核通过后,居住证将在15个工作日内制作完成并发放 [[AI建议]]
|
|
||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
|
|
||||||
请确保提供的材料真实有效,并按要求准备齐全 [[AI建议]]。如有任何疑问或材料不齐全的情况,建议及时与当地户籍派出所联系确认 [[AI建议]]。
|
请确保提供的材料真实有效 [[AI建议]]。如有疑问可先电话咨询当地派出所 [[AI建议]]。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **来源说明**
|
> **来源说明**
|
||||||
>
|
>
|
||||||
> **知识库引用:**
|
> **知识库引用:**
|
||||||
> - 【户口登记管理规定】:第五条规定,办理居住证需在居住地居住半年以上,并满足合法稳定就业、合法稳定住所或连续就读条件之一;需提供身份证、居住证明和近期照片。第十二条规定,办理时限为自受理之日起15个工作日内制作完成并发放。
|
> - 【xxx法规】:原文内容摘录
|
||||||
>
|
|
||||||
> **AI建议:**
|
|
||||||
> - 办理流程的四个步骤说明
|
|
||||||
> - 材料准备的注意事项和建议
|
|
||||||
|
|
||||||
### ✅ 输出前必检项(每次回答前自查)
|
> **AI建议:**
|
||||||
- [ ] 正文中所有知识库引用都标注了 [[知识库:文献名称]]
|
> - 办理流程说明和注意事项
|
||||||
- [ ] 正文中所有AI分析都标注了 [[AI建议]]
|
|
||||||
- [ ] 末尾有完整的来源汇总块
|
|
||||||
- [ ] 来源汇总中列出了知识库原文摘录
|
|
||||||
|
|
||||||
### 🚨 关键提醒
|
### 🚨 关键提醒
|
||||||
- 如果你的回答中有任何句子、列表项、问题没有标注来源,系统会自动补充标注
|
- 如果你的回答中有任何句子、列表项、问题没有标注来源,系统会自动补充标注
|
||||||
@@ -769,7 +955,8 @@ func (h *LLMChatHandler) buildMessages(systemPrompt, knowledgeContext string, ha
|
|||||||
|
|
||||||
`
|
`
|
||||||
if knowledgeContext != "" {
|
if knowledgeContext != "" {
|
||||||
finalSystem += "### 📚 知识库检索结果\n\n以下是从知识库中检索到的相关文献,请优先基于这些内容回答,并在每个引用处标注 [[知识库:文献名称]]:\n\n" + knowledgeContext
|
// 知识库上下文已在 retrieveKnowledge.buildChunkContext 中格式化为 [chunk:N] 格式
|
||||||
|
finalSystem += "### 📚 知识库检索结果\n\n" + knowledgeContext + "\n"
|
||||||
} else {
|
} else {
|
||||||
finalSystem += "### 📚 知识库检索结果\n\n⚠️ 当前知识库中未检索到与用户问题直接相关的文献。请使用AI知识回答,**每句话后都必须标注 [[AI建议]]**。\n"
|
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, 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
|
return msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,11 +1039,14 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
|
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
|
||||||
|
var chunks []knowledgeChunk
|
||||||
var knowledgeCtx string
|
var knowledgeCtx string
|
||||||
var kbSources []string
|
|
||||||
if hasKB {
|
if hasKB {
|
||||||
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
|
var err error
|
||||||
kbSources = h.extractKnowledgeSources(knowledgeCtx)
|
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{
|
llmReq := &llm.ChatRequest{
|
||||||
Model: modelToUse,
|
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,
|
Temperature: cfg.Temp,
|
||||||
MaxTokens: cfg.MaxTok,
|
MaxTokens: cfg.MaxTok,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
@@ -911,7 +1109,12 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
var modelName string
|
var modelName string
|
||||||
var fullResponse strings.Builder
|
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)
|
data, _ := json.Marshal(firstEvent)
|
||||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@@ -940,8 +1143,8 @@ func (h *LLMChatHandler) Chat(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
// 后处理:自动增强来源标注
|
// 后处理:自动增强来源标注(使用 chunk 映射精确标注)
|
||||||
enhancedResponse := h.enhanceCitations(fullResponse.String(), hasKB, kbSources)
|
enhancedResponse := h.enhanceCitationsWithChunks(fullResponse.String(), hasKB, chunks)
|
||||||
|
|
||||||
duration := time.Since(startTime).Milliseconds()
|
duration := time.Since(startTime).Milliseconds()
|
||||||
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
|
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 != ""
|
hasKB := cfg.KnowledgeBaseID != nil && *cfg.KnowledgeBaseID != ""
|
||||||
|
var chunks []knowledgeChunk
|
||||||
var knowledgeCtx string
|
var knowledgeCtx string
|
||||||
var kbSources []string
|
|
||||||
if hasKB {
|
if hasKB {
|
||||||
knowledgeCtx, _ = h.retrieveKnowledge(r.Context(), *cfg.KnowledgeBaseID, req.Message, 3)
|
var err error
|
||||||
kbSources = h.extractKnowledgeSources(knowledgeCtx)
|
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{
|
llmReq := &llm.ChatRequest{
|
||||||
Model: modelToUse,
|
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,
|
Temperature: cfg.Temp,
|
||||||
MaxTokens: cfg.MaxTok,
|
MaxTokens: cfg.MaxTok,
|
||||||
Stream: true,
|
Stream: true,
|
||||||
@@ -1028,7 +1234,12 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
|
|||||||
var modelName string
|
var modelName string
|
||||||
var fullResponse strings.Builder
|
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)
|
data, _ := json.Marshal(firstEvent)
|
||||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
@@ -1057,8 +1268,8 @@ func (h *LLMChatHandler) Completion(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
// 后处理:自动增强来源标注
|
// 后处理:自动增强来源标注(使用 chunk 映射精确标注)
|
||||||
enhancedResponse := h.enhanceCitations(fullResponse.String(), hasKB, kbSources)
|
enhancedResponse := h.enhanceCitationsWithChunks(fullResponse.String(), hasKB, chunks)
|
||||||
|
|
||||||
duration := time.Since(startTime).Milliseconds()
|
duration := time.Since(startTime).Milliseconds()
|
||||||
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
|
go h.recordUsage(appID, userID.String(), convID, req.Message, enhancedResponse, totalTokens, modelName, duration)
|
||||||
|
|||||||
Reference in New Issue
Block a user