loop-memory 0.4.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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Wiki synthesis prompts (multi-locale) and length-floor helpers.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
The legacy prompt strings lived inline at the top of ``evolution.py``
|
|
6
|
+
and ``llm_consolidate.py``. Two consequences:
|
|
7
|
+
|
|
8
|
+
1. They were written in English without any directive about output
|
|
9
|
+
language, so even Chinese-language users got distilled wiki pages
|
|
10
|
+
in English (the assistant's "default" tone). The user reported
|
|
11
|
+
"知识库中浓缩的知识为何全是英文,单个知识偏短不成体系" — every
|
|
12
|
+
distilled page came out in English, individual pages were 1-2
|
|
13
|
+
bullets long, and there was no systematic structure across the
|
|
14
|
+
wiki.
|
|
15
|
+
|
|
16
|
+
2. There was no length floor — the LLM could produce a 2-bullet page
|
|
17
|
+
("- we use SQLite") even when a richer answer was in the source
|
|
18
|
+
memories.
|
|
19
|
+
|
|
20
|
+
This module centralises the wiki-synthesis prompts in three
|
|
21
|
+
languages (Chinese, English, with a Japanese fallback when the user's
|
|
22
|
+
``lang`` is ja). All Stage-4 callers should pick a prompt through
|
|
23
|
+
``wiki_system_prompt(lang)`` rather than referencing the legacy
|
|
24
|
+
``_WIKI_SYSTEM`` constant directly.
|
|
25
|
+
|
|
26
|
+
The length / bullet floors (``MIN_BULLETS_PER_PAGE`` /
|
|
27
|
+
``MIN_BODY_CHARS``) are exported so a post-processing pass in
|
|
28
|
+
``evolution.py`` can re-prompt the LLM when a returned page comes
|
|
29
|
+
back too thin to be useful. The "completeness over compactness"
|
|
30
|
+
v2 invariant now lives in code, not just in the prompt text.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re as _re
|
|
36
|
+
|
|
37
|
+
# ----------------------------------------------------------------------------
|
|
38
|
+
# Length / bullet floors (v3 — code-level guarantee on top of prompt-level)
|
|
39
|
+
# ----------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
#: Minimum number of bullets the LLM must emit for a non-trivial wiki
|
|
42
|
+
#: page. ``evolution._stage4_wiki_synthesis`` will re-issue one
|
|
43
|
+
#: expansion prompt when a returned page falls below this floor before
|
|
44
|
+
#: falling back to the deterministic rule-based synthesizer.
|
|
45
|
+
MIN_BULLETS_PER_PAGE = 6
|
|
46
|
+
|
|
47
|
+
#: Minimum body length in characters. Mirrors ``MIN_BULLETS_PER_PAGE``
|
|
48
|
+
#: for prose-style summaries so a page can't sneak past the bullet
|
|
49
|
+
#: count by emitting six 3-character bullets.
|
|
50
|
+
MIN_BODY_CHARS = 600
|
|
51
|
+
|
|
52
|
+
#: When the first LLM pass returns a page below the floor, the
|
|
53
|
+
#: consolidator retries once with an expansion instruction before
|
|
54
|
+
#: giving up. ``MAX_WIKI_PROMPTS_PER_PAGE = 2`` keeps the worst-case
|
|
55
|
+
#: LLM bill under control even on a chatty session.
|
|
56
|
+
MAX_WIKI_PROMPTS_PER_PAGE = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ----------------------------------------------------------------------------
|
|
60
|
+
# Locale helpers
|
|
61
|
+
# ----------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
#: Supported output locales. Anything outside this set falls back to
|
|
64
|
+
#: English. The default user-facing locale is ``zh`` because the
|
|
65
|
+
#: default UI language is Chinese; English pages come back when the
|
|
66
|
+
#: user explicitly switches the toggle.
|
|
67
|
+
SUPPORTED_LOCALES = ("zh", "en", "ja")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalise_lang(lang: object | None) -> str:
|
|
71
|
+
"""Return one of :data:`SUPPORTED_LOCALES`. Unknown / empty → ``zh``."""
|
|
72
|
+
if not lang:
|
|
73
|
+
return "zh"
|
|
74
|
+
s = str(lang).strip().lower()
|
|
75
|
+
if s.startswith("zh"):
|
|
76
|
+
return "zh"
|
|
77
|
+
if s.startswith("en"):
|
|
78
|
+
return "en"
|
|
79
|
+
if s.startswith("ja") or s.startswith("jp"):
|
|
80
|
+
return "ja"
|
|
81
|
+
return "zh"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ----------------------------------------------------------------------------
|
|
85
|
+
# Shared prompt structure
|
|
86
|
+
# ----------------------------------------------------------------------------
|
|
87
|
+
#
|
|
88
|
+
# Both locales stress the same two invariants:
|
|
89
|
+
#
|
|
90
|
+
# * **Completeness over compactness.** Every atomic fact (number,
|
|
91
|
+
# name, decision, error message, workaround) in the source MUST
|
|
92
|
+
# land on at least one bullet.
|
|
93
|
+
# * **Systematic, not chatty.** Each page is a 6-20 bullet cluster
|
|
94
|
+
# grouped by *user-profile dimension* (preferences, decisions,
|
|
95
|
+
# projects, domain, feedback). Sub-bullets (`` -`` indent) are
|
|
96
|
+
# encouraged when a single sentence carries two related facts.
|
|
97
|
+
#
|
|
98
|
+
# Locale-specific wordings live in small f-strings so a contributor
|
|
99
|
+
# can read both side by side and catch drift.
|
|
100
|
+
|
|
101
|
+
_EN_PROMPT = (
|
|
102
|
+
"You maintain a personal knowledge base for ONE user. You receive the user's "
|
|
103
|
+
"existing wiki pages plus a batch of distilled cluster summaries (each already "
|
|
104
|
+
"filtered for noise).\n"
|
|
105
|
+
"GOAL: each page must be a COMPLETE, ACTIONABLE, SYSTEMATIC note the user "
|
|
106
|
+
"would actually want to recall later — NOT a quote of the original "
|
|
107
|
+
"conversation, and NOT a half-fact that loses the actionable detail.\n"
|
|
108
|
+
"OUTPUT LANGUAGE: ENGLISH. Every value of `title`, `summary`, `body`, "
|
|
109
|
+
"`tags`, `key_facts`, and `slug` MUST be in English. Do NOT switch to "
|
|
110
|
+
"Chinese or any other language even if the source memories were mixed.\n"
|
|
111
|
+
"COMPLETENESS OVER COMPACTNESS: if a cluster contains a number, a name, a "
|
|
112
|
+
"decision, a constraint, an error message, or a workaround, that detail "
|
|
113
|
+
"MUST land on the relevant page. The user will rely on this knowledge base "
|
|
114
|
+
"to skip re-deriving facts. Losing a fact is much worse than a longer page.\n"
|
|
115
|
+
"ALWAYS bucket into one of these dimensions, and make the slug reflect the topic:\n"
|
|
116
|
+
" preferences-<topic>, decision-<topic>, project-<topic>, domain-<topic>, "
|
|
117
|
+
"feedback-<topic>. Examples: 'prefers-dark-mode', 'decision-batch-size-50', "
|
|
118
|
+
"'project-loop-memory', 'domain-crypto-swing-trades', 'feedback-no-mixed-lang'.\n"
|
|
119
|
+
"Each page MUST have:\n"
|
|
120
|
+
" slug: lowercase, hyphen-separated, prefixed with the dimension; no hard "
|
|
121
|
+
"length cap, but keep it readable in a URL. NEVER a truncated user prompt.\n"
|
|
122
|
+
" title: a real noun phrase that names the topic; no hard length cap, but "
|
|
123
|
+
"keep it under ~12 words. NEVER a truncated user prompt.\n"
|
|
124
|
+
" summary: 1-3 sentence definition that stands on its own; no hard length "
|
|
125
|
+
"cap, no truncation mid-clause, MUST be understandable without the source.\n"
|
|
126
|
+
" body: bullet-point markdown. Each bullet MUST start with '- '. One "
|
|
127
|
+
"atomic fact per bullet. NO hard cap on bullet count — use as many bullets "
|
|
128
|
+
"as the source clusters justify. SYSTEMATIC pages MUST have at least "
|
|
129
|
+
f"{MIN_BULLETS_PER_PAGE} bullets and at least {MIN_BODY_CHARS} characters "
|
|
130
|
+
"of body content; if the source is thinner, prefer to MERGE it into an "
|
|
131
|
+
"existing page rather than emit a half-fact single-bullet stub. Sub-bullets "
|
|
132
|
+
"(indented with two spaces: ' -') are encouraged when one fact has two "
|
|
133
|
+
"halves (e.g. 'uses X for Y' / 'uses Z for W'). Every decision, number, "
|
|
134
|
+
"name, error, constraint, or workaround from the source MUST appear in at "
|
|
135
|
+
"least one bullet. No prose paragraphs. No 'Outcome: ...' echoes. Code "
|
|
136
|
+
"fences ONLY when the fact is literally a command or config snippet.\n"
|
|
137
|
+
" tags: 3-6 lowercase tags, snake_case\n"
|
|
138
|
+
" importance: 0..1 (1 = critical user preference/project, 0.3 = transient detail)\n"
|
|
139
|
+
" evidence_ids: list of memory ids that back this page (cite real ids from the input)\n"
|
|
140
|
+
"SKIP a cluster summary if it is just a user prompt, status update, or repeats "
|
|
141
|
+
"another cluster. PREFER updating an existing page (same slug) over creating "
|
|
142
|
+
"a near-duplicate — when updating, APPEND new atomic facts rather than "
|
|
143
|
+
"rewriting existing ones, so cumulative knowledge is preserved. Reply with "
|
|
144
|
+
"JSON: {\"pages\": [...]}. If nothing adds new info, reply {\"pages\": []}. "
|
|
145
|
+
"No prose, no markdown outside the JSON."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_ZH_PROMPT = (
|
|
150
|
+
"你为同一名用户维护一份个人长期知识库。你会拿到用户的已有 wiki 页面 + 一组 "
|
|
151
|
+
"经过过滤的「簇摘要」(Stage 3 已剔除噪音)。\n"
|
|
152
|
+
"目标:每一页都必须是【完整 + 可执行 + 成体系】的笔记,用户日后回查能直接用 "
|
|
153
|
+
"—— 不要复述对话,也不要丢掉任何关键事实。\n"
|
|
154
|
+
"**输出语言必须使用简体中文(zh-CN)**。所有的 title / summary / body / "
|
|
155
|
+
"tags / key_facts / slug 字段都必须用中文。即便源记忆里有英文术语或代码 "
|
|
156
|
+
"片段,正文叙述仍用中文表达(专有名词可保留英文原文,但不要整页变成英文)。\n"
|
|
157
|
+
"**完整性优先于简洁性**:簇里出现的数字、人名、决定、约束、报错、workaround "
|
|
158
|
+
"必须落到对应页的某一条 bullet 上。用户依赖这份知识库减少重复推导,丢掉任 "
|
|
159
|
+
"何一条事实都比写长一点更糟糕。\n"
|
|
160
|
+
"**结构化成体系**:每页都按以下 5 个用户画像维度之一归类,slug 直接拼上主题:\n"
|
|
161
|
+
" preferences-<主题>、decision-<主题>、project-<主题>、domain-<主题>、"
|
|
162
|
+
"feedback-<主题>。例如 'prefers-dark-mode'、'decision-batch-size-50'、"
|
|
163
|
+
"'project-loop-memory'、'domain-crypto-swing-trades'、'feedback-no-mixed-lang'。\n"
|
|
164
|
+
"每一页必须包含以下字段:\n"
|
|
165
|
+
" slug:小写,连字符分隔,前缀为维度;不做硬切,但 URL 要可读;禁止截断的原始用户输入。\n"
|
|
166
|
+
" title:能直接命名主题的名词短语,不做硬切,控制在 12 个词以内;禁止截断的原始用户输入。\n"
|
|
167
|
+
" summary:1-3 句能独立成立的概要;不做硬切、不在分句中间截断;脱离原文也要能读懂。\n"
|
|
168
|
+
f" body:项目式 Markdown。每条 bullet 必须以 '- ' 开头。一条 bullet 一个原子事实。"
|
|
169
|
+
f"不做硬性 bullet 数上限,但**系统性页面至少 {MIN_BULLETS_PER_PAGE} 条 bullet、"
|
|
170
|
+
f"正文至少 {MIN_BODY_CHARS} 个字符**;如果源材料偏薄,宁可并入已有页面,也不要"
|
|
171
|
+
f"生成只有一两条 bullet 的单薄页面。当一条事实包含两半内容(例如'用 X 做 Y,用 Z 做 W')"
|
|
172
|
+
f"时,强烈推荐用两层 bullet 缩进(' -')。源材料里的每一个决定、数字、"
|
|
173
|
+
f"名字、报错、约束、workaround 必须出现在至少一条 bullet 上。禁止整段散文;"
|
|
174
|
+
f"禁止 'Outcome: ...' 之类回声;只有在事实本身就是命令/配置片段时才用代码块。\n"
|
|
175
|
+
" tags:3-6 个小写标签,snake_case 风格。\n"
|
|
176
|
+
" importance:0..1(1=关键用户偏好/项目决定,0.3=临时细节)。\n"
|
|
177
|
+
" evidence_ids:引用来源记忆 ID 列表(用输入中真实存在的 ID)。\n"
|
|
178
|
+
"**跳过**单纯复述用户问题、状态更新或重复其他簇的摘要。**优先更新已有页面**"
|
|
179
|
+
"(同 slug)而不是新建近似页面;更新时只追加新事实,不要重写已有事实,"
|
|
180
|
+
"以保留累积知识。回复 JSON: {\"pages\": [...]}。若没有新增信息,回复 "
|
|
181
|
+
"{\"pages\": []}。不要在 JSON 之外输出任何说明文字或 Markdown。"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_JA_PROMPT = (
|
|
186
|
+
"あなたは一人のユーザーのための個人ナレッジベースを維持します。入力はユーザーの"
|
|
187
|
+
"既存 wiki ページ群と、Stage 3 でノイズ除去済みのクラスター要約のバッチです。\n"
|
|
188
|
+
"ゴール: 各ページはユーザーが後で再参照したい【完全で・実用的・体系的な】"
|
|
189
|
+
"メモでなければなりません。元の発言の引き写しでも、要点だけ抜いた薄い"
|
|
190
|
+
"ものでもいけません。\n"
|
|
191
|
+
"**出力言語は日本語 (ja-JP) とします**。title / summary / body / tags / "
|
|
192
|
+
"key_facts / slug のすべての値を日本語で記述してください。クラスター内に"
|
|
193
|
+
"英語の専門用語が混ざっていても、説明は日本語で書いてください(固有名詞は"
|
|
194
|
+
"原文のまま可)。\n"
|
|
195
|
+
"**完全性 > 簡潔性**: 数字・名前・決定・制約・エラーメッセージ・回避策は"
|
|
196
|
+
"全て、該当ページの bullet に必ず 1 ヶ所以上載せてください。\n"
|
|
197
|
+
"**体系化**: 以下の 5 つのユーザープロファイル次元のいずれかに分類し、"
|
|
198
|
+
"slug にその次元を付けてください: preferences-, decision-, project-, "
|
|
199
|
+
"domain-, feedback-。\n"
|
|
200
|
+
"各ページに必須のフィールド:\n"
|
|
201
|
+
" slug: 小文字ハイフン区切り、次元を前置。URL として読める長さに。"
|
|
202
|
+
" title: トピックを直接表す名詞句、12 語以内。\n"
|
|
203
|
+
f" body: Markdown の箇条書き。各 bullet は '- ' で始める。一つの bullet に"
|
|
204
|
+
f"一つのアトミックな事実。**体系的なページでは最低 {MIN_BULLETS_PER_PAGE} "
|
|
205
|
+
f"個の bullet と {MIN_BODY_CHARS} 文字以上**を必須とします。"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Length-floor instruction injected into a second-pass expansion prompt when
|
|
210
|
+
# the first LLM reply comes back thinner than the floors. We keep it short
|
|
211
|
+
# and locale-aware because the synthesiser copies it verbatim onto the
|
|
212
|
+
# end of the first reply's conversation.
|
|
213
|
+
|
|
214
|
+
def expansion_prompt(lang: str) -> str:
|
|
215
|
+
"""Locale-specific text that asks the LLM to expand an under-floor page."""
|
|
216
|
+
loc = normalise_lang(lang)
|
|
217
|
+
if loc == "zh":
|
|
218
|
+
return (
|
|
219
|
+
"上面给出的页面太短,未达到成体系的最低要求(bullet 数 / 正文字符数)。"
|
|
220
|
+
"请在该页面下追加新的 bullet,每条 bullet 仍以 '- ' 开头,承载一条原子事实,"
|
|
221
|
+
"尽量从所有提供的 cluster summary 里抽取尚未落入本页的关键细节(数字、"
|
|
222
|
+
"名字、决定、报错、workaround)。不要重写或删除已有 bullet,只在末尾追加。"
|
|
223
|
+
"回复 JSON:{\"pages\": [{...原页面 + 新 bullet + 同一 slug/title/summary...}]}。"
|
|
224
|
+
)
|
|
225
|
+
if loc == "ja":
|
|
226
|
+
return (
|
|
227
|
+
"前述ページは最低要件 (bullet 数 / 本文文字数) を満たしていません。"
|
|
228
|
+
"ページ末尾に新しい bullet を追加してください。'- ' 始まり、各 bullet 1 事実。"
|
|
229
|
+
"提供された全クラスター要約から未取込みの重要事項(数値・名前・決定・"
|
|
230
|
+
"エラー・回避策)を抽出すること。既存 bullet の書き換えや削除は禁止、"
|
|
231
|
+
"末尾追加のみ。JSON のみ返す。"
|
|
232
|
+
)
|
|
233
|
+
# English / default fallback
|
|
234
|
+
return (
|
|
235
|
+
"The page returned above is below the systematic floor (bullet count "
|
|
236
|
+
"and/or body character count). Please append new bullets to that same "
|
|
237
|
+
"page. Each new bullet MUST start with '- ' and MUST carry ONE atomic "
|
|
238
|
+
"fact. Pull from every cluster summary that has details not yet on the "
|
|
239
|
+
"page (numbers, names, decisions, errors, workarounds). Do NOT rewrite "
|
|
240
|
+
"or drop existing bullets — only append. Reply as JSON: "
|
|
241
|
+
"{\"pages\": [{...the same page with new bullets appended, "
|
|
242
|
+
"keeping slug / title / summary unchanged...}]}."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ----------------------------------------------------------------------------
|
|
247
|
+
# Public entry points
|
|
248
|
+
# ----------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
_PROMPTS = {"zh": _ZH_PROMPT, "en": _EN_PROMPT, "ja": _JA_PROMPT}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def wiki_system_prompt(lang: object | None = None) -> str:
|
|
254
|
+
"""Return the Stage-4 wiki system prompt for the requested locale.
|
|
255
|
+
|
|
256
|
+
Pass ``store.lang`` / behaviour lang through here. Falls back to
|
|
257
|
+
Chinese (the default UI language) when ``lang`` is missing or
|
|
258
|
+
unsupported. Result is cached so repeated calls in the same process
|
|
259
|
+
don't re-tokenise the prompt.
|
|
260
|
+
"""
|
|
261
|
+
key = normalise_lang(lang)
|
|
262
|
+
return _PROMPTS[key]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ----------------------------------------------------------------------------
|
|
266
|
+
# Body-floor measurement
|
|
267
|
+
# ----------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
_BULLET_LINE_RE = _re.compile(r"^\s*-\s+\S", _re.MULTILINE)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def count_bullets(body: str) -> int:
|
|
273
|
+
"""Count user-visible bullet items in a wiki body.
|
|
274
|
+
|
|
275
|
+
Recognises both ``- `` (most wiki pages) and ``* `` bullets. Indented
|
|
276
|
+
sub-bullets count too — they're still atomic facts the user wants
|
|
277
|
+
to recall later. Numbered lists don't count here; the prompt asks
|
|
278
|
+
for ``-`` bullets only.
|
|
279
|
+
"""
|
|
280
|
+
if not body:
|
|
281
|
+
return 0
|
|
282
|
+
n = 0
|
|
283
|
+
for line in body.splitlines():
|
|
284
|
+
if line.lstrip().startswith(("- ", "* ")):
|
|
285
|
+
n += 1
|
|
286
|
+
return n
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def meets_body_floor(body: str, *, min_bullets: int = MIN_BULLETS_PER_PAGE,
|
|
290
|
+
min_chars: int = MIN_BODY_CHARS) -> bool:
|
|
291
|
+
"""True when ``body`` clears both bullet and character floors."""
|
|
292
|
+
body = body or ""
|
|
293
|
+
if len(body) < min_chars:
|
|
294
|
+
return False
|
|
295
|
+
return count_bullets(body) >= min_bullets
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Scope parsing and safe per-client defaults for wiki pages."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VALID_SCOPE_TOKENS = frozenset({
|
|
9
|
+
"global",
|
|
10
|
+
"all",
|
|
11
|
+
"codex",
|
|
12
|
+
"claude",
|
|
13
|
+
"hermes",
|
|
14
|
+
"openclaw",
|
|
15
|
+
})
|
|
16
|
+
CLIENT_SCOPE_TOKENS = frozenset({"codex", "claude", "hermes", "openclaw"})
|
|
17
|
+
DEFAULT_CLIENT_SCOPE = "codex"
|
|
18
|
+
|
|
19
|
+
_SOURCE_ALIASES = {
|
|
20
|
+
"claude-code": "claude",
|
|
21
|
+
"claude_code": "claude",
|
|
22
|
+
"chatgpt": "codex",
|
|
23
|
+
"open-claw": "openclaw",
|
|
24
|
+
"open_claw": "openclaw",
|
|
25
|
+
"hermes-cli": "hermes",
|
|
26
|
+
"hermes_agent": "hermes",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def auto_scope_config(store: Any) -> dict[str, object]:
|
|
31
|
+
"""Read the auto-scope settings with compatibility fallbacks."""
|
|
32
|
+
raw = store.get_setting("wiki_auto_scope", {}) or {}
|
|
33
|
+
if not isinstance(raw, dict):
|
|
34
|
+
raw = {}
|
|
35
|
+
enabled = raw.get(
|
|
36
|
+
"enabled",
|
|
37
|
+
store.get_setting("wiki_auto_scope_enabled", True),
|
|
38
|
+
)
|
|
39
|
+
mode = raw.get(
|
|
40
|
+
"mode",
|
|
41
|
+
store.get_setting("wiki_auto_scope_mode", "pattern"),
|
|
42
|
+
)
|
|
43
|
+
mode = str(mode or "pattern").strip().lower()
|
|
44
|
+
if mode not in {"pattern", "llm", "off"}:
|
|
45
|
+
mode = "pattern"
|
|
46
|
+
if isinstance(enabled, str):
|
|
47
|
+
enabled = enabled.strip().lower() in {"1", "true", "yes", "on"}
|
|
48
|
+
return {"enabled": bool(enabled), "mode": mode}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_scope(value: Any) -> list[str]:
|
|
52
|
+
"""Parse a scope string/list into deduplicated lowercase tokens.
|
|
53
|
+
|
|
54
|
+
Validation is intentionally separate: callers can use this helper to
|
|
55
|
+
normalise a trusted value, while API routes can reject unknown tokens
|
|
56
|
+
before persisting them.
|
|
57
|
+
"""
|
|
58
|
+
if value is None:
|
|
59
|
+
return []
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
raw_values: Iterable[Any] = value.split(",")
|
|
62
|
+
elif isinstance(value, (list, tuple, set)):
|
|
63
|
+
raw_values = value
|
|
64
|
+
else:
|
|
65
|
+
raw_values = [value]
|
|
66
|
+
tokens: list[str] = []
|
|
67
|
+
for raw in raw_values:
|
|
68
|
+
token = str(raw or "").strip().lower()
|
|
69
|
+
if not token:
|
|
70
|
+
continue
|
|
71
|
+
if token == "all":
|
|
72
|
+
token = "global"
|
|
73
|
+
if token not in tokens:
|
|
74
|
+
tokens.append(token)
|
|
75
|
+
return tokens
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalise_scope(value: Any, *, allow_auto: bool = False) -> str:
|
|
79
|
+
"""Return a canonical scope or raise ``ValueError`` for bad input."""
|
|
80
|
+
if allow_auto and isinstance(value, str) and value.strip().lower() == "auto":
|
|
81
|
+
return "auto"
|
|
82
|
+
tokens = parse_scope(value)
|
|
83
|
+
if not tokens or any(token not in VALID_SCOPE_TOKENS for token in tokens):
|
|
84
|
+
raise ValueError(f"invalid scope: {value!r}")
|
|
85
|
+
if "global" in tokens:
|
|
86
|
+
return "global"
|
|
87
|
+
return ",".join(tokens)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def source_token(value: Any) -> str | None:
|
|
91
|
+
"""Map a loader/client source name to a supported scope token."""
|
|
92
|
+
raw = str(value or "").strip().lower()
|
|
93
|
+
if not raw:
|
|
94
|
+
return None
|
|
95
|
+
raw = raw.replace("\\", "/")
|
|
96
|
+
for separator in ("/", ":"):
|
|
97
|
+
if separator in raw:
|
|
98
|
+
raw = raw.split(separator, 1)[0]
|
|
99
|
+
break
|
|
100
|
+
raw = _SOURCE_ALIASES.get(raw, raw)
|
|
101
|
+
if raw in CLIENT_SCOPE_TOKENS:
|
|
102
|
+
return raw
|
|
103
|
+
for token in CLIENT_SCOPE_TOKENS:
|
|
104
|
+
if raw.startswith(token + "-") or raw.startswith(token + "_"):
|
|
105
|
+
return token
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _evidence_sources(evidence_ids: Iterable[Any] | None, store: Any) -> list[str]:
|
|
110
|
+
if not evidence_ids or store is None:
|
|
111
|
+
return []
|
|
112
|
+
ids = [str(item) for item in evidence_ids if str(item).strip()]
|
|
113
|
+
if not ids:
|
|
114
|
+
return []
|
|
115
|
+
try:
|
|
116
|
+
rows = store.list_memories(ids=ids, limit=max(1, len(ids)))
|
|
117
|
+
except Exception:
|
|
118
|
+
return []
|
|
119
|
+
sources: list[str] = []
|
|
120
|
+
for memory in rows or []:
|
|
121
|
+
raw = getattr(memory, "source", None)
|
|
122
|
+
token = source_token(raw)
|
|
123
|
+
if token and token not in sources:
|
|
124
|
+
sources.append(token)
|
|
125
|
+
return sources
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def derive_default_scope(
|
|
129
|
+
source_hint: Any = None,
|
|
130
|
+
evidence_ids: Iterable[Any] | None = None,
|
|
131
|
+
store: Any = None,
|
|
132
|
+
*,
|
|
133
|
+
fallback: str = DEFAULT_CLIENT_SCOPE,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Derive a non-global scope for knowledge not promoted globally.
|
|
136
|
+
|
|
137
|
+
An explicit client hint wins. Otherwise all recognised clients that
|
|
138
|
+
contributed evidence are retained, so a page distilled from Codex and
|
|
139
|
+
Claude is available to both without silently becoming global. The
|
|
140
|
+
fallback is deliberately a real client token rather than ``global``;
|
|
141
|
+
this is the privacy-preserving behavior for manually-created pages with
|
|
142
|
+
no evidence metadata.
|
|
143
|
+
"""
|
|
144
|
+
hinted = source_token(source_hint)
|
|
145
|
+
if hinted:
|
|
146
|
+
return hinted
|
|
147
|
+
evidence_sources = _evidence_sources(evidence_ids, store)
|
|
148
|
+
return derive_scope_from_sources(
|
|
149
|
+
source_hint=source_hint,
|
|
150
|
+
evidence_sources=evidence_sources,
|
|
151
|
+
fallback=fallback,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def derive_scope_from_sources(
|
|
156
|
+
source_hint: Any = None,
|
|
157
|
+
evidence_sources: Iterable[Any] | None = None,
|
|
158
|
+
*,
|
|
159
|
+
fallback: str = DEFAULT_CLIENT_SCOPE,
|
|
160
|
+
) -> str:
|
|
161
|
+
"""Derive a scope when source strings are already loaded by a caller."""
|
|
162
|
+
if evidence_sources:
|
|
163
|
+
tokens = [source_token(value) for value in evidence_sources]
|
|
164
|
+
tokens = [token for token in tokens if token]
|
|
165
|
+
else:
|
|
166
|
+
tokens = []
|
|
167
|
+
hinted = source_token(source_hint)
|
|
168
|
+
if hinted:
|
|
169
|
+
return hinted
|
|
170
|
+
if tokens:
|
|
171
|
+
counts = Counter(tokens)
|
|
172
|
+
ordered = sorted(
|
|
173
|
+
tokens,
|
|
174
|
+
key=lambda token: (-counts[token], tokens.index(token)),
|
|
175
|
+
)
|
|
176
|
+
return ",".join(dict.fromkeys(ordered))
|
|
177
|
+
return source_token(fallback) or DEFAULT_CLIENT_SCOPE
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_scope_audit(
|
|
181
|
+
classification: Any,
|
|
182
|
+
*,
|
|
183
|
+
scope: str,
|
|
184
|
+
decision: str,
|
|
185
|
+
source_hint: Any = None,
|
|
186
|
+
enabled: bool = True,
|
|
187
|
+
mode: str = "pattern",
|
|
188
|
+
existing: dict | None = None,
|
|
189
|
+
) -> dict:
|
|
190
|
+
"""Attach the applied scope and decision to a classifier result."""
|
|
191
|
+
if hasattr(classification, "to_dict"):
|
|
192
|
+
audit = dict(classification.to_dict())
|
|
193
|
+
elif isinstance(classification, dict):
|
|
194
|
+
audit = dict(classification)
|
|
195
|
+
else:
|
|
196
|
+
audit = {}
|
|
197
|
+
audit.update({
|
|
198
|
+
"scope_applied": scope,
|
|
199
|
+
"scope_decision": decision,
|
|
200
|
+
"source_hint": str(source_hint).strip() if source_hint else None,
|
|
201
|
+
"auto_scope_enabled": bool(enabled),
|
|
202
|
+
"mode": str(mode or "pattern"),
|
|
203
|
+
})
|
|
204
|
+
if existing:
|
|
205
|
+
previous = existing.get("auto_classification")
|
|
206
|
+
if isinstance(previous, dict):
|
|
207
|
+
prior_history = previous.get("history")
|
|
208
|
+
if isinstance(prior_history, list):
|
|
209
|
+
audit["history"] = [*prior_history[-19:], {k: v for k, v in previous.items() if k != "history"}]
|
|
210
|
+
else:
|
|
211
|
+
audit["history"] = [{k: v for k, v in previous.items() if k != "history"}]
|
|
212
|
+
audit.setdefault("history", [])
|
|
213
|
+
return audit
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = [
|
|
217
|
+
"CLIENT_SCOPE_TOKENS",
|
|
218
|
+
"DEFAULT_CLIENT_SCOPE",
|
|
219
|
+
"VALID_SCOPE_TOKENS",
|
|
220
|
+
"auto_scope_config",
|
|
221
|
+
"build_scope_audit",
|
|
222
|
+
"derive_default_scope",
|
|
223
|
+
"derive_scope_from_sources",
|
|
224
|
+
"normalise_scope",
|
|
225
|
+
"parse_scope",
|
|
226
|
+
"source_token",
|
|
227
|
+
]
|