"""知识库问答服务 — 调用 knowledge/ 轻量检索 + Ollama 生成答案""" import logging import httpx from knowledge import ( OLLAMA_MODEL, OLLAMA_URL, SITE_URL, get_cached_answer, search as kb_search, set_cached_answer, ) logger = logging.getLogger("spam_guard") KNOWLEDGE_PROMPT = """你是收割机(go-harvest)文档助手。请根据知识库内容,给用户一个准确、紧凑、可执行的中文回答。 相关文档内容: {context_text} 用户问题:{query} 硬性要求(非常重要): 1. 只能使用上方知识库内容回答。不要编造镜像名、环境变量、端口、命令、配置文件或链接。 2. 如果知识库里有代码块/YAML/env/bash 示例,涉及配置时必须原样引用或忠实摘录,不要自行重写完整配置,不要改写变量名。 3. 如果资料没有给出完整示例,不要自己补全完整示例;应说明“文档未给出完整示例”,再列出文档中已有的片段。 4. 对 Go Harvest 安装配置,若资料中出现 `newptools/go-harvest:latest`、`EMAIL`、`TOKEN`、`GO_WEB_PORT`、`POSTGRES_DB` 等,请保持这些原名,不要改成其他名字。 5. 不要把常识当成文档内容。可以说“文档没有说明”,不能猜。 回答格式要求: 1. 尽量控制在 Telegram 单条消息内,优先 1200 字以内,最多不要超过 3000 字。 2. 使用 Telegram 兼容 Markdown 排版:标题用 *粗体*,列表用 - 或 1.,命令/变量用反引号,配置示例用代码块。 3. 如果问题是“怎么做/如何配置/怎么安装/报错怎么办”,请使用紧凑结构: *结论* *步骤* *关键配置/命令*(仅限资料中出现的内容) *注意* *文档* 4. 不要展开无关背景,不要重复用户问题,不要写长篇解释。 5. 如果知识库信息不足,要明确说“文档里没有直接说明”,只列可确认部分。 6. 最后列出相关文档链接,最多 3 个。""" def build_context(docs: list[dict], max_chars: int = 12000) -> str: """构建给 LLM 的上下文,保留来源、标题、分数。""" parts = [] used = 0 for i, d in enumerate(docs, 1): text = d.get("text", "").strip() if not text: continue block = ( f"【资料 {i}】\n" f"标题:{d.get('title', '')}\n" f"来源:{d.get('source', '')}\n" f"链接:{d.get('url', '')}\n" f"相关度:{d.get('score', 0):.2f}\n" f"内容:\n{text}\n" ) if used + len(block) > max_chars and parts: break parts.append(block) used += len(block) return "\n---\n".join(parts) def extract_verbatim_snippets(query: str, docs: list[dict], limit: int = 2, max_chars: int = 4200) -> str: """配置/安装类问题优先展示知识库原文代码块,避免 LLM 重写配置时幻觉。""" q = query.lower() if not any(k in q for k in ["compose", "docker", "postgres", "postgresql", "配置", "安装", "部署", "环境变量"]): return "" snippets = [] used = 0 need_postgres = any(k in q for k in ["postgres", "postgresql", "pgsql"]) for d in docs: text = d.get("text", "") text_lower = text.lower() if "[代码块]" not in text: continue # PostgreSQL 问题优先只展示 PostgreSQL 相关代码块,避免混入 SQLite 示例 if need_postgres and "postgres" not in text_lower: continue # 优先 yaml/env/bash/text 配置块 if not any(mark in text_lower for mark in ["```yaml", "```env", "```bash", "```text"]): continue block = text.replace("[代码块]", "").strip() rendered = ( f"📌 文档原文配置片段({d.get('title', '')})\n" f"来源:{d.get('url', '')}\n\n" f"{block}" ) if used + len(rendered) > max_chars and snippets: continue snippets.append(rendered) used += len(rendered) if len(snippets) >= limit: break if not snippets: return "" return "\n\n".join(snippets) def build_source_text(docs: list[dict], limit: int = 5) -> str: seen = [] for d in docs: url = d.get("url") title = d.get("doc_title") or d.get("title") or url if not url or url in [x[1] for x in seen]: continue seen.append((title, url)) if len(seen) >= limit: break return "\n".join([f"📎 {title}: {url}" for title, url in seen]) async def ask_knowledge_base(query: str) -> str: """知识库问答""" cached = get_cached_answer(query) if cached: logger.info("📦 命中问答缓存") return cached # top_k 提高 + search 内部扩展相邻 chunk,让回答不再只基于碎片 relevant = kb_search(query, top_k=8, expand=True, context_window=1) if not relevant: return f"❌ 未找到相关内容,请换个关键词试试\n\n文档: {SITE_URL}" context_text = build_context(relevant, max_chars=12000) prompt = KNOWLEDGE_PROMPT.format(context_text=context_text, query=query) try: import time as _time t0 = _time.time() logger.info(f"🤖 调用 LLM 生成回答... (模型: {OLLAMA_MODEL}, 上下文: {len(context_text)}字, 文档块: {len(relevant)})") async with httpx.AsyncClient(timeout=120, proxy=None) as client: resp = await client.post( f"{OLLAMA_URL}/api/chat", json={ "model": OLLAMA_MODEL, "messages": [{"role": "user", "content": prompt}], "stream": False, "options": { "num_predict": 3000, "temperature": 0.25, "top_p": 0.9, }, "think": False, }, ) resp.raise_for_status() answer = resp.json()["message"]["content"].strip() elapsed = _time.time() - t0 logger.info(f"✅ LLM 回答完成 (耗时: {elapsed:.1f}s, 长度: {len(answer)}字)") source_text = build_source_text(relevant, limit=5) verbatim = extract_verbatim_snippets(query, relevant, limit=2) body = f"{verbatim}\n\n---\n\n{answer}" if verbatim else answer full_answer = f"{body}\n\n📚 相关文档:\n{source_text}" if source_text else body set_cached_answer(query, full_answer) return full_answer except Exception as e: logger.error(f"❌ 知识库问答失败: {e}") return f"❌ 回答失败: {e}"