kb-memory 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kb/i18n.py ADDED
@@ -0,0 +1,257 @@
1
+ """双语消息(i18n)与 MCP 接入规约。
2
+
3
+ - `KB_LANG`:服务端错误/校验消息语言(zh/en/auto,默认 auto)
4
+ - `KB_MEMORY_LANG`:记忆书写语言(auto/zh/en/...,默认 auto = 操作系统默认语言)
5
+ - 自定义记忆规则:`~/.kb/rules.md` + `<data_dir>/rules.md`,截断后注入 MCP instructions
6
+
7
+ English: Bilingual messages plus the MCP instructions contract.
8
+ - KB_LANG: language of server error/validation messages (zh/en/auto).
9
+ - KB_MEMORY_LANG: language used when writing memories (auto = OS default language).
10
+ - Custom memory rules: ~/.kb/rules.md plus <data_dir>/rules.md, truncated and injected
11
+ into the MCP instructions.
12
+ """
13
+ import locale
14
+ from pathlib import Path
15
+
16
+ from kb.config import get_settings
17
+
18
+
19
+ def detect_lang() -> str:
20
+ """检测系统语言:中文系统→zh,其他→en;KB_LANG=zh/en 时直接采用。
21
+ English: Detect system language: Chinese systems→zh, otherwise→en; KB_LANG=zh/en wins."""
22
+ try:
23
+ cfg_lang = get_settings().lang
24
+ if cfg_lang in ("zh", "en"):
25
+ return cfg_lang
26
+ except Exception:
27
+ pass
28
+ # auto:检测系统 locale
29
+ try:
30
+ code, _ = locale.getdefaultlocale()
31
+ if code and code.lower().startswith("zh"):
32
+ return "zh"
33
+ except Exception:
34
+ pass
35
+ return "en"
36
+
37
+
38
+ # 常见语言代码 → 语言名(供 MCP instructions 明确要求 agent 使用的书写语言)
39
+ _LANG_NAMES: dict[str, str] = {
40
+ "zh": "Chinese (简体中文)",
41
+ "en": "English",
42
+ "ja": "Japanese (日本語)",
43
+ "ko": "Korean (한국어)",
44
+ "de": "German (Deutsch)",
45
+ "fr": "French (Français)",
46
+ "es": "Spanish (Español)",
47
+ "pt": "Portuguese (Português)",
48
+ "it": "Italian (Italiano)",
49
+ "ru": "Russian (Русский)",
50
+ "ar": "Arabic (العربية)",
51
+ "hi": "Hindi (हिन्दी)",
52
+ "nl": "Dutch (Nederlands)",
53
+ "pl": "Polish (Polski)",
54
+ "tr": "Turkish (Türkçe)",
55
+ }
56
+
57
+
58
+ def system_language_code() -> str:
59
+ """操作系统默认语言代码(如 zh-CN / fr-FR);检测失败回退 en。
60
+ English: OS default language code (e.g. zh-CN / fr-FR); falls back to en."""
61
+ code = ""
62
+ try:
63
+ code = locale.getlocale()[0] or ""
64
+ except Exception:
65
+ pass
66
+ if not code:
67
+ try:
68
+ code, _ = locale.getdefaultlocale()
69
+ except Exception:
70
+ pass
71
+ return (code or "en").replace("_", "-")
72
+
73
+
74
+ def memory_lang() -> str:
75
+ """记忆书写语言名:KB_MEMORY_LANG 显式指定优先,auto 按操作系统语言。
76
+ English: Display name of the memory-writing language: KB_MEMORY_LANG wins;
77
+ auto follows the OS language."""
78
+ try:
79
+ cfg = (get_settings().memory_lang or "auto").strip().lower()
80
+ except Exception:
81
+ cfg = "auto"
82
+ code = system_language_code() if cfg == "auto" else cfg
83
+ base = code.replace("_", "-").split("-")[0].lower()
84
+ return _LANG_NAMES.get(base, code or "English")
85
+
86
+
87
+ # 消息表:key → {zh, en}
88
+ _MESSAGES: dict[str, dict[str, str]] = {
89
+ # 校验类(service.validate_* 供 MCP/REST 共用)
90
+ "agent_id_required": {
91
+ "zh": "agent_id 必填且必须是有意义的任务名(如 TASK-0076 / worker-1),不能用 default/unknown 等占位",
92
+ "en": "agent_id is required and must be a meaningful task name (e.g. TASK-0076 / worker-1); placeholders like default/unknown are rejected",
93
+ },
94
+ "agent_id_format": {
95
+ "zh": "agent_id 格式非法:仅允许字母/数字/中文/下划线/连字符,1~64 字符(当前:{v!r})",
96
+ "en": "invalid agent_id: only letters/digits/CJK/underscore/hyphen allowed, 1~64 chars (got {v!r})",
97
+ },
98
+ "client_format": {
99
+ "zh": "client 格式非法:仅允许字母/数字/中文/下划线/连字符/空格/点,1~64 字符(当前:{v!r};不传则自动识别)",
100
+ "en": "invalid client: only letters/digits/CJK/underscore/hyphen/space/dot allowed, 1~64 chars (got {v!r}; omit for auto-detect)",
101
+ },
102
+ "project_format": {
103
+ "zh": "project 格式非法:仅允许字母/数字/中文/下划线/连字符,1~64 字符且不能是系统保留名(当前:{v!r})",
104
+ "en": "invalid project: only letters/digits/CJK/underscore/hyphen allowed, 1~64 chars, reserved OS names rejected (got {v!r})",
105
+ },
106
+ # 业务错误(MCP 工具返回的错误消息)
107
+ "content_blank": {
108
+ "zh": "content 不能为空或纯空白",
109
+ "en": "content must not be empty or blank",
110
+ },
111
+ "top_k_min": {
112
+ "zh": "top_k 必须 >= 1",
113
+ "en": "top_k must be >= 1",
114
+ },
115
+ "record_none": {
116
+ "zh": "记录不存在或无权访问",
117
+ "en": "record not found or forbidden",
118
+ },
119
+ "record_forbidden": {
120
+ "zh": "无权访问该记忆(非当前客户端/项目归属)",
121
+ "en": "memory not accessible (not owned by this client/project)",
122
+ },
123
+ "llm_disabled": {
124
+ "zh": "未检测到可用的 LLM:请在 .env 配置 KB_LLM_MODE=local(Ollama)或 KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)",
125
+ "en": "no LLM available: configure KB_LLM_MODE=local (Ollama) or KB_LLM_API_KEY in .env for a cloud provider (any OpenAI-compatible endpoint)",
126
+ },
127
+ "unsupported_format": {
128
+ "zh": "不支持的文件格式",
129
+ "en": "unsupported file format",
130
+ },
131
+ "web_fetch_failed": {
132
+ "zh": "网页抓取/正文提取失败",
133
+ "en": "webpage fetch/body extraction failed",
134
+ },
135
+ "file_not_found": {
136
+ "zh": "文件不存在",
137
+ "en": "file not found",
138
+ },
139
+ "bad_request": {
140
+ "zh": "请求参数不合法",
141
+ "en": "invalid request parameters",
142
+ },
143
+ }
144
+
145
+
146
+ def t(key: str, **fmt) -> str:
147
+ """按当前语言返回 key 的消息;未知 key 回退英文(或 key 本身)。
148
+ English: Return the message for key in the current language; unknown keys fall back to English."""
149
+ msg = _MESSAGES.get(key, {})
150
+ text = msg.get(detect_lang()) or msg.get("en") or key
151
+ try:
152
+ return text.format(**fmt)
153
+ except Exception:
154
+ return text
155
+
156
+
157
+ def custom_rules() -> str:
158
+ """读取用户自定义记忆规则:全局 ~/.kb/rules.md 在前,本安装 rules.md 在后,
159
+ 拼接后按 KB_RULES_MAX_CHARS 截断(默认 4000)。
160
+ English: Load user-defined memory rules: the global ~/.kb/rules.md first, then
161
+ this installation's rules.md, joined and truncated to KB_RULES_MAX_CHARS (4000)."""
162
+ try:
163
+ settings = get_settings()
164
+ except Exception:
165
+ return ""
166
+ paths = [Path.home() / ".kb" / "rules.md", settings.rules_file]
167
+ parts: list[str] = []
168
+ for path in paths:
169
+ try:
170
+ if path.is_file():
171
+ text = path.read_text(encoding="utf-8").strip()
172
+ if text:
173
+ parts.append(text)
174
+ except OSError:
175
+ continue
176
+ limit = max(0, int(settings.rules_max_chars or 0))
177
+ return "\n\n".join(parts)[:limit]
178
+
179
+
180
+ # ---- MCP server instructions(v2.1 接入规约,2026-10-20)----
181
+ # 跨客户端全局提示词载体:任何客户端挂载 MCP 后自动注入 AI 上下文;
182
+ # 语言按 KB_MEMORY_LANG/系统语言确定,并注入用户自定义记忆规则。
183
+ # 核心:不要求先做健康探测、反馈极简、project 隔离项目记忆、全局记忆共享。
184
+ _MCP_INSTRUCTIONS: dict[str, str] = {
185
+ "zh": """你是连接到 kb(本地 Agent 记忆与知识服务)的助手。kb 是本机常驻服务,你现在就能直接调用它的 MCP 工具写入/检索记忆与知识,默认情况下服务在正常运行,无需也不要求事前探测(health check)——直接调用工具即可,一切正常时请继续工作。
186
+
187
+ 【身份与项目】
188
+ - client 由系统自动识别,你不需要自报身份,也不要展示。
189
+ - project 用于区分不同项目的记忆:当用户明确在某个项目上下文中工作(提到项目名、仓库名或项目目录)时,将该项目的稳定短名传给 project 参数;同一项目始终用同一个名字。
190
+ - 项目记忆只在该 project 下可见;不传 project 写入的是全局记忆(所有项目可见),适合用户偏好、通用事实与跨项目约定。不确定项目名时不要猜测:写全局或先问用户。
191
+ - 检索时传 project 会同时覆盖该项目与全局记忆;不传只看全局。
192
+
193
+ 【记忆语言】
194
+ - 写入记忆一律使用 {memory_lang};专有名词、代码、命令、路径保持原文,不要翻译。
195
+
196
+ 【何时写入记忆(write_memory,内容精炼 1~3 句)】
197
+ 1. 用户偏好:主题、工具链、代码风格、命名习惯、沟通方式。
198
+ 2. 项目决策:已拍板的选型/架构取舍/约定,写清背景与结论。
199
+ 3. 事实与约定:关键路径、命令、依赖、环境细节、版本约束等可复用事实。
200
+ 4. 敏感约束:用户明示的不可做红线、安全/隐私要求。
201
+ 5. 任务进度:复杂任务的关键进展与下一步,便于跨会话接力。
202
+ 不写:纯寒暄、临时计算、可由代码/文档直接查到的实现细节。
203
+ - 写前先查重:先 search_memory,已有同类则 update_memory 覆盖,不新增重复。
204
+ - 敏感信息不入库:密钥、凭据、身份证号等严禁写入。
205
+
206
+ 【何时检索(search_memory)】
207
+ - 任务开始、回答涉及历史决策/用户偏好、跨会话接力时,先主动检索召回,query 用自然语言描述语义。
208
+
209
+ 【对用户反馈(重要,极简)】
210
+ - 写入/更新成功:只轻提一句,如"已记住你的偏好";失败才说明原因(如"没记:与已有记忆重复/内容敏感/格式不支持")。
211
+ - 不要展示:健康检查过程、工具名、记录 ID、JSON、检索命中详情。
212
+ - 若 MCP 工具调用报连接失败(服务确实不可达),简提"kb 服务未启动,需先运行 python -m kb serve",不要臆造结果。""",
213
+ "en": """You are connected to kb, a local Agent memory & knowledge service. kb is a resident local service whose MCP tools you can call right now to write/retrieve memories and knowledge. It is normally running; no health check or pre-probing is required — just call the tools directly and keep working when things are normal.
214
+
215
+ [Identity & projects]
216
+ - The client is auto-detected; never self-report or display it.
217
+ - project separates memories between projects: when the user is clearly working in a project context (project name, repository name, or project directory), pass that project's stable short name as project; always use the same name for the same project.
218
+ - Project memories are visible only under that project. Without project, writes go to global memory (visible to every project) — suitable for user preferences, general facts, and cross-project conventions. Never guess a project name: write globally or ask the user.
219
+ - Searching with project covers that project plus global memory; without it, only global memory.
220
+
221
+ [Memory language]
222
+ - Always write memories in {memory_lang}; keep proper nouns, code, commands, and paths unchanged — do not translate them.
223
+
224
+ [When to write memory (write_memory, 1-3 concise sentences)]
225
+ 1. User preferences: themes, toolchain, code style, naming habits, communication style.
226
+ 2. Project decisions: settled tech choices, architecture trade-offs, conventions — with context and conclusion.
227
+ 3. Facts & conventions: key paths, commands, dependencies, environment details, version constraints.
228
+ 4. Sensitive constraints: explicit hard rules ("do not"), security/privacy requirements.
229
+ 5. Task progress: key progress of complex tasks and next steps, for cross-session handoff.
230
+ Do NOT write: chit-chat, transient computation, implementation details directly findable in code/docs.
231
+ - Dedupe before writing: search_memory first; if similar content exists, update_memory to overwrite instead of adding duplicates.
232
+ - Never store sensitive data: keys, credentials, ID numbers are forbidden.
233
+
234
+ [When to search (search_memory)]
235
+ - Proactively recall at task start, when an answer involves past decisions/preferences, or on cross-session handoff; ask in natural language describing the semantics.
236
+
237
+ [Feedback to user (important — keep it minimal)]
238
+ - On successful write/update: mention it briefly, e.g. "Noted your preference."
239
+ - On failure, state the reason (e.g. "Not saved: duplicate of an existing memory / sensitive content / unsupported format").
240
+ - Do NOT show: health-check process, tool names, record IDs, JSON, retrieval hit details.
241
+ - Only if an MCP tool call fails with a connection error (service truly unreachable), briefly say "kb service is not running; start it with python -m kb serve". Never fabricate results.""",
242
+ }
243
+
244
+
245
+ def mcp_instructions() -> str:
246
+ """按当前系统语言返回 MCP instructions,并注入记忆语言与用户自定义规则。
247
+ English: Return the MCP instructions for the current system language, with the
248
+ memory language and user-defined rules injected."""
249
+ lang = detect_lang()
250
+ text = (_MCP_INSTRUCTIONS.get(lang) or _MCP_INSTRUCTIONS["en"]).format(
251
+ memory_lang=memory_lang())
252
+ rules = custom_rules()
253
+ if not rules:
254
+ return text
255
+ if lang == "zh":
256
+ return text + "\n\n【用户自定义记忆规则(优先遵守)】\n" + rules
257
+ return text + "\n\n[User-defined memory rules (take precedence)]\n" + rules
kb/ingest.py ADDED
@@ -0,0 +1,122 @@
1
+ """文档/网页摄取:解析为纯文本 + 递归切分(设计文档第 9 节)。
2
+
3
+ 解析分派:.pdf→pypdf;.docx→python-docx;.md/.txt→直读;
4
+ Office 等已知格式(.xlsx/.pptx/...)→ markitdown 兜底;
5
+ 白名单之外的扩展名一律抛 UnsupportedFormatError(API 层转 400)。
6
+
7
+ 网页摄取:httpx 拉取(超时 15s,UA 伪装常规浏览器)→ trafilatura
8
+ 提取正文;抓取/提取失败抛 WebFetchError(API 层转 400 + 原因)。
9
+
10
+ 重依赖(pypdf / python-docx / markitdown / langchain-text-splitters /
11
+ httpx / trafilatura)均在函数内延迟导入,服务启动不加载;其中
12
+ pypdf/python-docx/markitdown/trafilatura 属于可选 extra `[ingest]`,
13
+ 缺失时返回带安装指引的可读错误。
14
+ """
15
+ from pathlib import Path
16
+
17
+ _INGEST_HINT = "请运行 pip install \"kb-memory[ingest]\" 后重试"
18
+
19
+
20
+ def _ingest_import(module: str, what: str):
21
+ """导入摄取可选依赖;缺失时抛带 extra 安装指引的 UnsupportedFormatError。"""
22
+ import importlib
23
+ try:
24
+ return importlib.import_module(module)
25
+ except ImportError as exc:
26
+ raise UnsupportedFormatError(
27
+ f"{what}需要可选依赖 {module}:{_INGEST_HINT}") from exc
28
+
29
+ # 直接解析的扩展名(.md / .txt 直读原文,不做任何清洗)
30
+ _DIRECT_EXTS = {".md", ".txt"}
31
+ # markitdown 兜底的已知格式(Office 全格式等;markitdown 对未知扩展名
32
+ # 会当纯文本静默返回,故必须白名单控制,白名单外直接拒绝)
33
+ _MARKITDOWN_EXTS = {".xlsx", ".xls", ".pptx", ".ppt", ".csv", ".json",
34
+ ".xml", ".html", ".htm", ".rst"}
35
+
36
+
37
+ class UnsupportedFormatError(Exception):
38
+ """不支持的文档格式(API 层转 400)。"""
39
+
40
+
41
+ class WebFetchError(Exception):
42
+ """网页抓取或正文提取失败(API 层转 400 + 原因)。"""
43
+
44
+
45
+ # 网页抓取 UA:伪装常规浏览器(设计文档第 9 节),避免站点拒绝默认 UA
46
+ _WEB_USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
47
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
48
+ "Chrome/126.0.0.0 Safari/537.36")
49
+
50
+
51
+ def parse_file(path: Path | str) -> str:
52
+ """解析文档为纯文本;不支持的扩展名抛 UnsupportedFormatError。"""
53
+ path = Path(path)
54
+ ext = path.suffix.lower()
55
+ if ext in _DIRECT_EXTS:
56
+ return path.read_text(encoding="utf-8")
57
+ if ext == ".pdf":
58
+ return _parse_pdf(path)
59
+ if ext == ".docx":
60
+ return _parse_docx(path)
61
+ if ext in _MARKITDOWN_EXTS:
62
+ return _parse_markitdown(path)
63
+ raise UnsupportedFormatError(f"不支持的文档格式:{ext or '<无扩展名>'}({path.name})")
64
+
65
+
66
+ def _parse_pdf(path: Path) -> str:
67
+ """pypdf 逐页提取文本,空页跳过。"""
68
+ PdfReader = _ingest_import("pypdf", "解析 PDF ").PdfReader
69
+ reader = PdfReader(str(path))
70
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
71
+
72
+
73
+ def _parse_docx(path: Path) -> str:
74
+ """python-docx 提取全部段落文本。"""
75
+ Document = _ingest_import("docx", "解析 DOCX ").Document
76
+ doc = Document(str(path))
77
+ return "\n".join(p.text for p in doc.paragraphs)
78
+
79
+
80
+ def _parse_markitdown(path: Path) -> str:
81
+ """markitdown 兜底解析(Office 全格式);解析失败抛 UnsupportedFormatError。"""
82
+ MarkItDown = _ingest_import("markitdown", "解析 Office 文档 ").MarkItDown
83
+ try:
84
+ return MarkItDown().convert(str(path)).text_content
85
+ except Exception as exc:
86
+ raise UnsupportedFormatError(f"文档解析失败({path.name}):{exc}") from exc
87
+
88
+
89
+ def chunk_text(text: str, size: int, overlap: int) -> list[str]:
90
+ """langchain RecursiveCharacterTextSplitter 递归切分(按分隔符层级回退)。"""
91
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
92
+ splitter = RecursiveCharacterTextSplitter(chunk_size=size,
93
+ chunk_overlap=overlap)
94
+ return splitter.split_text(text)
95
+
96
+
97
+ def _fetch_html(url: str) -> str:
98
+ """httpx 抓取页面 HTML:超时 15s、UA 伪装常规浏览器、跟随重定向;
99
+ 4xx/5xx 与网络错误抛原始异常(由 fetch_webpage 统一包装)。"""
100
+ import httpx
101
+ with httpx.Client(timeout=15, headers={"User-Agent": _WEB_USER_AGENT},
102
+ follow_redirects=True) as client:
103
+ resp = client.get(url)
104
+ resp.raise_for_status()
105
+ return resp.text
106
+
107
+
108
+ def fetch_webpage(url: str) -> str:
109
+ """抓取网页并提取正文:_fetch_html 拉取 → trafilatura.extract 提取;
110
+ 抓取异常或正文提取结果为空时抛 WebFetchError。"""
111
+ try:
112
+ html = _fetch_html(url)
113
+ except Exception as exc:
114
+ raise WebFetchError(f"网页抓取失败({url}):{exc}") from exc
115
+ try:
116
+ trafilatura = _ingest_import("trafilatura", "提取网页正文 ")
117
+ except UnsupportedFormatError as exc:
118
+ raise WebFetchError(str(exc)) from exc
119
+ text = trafilatura.extract(html)
120
+ if not text:
121
+ raise WebFetchError(f"网页正文提取失败({url}):未识别到正文内容")
122
+ return text
kb/llm.py ADDED
@@ -0,0 +1,155 @@
1
+ """LLM 接入层:Ollama 探测、模式解析(local/auto/cloud)、护栏参数、本地/云端双客户端统一接口。"""
2
+ from enum import Enum
3
+
4
+ import httpx
5
+
6
+ from kb.config import Settings
7
+
8
+
9
+ class LLMError(Exception):
10
+ """LLM 调用异常(service 层据此转 503 或降级)。"""
11
+
12
+
13
+ class LLMStatus(str, Enum):
14
+ """LLM 可用状态。"""
15
+
16
+ LOCAL = "local"
17
+ CLOUD = "cloud"
18
+ DISABLED = "disabled"
19
+
20
+
21
+ class LLMClient:
22
+ """本地(Ollama 原生 /api/chat)与云端(OpenAI 兼容接口)统一生成接口。
23
+
24
+ 模式(KB_LLM_MODE):off(默认,不加载/不调用任何 LLM)| local | auto | cloud。
25
+ 云端端点通用化:任意 OpenAI 兼容服务(OpenAI/DeepSeek/通义/硅基流动等),
26
+ 配置键 KB_LLM_API_KEY / KB_LLM_BASE_URL / KB_LLM_CLOUD_MODEL;旧 KB_DEEPSEEK_* 兼容。
27
+
28
+ 护栏参数硬编码默认值:think=False、temperature 0.2、num_ctx 4096、
29
+ max_tokens 800(可被 chat 入参覆盖),不新增配置项。
30
+ """
31
+
32
+ def __init__(self, settings: Settings, http_client: httpx.Client | None = None):
33
+ """http_client 注入用于测试;默认自建(base_url=settings.ollama_base_url)。
34
+ 构造时立即探测(每次构造重新探测,不使用模块级缓存)。"""
35
+ self._settings = settings
36
+ # 生成可能较慢(含模型冷加载),默认客户端给足超时;probe 另有 2s 限时
37
+ self._http = http_client or httpx.Client(
38
+ base_url=settings.ollama_base_url, timeout=120.0)
39
+ self._local_ok = False
40
+ self._cloud_ok = False
41
+ self.probe()
42
+
43
+ def probe(self) -> dict:
44
+ """GET {ollama_base_url}/v1/models(2s 超时)→ 本地可用性;
45
+ 云端 API Key 非空 → 云端可用性。off 模式不探测(恒 DISABLED)。
46
+ Returns {"local": bool, "cloud": bool}."""
47
+ if self._settings.llm_mode == "off":
48
+ self._local_ok = False
49
+ self._cloud_ok = False
50
+ return {"local": False, "cloud": False}
51
+ local_ok = False
52
+ try:
53
+ resp = self._http.get("/v1/models", timeout=2.0)
54
+ local_ok = resp.is_success
55
+ except httpx.HTTPError:
56
+ local_ok = False
57
+ self._local_ok = local_ok
58
+ self._cloud_ok = bool(self._settings.cloud_api_key)
59
+ return {"local": self._local_ok, "cloud": self._cloud_ok}
60
+
61
+ @property
62
+ def status(self) -> LLMStatus:
63
+ """off:恒 DISABLED;
64
+ local 模式:本地可用→LOCAL 否则 DISABLED;
65
+ cloud 模式:有 Key→CLOUD 否则 DISABLED;
66
+ auto 模式:本地可用→LOCAL;无本地有云→CLOUD;都无→DISABLED。"""
67
+ mode = self._settings.llm_mode
68
+ if mode == "off":
69
+ return LLMStatus.DISABLED
70
+ if mode == "local":
71
+ return LLMStatus.LOCAL if self._local_ok else LLMStatus.DISABLED
72
+ if mode == "cloud":
73
+ return LLMStatus.CLOUD if self._cloud_ok else LLMStatus.DISABLED
74
+ # auto(默认):本地优先,云端兜底
75
+ if self._local_ok:
76
+ return LLMStatus.LOCAL
77
+ if self._cloud_ok:
78
+ return LLMStatus.CLOUD
79
+ return LLMStatus.DISABLED
80
+
81
+ def chat(self, messages: list[dict], max_tokens: int | None = None,
82
+ prefer: str = "auto") -> str:
83
+ """统一生成接口。prefer: local/cloud/auto(路由用)。
84
+
85
+ - DISABLED 状态直接抛 LLMError;
86
+ - 显式 prefer 指定的后端不可用时抛 LLMError(不跨端回退,
87
+ 避免 local 模式或敏感场景下悄悄走云);
88
+ - auto:本地优先,云端兜底,都无抛 LLMError。
89
+ """
90
+ if self.status is LLMStatus.DISABLED:
91
+ raise LLMError("LLM 不可用:KB_LLM_MODE=off 或本地 Ollama 未响应且云端未配置 API Key")
92
+ mode = self._settings.llm_mode
93
+ # 模式约束后端范围:local 模式禁云端,cloud 模式禁本地
94
+ local_usable = self._local_ok and mode != "cloud"
95
+ cloud_usable = self._cloud_ok and mode != "local"
96
+ if prefer == "local":
97
+ if not local_usable:
98
+ raise LLMError("本地 LLM 不可用(Ollama 未响应或 cloud 模式禁用本地)")
99
+ return self._chat_local(messages, max_tokens)
100
+ if prefer == "cloud":
101
+ if not cloud_usable:
102
+ raise LLMError("云端 LLM 不可用(未配置 API Key 或 local 模式禁用云端)")
103
+ return self._chat_cloud(messages, max_tokens)
104
+ # prefer == "auto":本地优先,云端兜底
105
+ if local_usable:
106
+ return self._chat_local(messages, max_tokens)
107
+ return self._chat_cloud(messages, max_tokens)
108
+
109
+ def _chat_local(self, messages: list[dict], max_tokens: int | None) -> str:
110
+ """本地 Ollama 原生 /api/chat(openai SDK 不透传 think 参数,故直用 httpx)。"""
111
+ if not self._settings.llm_model:
112
+ raise LLMError(
113
+ "未配置本地模型:请在 .env 设置 KB_LLM_MODEL=<ollama list 中的模型名>")
114
+ body = {
115
+ "model": self._settings.llm_model,
116
+ "messages": messages,
117
+ "think": False,
118
+ "stream": False,
119
+ "options": {
120
+ "temperature": 0.2,
121
+ "num_ctx": 4096,
122
+ "max_tokens": max_tokens or 800,
123
+ },
124
+ }
125
+ try:
126
+ resp = self._http.post("/api/chat", json=body)
127
+ resp.raise_for_status()
128
+ data = resp.json()
129
+ except httpx.HTTPError as exc:
130
+ raise LLMError(f"本地 LLM 调用失败: {exc}") from exc
131
+ return data.get("message", {}).get("content") or ""
132
+
133
+ def _chat_cloud(self, messages: list[dict], max_tokens: int | None) -> str:
134
+ """云端 OpenAI 兼容接口(openai SDK;延迟导入,off/local 模式无需安装 SDK)。
135
+
136
+ 端点由 KB_LLM_BASE_URL / KB_LLM_API_KEY / KB_LLM_CLOUD_MODEL 配置(兼容旧 KB_DEEPSEEK_*)。
137
+ """
138
+ try:
139
+ from openai import OpenAI, OpenAIError
140
+ except ImportError as exc:
141
+ raise LLMError(
142
+ "未安装 openai SDK,无法调用云端 LLM:"
143
+ "请运行 pip install \"kb-memory[llm]\"") from exc
144
+ client = OpenAI(api_key=self._settings.cloud_api_key,
145
+ base_url=self._settings.cloud_base_url)
146
+ try:
147
+ resp = client.chat.completions.create(
148
+ model=self._settings.cloud_model,
149
+ messages=messages,
150
+ temperature=0.2,
151
+ max_tokens=max_tokens or 800,
152
+ )
153
+ except OpenAIError as exc:
154
+ raise LLMError(f"云端 LLM 调用失败: {exc}") from exc
155
+ return resp.choices[0].message.content or ""
kb/logging_setup.py ADDED
@@ -0,0 +1,75 @@
1
+ """日志体系装配(N17):setup_logging 一次调用配置 kb.* 日志树。
2
+
3
+ 设计依据(历史日志设计,已本地归档)第 6 节:
4
+ - 控制台 handler:精简格式(asctime 只保留时分秒,省略日期部分);
5
+ - RotatingFileHandler:完整格式(含模块名),按 KB_LOG_MAX_BYTES 轮转,
6
+ 保留 KB_LOG_BACKUP_COUNT 个备份;
7
+ - 格式统一 ``%(asctime)s | %(levelname)s | %(name)s | %(message)s``。
8
+
9
+ 说明:handler 挂在 "kb" 命名日志树根上(kb.serve / kb.api / kb.watcher
10
+ 等子 logger 自动继承级别与 handler),不碰进程根 logger——避免第三方库
11
+ (urllib3 / watchdog 等)日志混入 kb.log。可重复调用(create_app 每次
12
+ 装配都会走到):先关闭并摘除旧 handler 再挂新的,规避 Windows 文件
13
+ 句柄占用导致轮转失败。
14
+ """
15
+ import logging
16
+ from logging.handlers import RotatingFileHandler
17
+ from pathlib import Path
18
+
19
+ # 统一格式(设计第 6 节):时间 | 级别 | 模块 | 消息
20
+ _FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
21
+ # 控制台版 datefmt:只保留时分秒(省略日期部分保持简洁)
22
+ _CONSOLE_DATEFMT = "%H:%M:%S"
23
+
24
+ # KB_LOG_LEVEL 合法级别表;非法值回退 INFO(容错不阻断启动)
25
+ _LEVELS = {"DEBUG": logging.DEBUG, "INFO": logging.INFO,
26
+ "WARNING": logging.WARNING, "ERROR": logging.ERROR,
27
+ "CRITICAL": logging.CRITICAL}
28
+
29
+
30
+ class _NoRequestFilter(logging.Filter):
31
+ """控制台降噪过滤器:拦截 event=request.* 的访问日志。
32
+
33
+ 文件 handler 不挂此过滤器(全量审计保留);仅控制台静默,
34
+ 避免高频轮询(每 10 秒 3 个请求)刷屏。匹配 N17 中间件的
35
+ 消息格式 "request.start method=..." / "request.end method=..."。
36
+ """
37
+
38
+ def filter(self, record: logging.LogRecord) -> bool:
39
+ return not str(record.getMessage()).startswith("request.")
40
+
41
+
42
+ def setup_logging(settings=None) -> logging.Logger:
43
+ """装配 kb 日志树:控制台 + 轮转文件双 handler,返回 "kb" logger。
44
+
45
+ settings 缺省时读全局配置单例(get_settings)。日志目录不存在时
46
+ 自动创建;级别名大小写不敏感,非法值回退 INFO。
47
+ """
48
+ if settings is None:
49
+ from kb.config import get_settings
50
+ settings = get_settings()
51
+
52
+ level = _LEVELS.get(str(settings.log_level).upper(), logging.INFO)
53
+ log_dir = Path(settings.log_dir)
54
+ log_dir.mkdir(parents=True, exist_ok=True)
55
+
56
+ root = logging.getLogger("kb")
57
+ root.setLevel(level)
58
+ # 关闭并摘除旧 handler(测试/重复装配场景),防 Windows 句柄占用
59
+ for h in list(root.handlers):
60
+ root.removeHandler(h)
61
+ h.close()
62
+
63
+ console = logging.StreamHandler() # 默认 stderr
64
+ console.setFormatter(logging.Formatter(_FORMAT, datefmt=_CONSOLE_DATEFMT))
65
+ # 控制台降噪(用户反馈):request.* 访问日志全量进文件(审计),
66
+ # 控制台只保留错误与关键生命周期事件,高频轮询不再刷屏
67
+ console.addFilter(_NoRequestFilter())
68
+ root.addHandler(console)
69
+
70
+ file_handler = RotatingFileHandler(
71
+ log_dir / "kb.log", maxBytes=settings.log_max_bytes,
72
+ backupCount=settings.log_backup_count, encoding="utf-8")
73
+ file_handler.setFormatter(logging.Formatter(_FORMAT))
74
+ root.addHandler(file_handler)
75
+ return root