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,2021 @@
|
|
|
1
|
+
"""Evolution Consolidator — a 5-stage distillation pipeline.
|
|
2
|
+
|
|
3
|
+
Replaces the old single-pass LLM consolidator with a hierarchical,
|
|
4
|
+
feedback-driven loop. The pipeline is:
|
|
5
|
+
|
|
6
|
+
Stage 1 Signal-Aware Scoring
|
|
7
|
+
Each memory's importance is blended with its behavioural
|
|
8
|
+
signals (recall_count, positive/negative feedback). This
|
|
9
|
+
makes "things the user actually uses" float to the top.
|
|
10
|
+
|
|
11
|
+
Stage 2 Semantic Batching
|
|
12
|
+
Memories are embedded (or use a hashed fallback) and
|
|
13
|
+
clustered into K buckets via greedy cosine clustering.
|
|
14
|
+
Each cluster <= CLUSTER_MAX so the LLM never sees too much
|
|
15
|
+
at once and the topic stays focused.
|
|
16
|
+
|
|
17
|
+
Stage 3 Per-Cluster Distillation
|
|
18
|
+
For each cluster we ask the LLM to produce:
|
|
19
|
+
* a 1-sentence cluster summary
|
|
20
|
+
* a refined importance per row
|
|
21
|
+
* a list of "keep / drop / rewrite" actions
|
|
22
|
+
This is the cheap-per-cluster pass that decides what's
|
|
23
|
+
noise vs. signal.
|
|
24
|
+
|
|
25
|
+
Stage 4 Hierarchical Wiki Synthesis
|
|
26
|
+
Cluster summaries + the user's existing wiki pages are
|
|
27
|
+
passed to the LLM, which produces/updates wiki pages
|
|
28
|
+
grouped by *user-profile dimension*:
|
|
29
|
+
- preferences (how the user likes things done)
|
|
30
|
+
- decisions (concrete choices the user made)
|
|
31
|
+
- projects (ongoing work / topics)
|
|
32
|
+
- domain (technical knowledge to keep)
|
|
33
|
+
- feedback (corrections, dislikes, do/don't)
|
|
34
|
+
Re-running merges with existing wiki pages by slug.
|
|
35
|
+
|
|
36
|
+
Stage 5 Evolution Memo
|
|
37
|
+
We persist a short "evolution memo" (which wiki pages
|
|
38
|
+
changed, how much importance shifted, what signals were
|
|
39
|
+
used). The next run's Stage 4 prompt includes the memo so
|
|
40
|
+
the LLM keeps learning the user's preferences across runs
|
|
41
|
+
without us having to retrain anything.
|
|
42
|
+
|
|
43
|
+
The consolidator is fully optional: the existing single-pass
|
|
44
|
+
``LLMConsolidator`` still works and is what the UI's "AI Consolidate"
|
|
45
|
+
button calls by default. ``EvolutionConsolidator`` is wired in as an
|
|
46
|
+
opt-in mode so the user can A/B compare.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import hashlib
|
|
52
|
+
import json
|
|
53
|
+
import math
|
|
54
|
+
import re
|
|
55
|
+
import time
|
|
56
|
+
from collections.abc import Callable
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
from typing import Any
|
|
59
|
+
|
|
60
|
+
from ..llm.base import ChatHistory, LLMClient, Message
|
|
61
|
+
from ..storage.sqlite_store import MemoryStore, StoredMemory
|
|
62
|
+
from ..wiki.prompts import (
|
|
63
|
+
MAX_WIKI_PROMPTS_PER_PAGE,
|
|
64
|
+
MIN_BODY_CHARS,
|
|
65
|
+
MIN_BULLETS_PER_PAGE,
|
|
66
|
+
count_bullets,
|
|
67
|
+
expansion_prompt,
|
|
68
|
+
meets_body_floor,
|
|
69
|
+
normalise_lang,
|
|
70
|
+
wiki_system_prompt,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ----------------------------------------------------------------------------
|
|
74
|
+
# Constants
|
|
75
|
+
# ----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
# Bullet-line splitter used by ``_expand_under_floor_pages`` to merge the
|
|
78
|
+
# "old" body with the "expansion" body returned by the LLM. We keep the
|
|
79
|
+
# order stable: all existing bullets first, then any new bullet whose
|
|
80
|
+
# first 60 chars aren't already present.
|
|
81
|
+
_BULLET_LINE_RE = re.compile(r"^[\s]*[-\*][\s]+\S", re.MULTILINE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _split_bullets(body: str) -> list[str]:
|
|
85
|
+
"""Return a list of bullet lines (without trailing blank lines)."""
|
|
86
|
+
if not body:
|
|
87
|
+
return []
|
|
88
|
+
lines = []
|
|
89
|
+
buf: list[str] = []
|
|
90
|
+
for line in body.splitlines():
|
|
91
|
+
if _BULLET_LINE_RE.match(line):
|
|
92
|
+
if buf:
|
|
93
|
+
lines.append("\n".join(buf).rstrip())
|
|
94
|
+
buf = []
|
|
95
|
+
buf.append(line)
|
|
96
|
+
else:
|
|
97
|
+
if buf:
|
|
98
|
+
buf.append(line)
|
|
99
|
+
if buf:
|
|
100
|
+
lines.append("\n".join(buf).rstrip())
|
|
101
|
+
return [ln for ln in lines if ln.strip()]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _merge_bullet_lists(old: str, new: str) -> str:
|
|
105
|
+
"""Append-only bullet merge. Existing bullets keep their order; new
|
|
106
|
+
bullets whose first 60 chars are not already present get appended.
|
|
107
|
+
Blank lines and prose paragraphs in ``new`` are also appended at the
|
|
108
|
+
end so the LLM can keep its "_Source:" footer or similar."""
|
|
109
|
+
old_lines = _split_bullets(old or "")
|
|
110
|
+
new_lines = _split_bullets(new or "")
|
|
111
|
+
seen = {ln.strip()[:60].lower() for ln in old_lines}
|
|
112
|
+
appended: list[str] = []
|
|
113
|
+
for nl in new_lines:
|
|
114
|
+
key = nl.strip()[:60].lower()
|
|
115
|
+
if not key or key in seen:
|
|
116
|
+
continue
|
|
117
|
+
seen.add(key)
|
|
118
|
+
appended.append(nl)
|
|
119
|
+
if not appended:
|
|
120
|
+
return old
|
|
121
|
+
return (old.rstrip() + "\n" + "\n".join(appended)).rstrip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_TOPIC_WORD_RE = re.compile(r"[a-z0-9][a-z0-9_.+-]{2,}", re.IGNORECASE)
|
|
125
|
+
_TOPIC_CJK_RE = re.compile(r"[\u4e00-\u9fff]{2,}")
|
|
126
|
+
_TOPIC_STOP_WORDS = {
|
|
127
|
+
"project", "domain", "decision", "feedback", "preferences",
|
|
128
|
+
"the", "and", "for", "with", "from", "this", "that",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _topic_tokens(value: Any) -> set[str]:
|
|
133
|
+
"""Return lightweight bilingual tokens for evidence recovery."""
|
|
134
|
+
text = str(value or "").lower()
|
|
135
|
+
tokens = {
|
|
136
|
+
token for token in _TOPIC_WORD_RE.findall(text)
|
|
137
|
+
if token not in _TOPIC_STOP_WORDS
|
|
138
|
+
}
|
|
139
|
+
for span in _TOPIC_CJK_RE.findall(text):
|
|
140
|
+
if len(span) <= 8:
|
|
141
|
+
tokens.add(span)
|
|
142
|
+
tokens.update(span[index:index + 2] for index in range(len(span) - 1))
|
|
143
|
+
return tokens
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _recover_page_evidence(
|
|
147
|
+
page: dict[str, Any],
|
|
148
|
+
clusters: list[dict[str, Any]],
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
"""Validate LLM citations and recover omitted IDs from matching clusters."""
|
|
151
|
+
cluster_ids = {
|
|
152
|
+
str(memory_id)
|
|
153
|
+
for cluster in clusters
|
|
154
|
+
for memory_id in (cluster.get("evidence_ids") or [])
|
|
155
|
+
if memory_id
|
|
156
|
+
}
|
|
157
|
+
supplied = page.get("evidence_ids") or []
|
|
158
|
+
if isinstance(supplied, list):
|
|
159
|
+
valid = [str(memory_id) for memory_id in supplied if str(memory_id) in cluster_ids]
|
|
160
|
+
if valid:
|
|
161
|
+
return list(dict.fromkeys(valid))[:50]
|
|
162
|
+
|
|
163
|
+
page_text = " ".join([
|
|
164
|
+
str(page.get("slug") or ""),
|
|
165
|
+
str(page.get("title") or ""),
|
|
166
|
+
str(page.get("summary") or ""),
|
|
167
|
+
str(page.get("body") or ""),
|
|
168
|
+
" ".join(str(tag) for tag in (page.get("tags") or [])),
|
|
169
|
+
])
|
|
170
|
+
page_tokens = _topic_tokens(page_text)
|
|
171
|
+
ranked: list[tuple[int, int, dict[str, Any]]] = []
|
|
172
|
+
for index, cluster in enumerate(clusters):
|
|
173
|
+
evidence_ids = cluster.get("evidence_ids") or []
|
|
174
|
+
if not evidence_ids:
|
|
175
|
+
continue
|
|
176
|
+
score = len(page_tokens & _topic_tokens(cluster.get("text") or ""))
|
|
177
|
+
ranked.append((score, -index, cluster))
|
|
178
|
+
ranked.sort(reverse=True, key=lambda item: (item[0], item[1]))
|
|
179
|
+
if not ranked:
|
|
180
|
+
return []
|
|
181
|
+
best_score = ranked[0][0]
|
|
182
|
+
if best_score <= 0 and len(ranked) != 1:
|
|
183
|
+
return []
|
|
184
|
+
selected = [ranked[0][2]]
|
|
185
|
+
if best_score > 0:
|
|
186
|
+
threshold = max(1, math.ceil(best_score * 0.6))
|
|
187
|
+
selected = [cluster for score, _index, cluster in ranked if score >= threshold]
|
|
188
|
+
recovered = [
|
|
189
|
+
str(memory_id)
|
|
190
|
+
for cluster in selected
|
|
191
|
+
for memory_id in (cluster.get("evidence_ids") or [])
|
|
192
|
+
if memory_id
|
|
193
|
+
]
|
|
194
|
+
return list(dict.fromkeys(recovered))[:50]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
CLUSTER_MAX = 15 # max memories per cluster (Stage 3 prompt size)
|
|
198
|
+
WIKI_INPUT_CLUSTERS = 8 # how many cluster summaries feed Stage 4
|
|
199
|
+
PROFILE_DIMS = ("preferences", "decisions", "projects", "domain", "feedback")
|
|
200
|
+
|
|
201
|
+
# ----------------------------------------------------------------------------
|
|
202
|
+
# Text noise cleaners (used by Stage 0 dedup + rule-based wiki fallback)
|
|
203
|
+
# ----------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# Common assistant boilerplate / meta-narration to strip from raw memory text
|
|
206
|
+
# when synthesizing wiki bodies without an LLM. We keep the regex list small
|
|
207
|
+
# and targeted so a real, atomic fact still survives.
|
|
208
|
+
# Recognise the common "wrapper" prefixes the loop-memory hooks prepend
|
|
209
|
+
# to assistant text. We strip the whole wrapper so the atomic fact inside
|
|
210
|
+
# can survive, instead of being glued to cron ids / working-directory tags.
|
|
211
|
+
_NOISE_WRAPPER_PATTERNS = [
|
|
212
|
+
# Cron-prompt headers: "[cron:UUID label] 请立即生成..."
|
|
213
|
+
r"\[cron:[^\]]+\]",
|
|
214
|
+
# Working-directory wrapper: "[Working directory: ~/.openclaw/workspace] 帮我..."
|
|
215
|
+
r"\[Working directory:[^\]]*\]\s*",
|
|
216
|
+
# Provider / source prefix: "[codex] ", "[claude] ", "[hermes] ", "[openclaw] "
|
|
217
|
+
r"\[(?:codex|claude|hermes|openclaw|claude-code|chatgpt|hermes-cli)\][\s::]+",
|
|
218
|
+
# Outcome / Result / Status / Task / Latest / Output prefix
|
|
219
|
+
r"^(?:Outcome|Result|Status|Task|Latest|Output|Reply|Response|Assistant|Human|User intent)\s*[::]\s*",
|
|
220
|
+
# "[thinking]" inline blocks: keep stripping
|
|
221
|
+
r"\[thinking\][\s\S]*?\[/thinking\]",
|
|
222
|
+
r"\[thinking\]\s*",
|
|
223
|
+
# "You are an assistant..." tool-system narration
|
|
224
|
+
r"You are an? [^.\n]{1,160}\.\s*",
|
|
225
|
+
# "<cwd>", "<shell>", "<current_date>", "<permissions_instructions>" env tags
|
|
226
|
+
r"<\w+>[\s\S]*?</\w+>",
|
|
227
|
+
r"<\w+>\s*",
|
|
228
|
+
# "**已完成**" / "**全部完成**" / "DONE" markers as standalone
|
|
229
|
+
r"^\*\*(?:已完成|全部完成|DONE|DONE\.|OK|完成|完成\.)\*\*\s*[::]?\s*",
|
|
230
|
+
]
|
|
231
|
+
_NOISE_PREFIX_RE = re.compile(
|
|
232
|
+
"|".join(_NOISE_WRAPPER_PATTERNS),
|
|
233
|
+
re.IGNORECASE,
|
|
234
|
+
)
|
|
235
|
+
_NOISE_CODE_FENCE_RE = re.compile(r"```[^`]*```", re.DOTALL)
|
|
236
|
+
_NOISE_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
|
237
|
+
_NOISE_PATH_RE = re.compile(r"/Users/[^\s)\"<>]+")
|
|
238
|
+
_NOISE_URL_RE = re.compile(r"https?://[^\s)\"<>]+")
|
|
239
|
+
_NOISE_WS_RE = re.compile(r"[ \t]+")
|
|
240
|
+
_NOISE_NEWLINES_RE = re.compile(r"\n{2,}")
|
|
241
|
+
# Markdown table rows: "| col | col | col |" — never a fact
|
|
242
|
+
_NOISE_TABLE_ROW_RE = re.compile(r"(?:\|[^|\n]*)+\|")
|
|
243
|
+
# Markdown headings: "## Heading" or "### Heading"
|
|
244
|
+
_NOISE_HEADING_RE = re.compile(r"^#+\s+[^\n]+", re.MULTILINE)
|
|
245
|
+
# Markdown emphasis that wraps an entire short line: "**全部完成。**"
|
|
246
|
+
_NOISE_BOLD_LINE_RE = re.compile(r"^\*\*[^\n]{1,40}\*\*\s*[::]?\s*", re.MULTILINE)
|
|
247
|
+
# Repeated punctuation runs (---, ====, *****)
|
|
248
|
+
_NOISE_RULE_RE = re.compile(r"^[-=*]{3,}\s*$", re.MULTILINE)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _clean_noise(text: str, *, max_len: int = 240) -> str:
|
|
252
|
+
"""Strip the wrapper off a raw memory so the atomic fact can survive.
|
|
253
|
+
|
|
254
|
+
The goal is *not* to summarize (the LLM does that); it is to remove the
|
|
255
|
+
80% of characters that are pleasantries, code fences, narration, file
|
|
256
|
+
paths, and tool chatter so a downstream bullet is readable on its own.
|
|
257
|
+
|
|
258
|
+
Order matters: bigger wrappers (cron headers, env tags) first, then
|
|
259
|
+
inline / line-level noise (markdown tables, bold-as-line, code).
|
|
260
|
+
"""
|
|
261
|
+
if not text:
|
|
262
|
+
return ""
|
|
263
|
+
s = text
|
|
264
|
+
# Drop code fences entirely (they are usually tool output, not a fact).
|
|
265
|
+
s = _NOISE_CODE_FENCE_RE.sub(" ", s)
|
|
266
|
+
# Markdown structural noise: tables, headings, hrules, whole-line bold
|
|
267
|
+
s = _NOISE_TABLE_ROW_RE.sub(" ", s)
|
|
268
|
+
s = _NOISE_RULE_RE.sub(" ", s)
|
|
269
|
+
s = _NOISE_HEADING_RE.sub(" ", s)
|
|
270
|
+
s = _NOISE_BOLD_LINE_RE.sub("", s)
|
|
271
|
+
# Drop wrapper prefixes (cron, working directory, provider tags, [thinking],
|
|
272
|
+
# <cwd> env tags, "Outcome: ", "User intent: ", etc.). Use a loop because
|
|
273
|
+
# the same text can have multiple stacked wrappers ("[openclaw] [Working
|
|
274
|
+
# directory: ...] 帮我...") and a single sub() only catches the first.
|
|
275
|
+
for _ in range(4):
|
|
276
|
+
prev_new = _NOISE_PREFIX_RE.sub("", s)
|
|
277
|
+
if prev_new == s:
|
|
278
|
+
break
|
|
279
|
+
s = prev_new
|
|
280
|
+
# Inline code: keep the inside, not the backticks.
|
|
281
|
+
s = _NOISE_INLINE_CODE_RE.sub(r"\1", s)
|
|
282
|
+
# Drop absolute home paths (these leak the user's filesystem, not a fact).
|
|
283
|
+
s = _NOISE_PATH_RE.sub(" ", s)
|
|
284
|
+
# Drop URLs (rarely a fact worth keeping in a wiki bullet).
|
|
285
|
+
s = _NOISE_URL_RE.sub(" ", s)
|
|
286
|
+
# Collapse whitespace.
|
|
287
|
+
s = _NOISE_WS_RE.sub(" ", s)
|
|
288
|
+
s = _NOISE_NEWLINES_RE.sub(" ", s)
|
|
289
|
+
s = s.strip(" \t\n.,;:|/\u3000")
|
|
290
|
+
# Final hard cap
|
|
291
|
+
if len(s) > max_len:
|
|
292
|
+
s = s[: max_len - 1].rstrip(" ,;:|/") + "…"
|
|
293
|
+
return s
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Patterns that mark a memory as pure status narration / completion ping
|
|
297
|
+
# with no atomic fact a wiki page should remember. Matched on the CLEANED
|
|
298
|
+
# (lowercased) form. The bar is: would a future user re-derive something
|
|
299
|
+
# they didn't already know? If the answer is no, drop it.
|
|
300
|
+
_LOW_SIGNAL_PATTERNS = (
|
|
301
|
+
# English
|
|
302
|
+
r"^tests? (is|are) green",
|
|
303
|
+
r"^ci (passed|green|succeeded|is green)",
|
|
304
|
+
r"^all (good|done|set|clear|green|passing|checks? pass)",
|
|
305
|
+
r"^running\s*\.\.\.?\s*$",
|
|
306
|
+
r"^done\.?$",
|
|
307
|
+
r"^ok\.?$",
|
|
308
|
+
r"^green\.?$",
|
|
309
|
+
r"^passed\.?$",
|
|
310
|
+
r"^succeeded\.?$",
|
|
311
|
+
r"^pushed successfully",
|
|
312
|
+
r"^now (commit|wait|push|run|deploy|test)",
|
|
313
|
+
r"^everything is green",
|
|
314
|
+
r"^all tests pass",
|
|
315
|
+
r"^build (passed|succeeded|is green)",
|
|
316
|
+
r"^lints? clean",
|
|
317
|
+
r"^ruff (clean|is clean|ok)",
|
|
318
|
+
r"^verified? (locally|and ready|ok)",
|
|
319
|
+
# Chinese
|
|
320
|
+
r"^全部完成",
|
|
321
|
+
r"^已完成",
|
|
322
|
+
r"^完成\.?",
|
|
323
|
+
r"^已经完成",
|
|
324
|
+
r"^都 (好|搞定|完成|通过了)",
|
|
325
|
+
r"^测试 (通过|成功|过了|全过)",
|
|
326
|
+
r"^跑通了?",
|
|
327
|
+
r"^没问题",
|
|
328
|
+
r"^好的",
|
|
329
|
+
r"^已推送",
|
|
330
|
+
r"^推送成功",
|
|
331
|
+
r"^现在 (推送|提交|等待|运行|测试)",
|
|
332
|
+
r"^任务完成",
|
|
333
|
+
r"^看板 (已|现在) 显示",
|
|
334
|
+
r"^一切 (正常|就绪|顺利)",
|
|
335
|
+
r"^191/191",
|
|
336
|
+
r"^\d+/\d+",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Heuristic flags for RAW USER PROMPTS that should never become wiki
|
|
340
|
+
# bullets. These are conversational imperatives that wrap the user's
|
|
341
|
+
# request — useful for session replay, but they contain no atomic fact a
|
|
342
|
+
# future user (or model) would re-derive. Matched on the cleaned form.
|
|
343
|
+
_RAW_PROMPT_PATTERNS = (
|
|
344
|
+
# "帮我..." (help me...)
|
|
345
|
+
r"^帮我",
|
|
346
|
+
r"\s帮我(?!知道|看一下|看看)",
|
|
347
|
+
r"帮我(检查|梳理|分析|整理|写|生成|跑|修复|优化|测试|部署|调研|对比|总结|封装|压缩|转换|翻译)",
|
|
348
|
+
# "请立即..." / "请..." (Please immediately)
|
|
349
|
+
r"^请立即",
|
|
350
|
+
r"^请(?!求|求你|告诉我)",
|
|
351
|
+
# Catch short imperative + concrete action even after wrapper stripping
|
|
352
|
+
r"^请立即生成",
|
|
353
|
+
r"^请把",
|
|
354
|
+
r"^请你",
|
|
355
|
+
# "the user is asking..." / "the user wants..." - assistant narration
|
|
356
|
+
r"the user (is asking|wants|wants? to|asked|wants me to|requested)",
|
|
357
|
+
r"^the user ",
|
|
358
|
+
# "如何..." / "How to..." / "怎么..." (how to)
|
|
359
|
+
r"^(如何|怎么|怎样|怎么(样|做)|how (to|do|can|should))\b",
|
|
360
|
+
# English imperatives the user types verbatim
|
|
361
|
+
r"^(please\s+)?(help|write|create|make|build|find|fix|run|do|generate|check|analy[sz]e|review|explain|compare|summari[sz]e|optimi[sz]e|clean|deploy)\s+(me\s+|a\s+|an\s+|the\s+|this\s+|that\s+|my\s+|our\s+|some\s+|all\s+)?",
|
|
362
|
+
# Verb-first project briefs
|
|
363
|
+
r"^(thoroughly|completely|fully|deeply|carefully)\s+(explore|review|analy[sz]e|investigate|examine|rewrite|rebuild|redesign)\b",
|
|
364
|
+
# "整理..." wrappers
|
|
365
|
+
r"^整理[\"\u201c].*?(目录|文件|报告|教程|笔记)",
|
|
366
|
+
r"^梳理.*?(市场|标的|行业|板块|投资|整个项目|项目|缺陷|代码|逻辑)",
|
|
367
|
+
# More Chinese imperatives (verb-first) — match the verb alone so
|
|
368
|
+
# "整理整个项目" / "分析下当前" / "运行测试" all flag.
|
|
369
|
+
r"^(分析|调研|梳理|整理|检查|生成|写|制作|开发|修复|优化|跑通|跑|测试|运行|启动|部署|上传|下载|导出|导入|删除|添加|新增|更新|修改|重写|重构|封装|压缩|转换|翻译|读取|写入|抓取|爬取|搜索|查看|打开|关闭|重启|停止|暂停|继续|创建|构建|编译|操作|执行|移动|复制|粘贴|撤销|恢复|清理|清空|刷新|加载|渲染|展示)",
|
|
370
|
+
# Verb + 下/一下/看看
|
|
371
|
+
r"(帮我|请)?(看|处理|搞|弄|调整|改|写|跑|做|检查|分析|调研|梳理|整理|总结|测试|运行|启动|部署|搜索|查看)(一下|看|看看|下)",
|
|
372
|
+
r"^(看|处理|搞|弄|调整|改|写|跑|做|检查|分析|调研|梳理|整理|总结|测试|运行|启动|部署|搜索|查看)(一下|看|看看|下)",
|
|
373
|
+
# "我..." user-self imperative
|
|
374
|
+
r"^我(想|需要|要|想请|想让|希望)",
|
|
375
|
+
# "You are ..." assistant task instructions (colon or period)
|
|
376
|
+
r"^you are (implementing|fixing|reviewing|writing|building|creating|checking|verifying|analy[sz]ing|investigating|designing|updating|modifying)\b",
|
|
377
|
+
# Pure file-path / tool recipes
|
|
378
|
+
r"^[/~][\w./_-]+/\w+\.(py|md|sh|json|yml|yaml|toml)\s*$",
|
|
379
|
+
# "PDF was generated" / "I am now going to" type assistant narration
|
|
380
|
+
r"^(pdf|the pdf|the file|the report|it) was (generated|created|saved|uploaded|sent)",
|
|
381
|
+
r"^(i|now|next|then) (will|am going to|need to|should|want to)\b",
|
|
382
|
+
# "let me ..." assistant narration
|
|
383
|
+
r"^let me (check|look|run|try|verify|test|create|build|do|now)\b",
|
|
384
|
+
r"^now let me ",
|
|
385
|
+
# "I\'m going to ..." assistant narration
|
|
386
|
+
r"^i.m going to ",
|
|
387
|
+
# Verb-imperative-only (when text is 4-7 chars, no period, ends in noun)
|
|
388
|
+
r"^[一-鿿]{2,4}[一-鿿a-z]",
|
|
389
|
+
# "看下X" / "整理X" / etc. — verb + 下/一下/看/看看 with anything after
|
|
390
|
+
r"^[看处理搞弄调整改写跑做检查分析调研梳理整理总结测试运行启动部署搜索查看](下|一下|看|看看)",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _is_low_signal(text: str) -> bool:
|
|
395
|
+
"""Return True if a memory contains no actionable information worth a wiki
|
|
396
|
+
bullet. Used to drop near-empty / pure-status memories before clustering.
|
|
397
|
+
Operates on the cleaned form so "Outcome: tests are green" still matches."""
|
|
398
|
+
if not text:
|
|
399
|
+
return True
|
|
400
|
+
s = _clean_noise(text, max_len=400).strip().lower()
|
|
401
|
+
if len(s) < 12:
|
|
402
|
+
return True
|
|
403
|
+
for pat in _LOW_SIGNAL_PATTERNS:
|
|
404
|
+
if re.match(pat, s):
|
|
405
|
+
return True
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _looks_like_raw_prompt(text: str) -> bool:
|
|
410
|
+
"""Return True if a memory is *primarily* a raw user prompt — i.e. its
|
|
411
|
+
atomic value is essentially the question / command itself, not a fact.
|
|
412
|
+
|
|
413
|
+
The wiki must NOT show these: the user already remembers the question,
|
|
414
|
+
and stuffing it into the wiki pollutes recall. We DO keep the memory in
|
|
415
|
+
the store (so context can be replayed) but the rule-based wiki step
|
|
416
|
+
drops the prompt form. A real LLM distillation would also drop these.
|
|
417
|
+
"""
|
|
418
|
+
if not text:
|
|
419
|
+
return True
|
|
420
|
+
s = _clean_noise(text, max_len=400).strip().lower()
|
|
421
|
+
# Threshold: very short texts (< 4 chars) are too ambiguous to
|
|
422
|
+
# classify (a single Chinese verb is itself 2 chars, so 4 is the
|
|
423
|
+
# smallest useful unit for an imperative).
|
|
424
|
+
if len(s) < 4:
|
|
425
|
+
return False
|
|
426
|
+
for pat in _RAW_PROMPT_PATTERNS:
|
|
427
|
+
if re.search(pat, s):
|
|
428
|
+
return True
|
|
429
|
+
# "Thoroughly explore the X project at I need to understand: 1. 2. 3. ..."
|
|
430
|
+
# type run-on briefs. If the cleaned text still contains "i need to
|
|
431
|
+
# understand" or "i want to" verbatim, it's a brief, not a fact.
|
|
432
|
+
if re.search(r"i (need|want) to (understand|know|do|build|check|see|review|explore)", s):
|
|
433
|
+
return True
|
|
434
|
+
# Long imperative sentence (>140 cleaned chars) with no period in the
|
|
435
|
+
# first 80 chars is almost always a user prompt, not an observation.
|
|
436
|
+
head = s[:80]
|
|
437
|
+
if len(s) > 140 and "." not in head and (" " in head[:20] or "\n" not in head):
|
|
438
|
+
# Additional sanity: starts with a verb or 帮我 / 请 / how / please
|
|
439
|
+
if re.match(r"^(please|how|why|what|when|where|can|could|would|should|do|does|is|are|was|were|i|you|we|let)", head):
|
|
440
|
+
return True
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _title_from(text: str, fallback_kind: str = "", max_len: int = 60) -> str:
|
|
445
|
+
"""Make a real, noun-phrase title out of a raw memory string. Strips the
|
|
446
|
+
wrapper, picks the first meaningful clause, and clamps length."""
|
|
447
|
+
cleaned = _clean_noise(text, max_len=max_len * 2)
|
|
448
|
+
if not cleaned:
|
|
449
|
+
return (fallback_kind.title() if fallback_kind else "Cluster")[:max_len]
|
|
450
|
+
# Prefer the part before the first sentence break
|
|
451
|
+
head = re.split(r"[.!?。!?\n]", cleaned, maxsplit=1)[0].strip(" ,;:|/")
|
|
452
|
+
if not head:
|
|
453
|
+
head = cleaned
|
|
454
|
+
if len(head) > max_len:
|
|
455
|
+
head = head[: max_len - 1].rstrip(" ,;:|/") + "…"
|
|
456
|
+
return head
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# Stage 1 weight on signals. importance in [0,1] is the LLM/original value;
|
|
460
|
+
# we blend in recall_count and negative as dampeners.
|
|
461
|
+
W_RECALL = 0.10 # +0.10 per high-recall cluster, capped
|
|
462
|
+
W_NEGATIVE = 0.15 # -0.15 per negative feedback event, capped
|
|
463
|
+
RECALL_SATURATION = 5 # recall_count / 5 saturates the recall boost
|
|
464
|
+
NEGATIVE_SATURATION = 3 # 3 negative events = full dampener
|
|
465
|
+
|
|
466
|
+
# Stage 3 system prompt: per-cluster, keep/rewrite/drop actions.
|
|
467
|
+
#
|
|
468
|
+
# Length policy (v2 — completeness over compactness):
|
|
469
|
+
# * No hard character caps anywhere. The goal is one COMPLETE, USABLE fact
|
|
470
|
+
# per item, written so a human or another LLM can act on it later without
|
|
471
|
+
# re-reading the original transcript.
|
|
472
|
+
# * Brevity is still preferred where it does not lose information: prefer a
|
|
473
|
+
# single declarative sentence; collapse obvious tautologies; but NEVER cut
|
|
474
|
+
# a fact, a constraint, a number, a name, or a qualifier just to hit a
|
|
475
|
+
# length target.
|
|
476
|
+
# * Strip ONLY the wrapper noise (see below). The body of the fact stays.
|
|
477
|
+
_CLUSTER_SYSTEM = (
|
|
478
|
+
"You are an assistant that tidies a small cluster of personal memory snippets. "
|
|
479
|
+
"Treat every item as raw evidence and pull out the ATOMIC fact, never the wrapper.\n"
|
|
480
|
+
"COMPLETENESS OVER COMPACTNESS: the user is rebuilding a long-term personal "
|
|
481
|
+
"knowledge base. Losing a fact is much worse than writing a longer distill. "
|
|
482
|
+
"If the row contains a concrete decision, preference, number, name, "
|
|
483
|
+
"constraint, error, or workaround — preserve it verbatim. Prefer a single "
|
|
484
|
+
"declarative sentence but DO NOT truncate mid-clause to hit a length cap.\n"
|
|
485
|
+
"For EACH item return a JSON object with:\n"
|
|
486
|
+
' keep: boolean (true = the row contains real signal worth keeping long-term)\n'
|
|
487
|
+
' importance: number 0..1 (your new estimate of long-term importance for a '
|
|
488
|
+
'user-profile knowledge base — be strict; routine tool chatter is <0.3)\n'
|
|
489
|
+
' distill: a complete, standalone rewritten version of the core fact. '
|
|
490
|
+
'NO HARD CHARACTER LIMIT. Strip ONLY wrapper noise: greeting/pleasantries, '
|
|
491
|
+
'tool chatter ("Now let me check...", "Let me run..."), [thinking] blocks, '
|
|
492
|
+
'code fences, raw user prompts repeated verbatim, "Outcome:" prefixes that '
|
|
493
|
+
"just echo the user's question, file paths inside the user's home directory, "
|
|
494
|
+
'console-style progress narration, and meta commentary about the assistant '
|
|
495
|
+
'itself. The fact itself — every decision, number, name, error message, '
|
|
496
|
+
'command, or constraint — MUST survive intact. If the row has no real fact '
|
|
497
|
+
'after stripping wrapper noise, set distill to "".\n'
|
|
498
|
+
' tags: array of up to 5 lowercase tags (no duplicates, snake_case preferred).\n'
|
|
499
|
+
'Set keep=false for: pure repetition of an earlier item, status pings with no '
|
|
500
|
+
'fact ("Tests are green"), single-line acknowledgements, or text whose only '
|
|
501
|
+
'information is a file path the user already knows.\n'
|
|
502
|
+
'Reply with JSON: {"items": [...]}, no prose.'
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Stage 4 system prompt: build/update wiki pages from cluster summaries.
|
|
506
|
+
#
|
|
507
|
+
# Length policy (v2 — completeness over compactness):
|
|
508
|
+
# * No hard character caps on title / summary / body / slug.
|
|
509
|
+
# * Bullets must be COMPLETE atomic facts — every decision, number, name,
|
|
510
|
+
# constraint, error, or workaround from the source clusters must survive
|
|
511
|
+
# in at least one bullet on the relevant page.
|
|
512
|
+
# * Brevity is still preferred where it does not lose information — but the
|
|
513
|
+
# test is: "could the user act on this bullet without re-reading the
|
|
514
|
+
# original conversation?". If not, expand it.
|
|
515
|
+
_WIKI_SYSTEM = (
|
|
516
|
+
"You maintain a personal knowledge base for ONE user. You receive the user's "
|
|
517
|
+
"existing wiki pages plus a batch of distilled cluster summaries (each already "
|
|
518
|
+
"filtered for noise).\n"
|
|
519
|
+
"GOAL: each page must be a COMPLETE, ACTIONABLE atomic note the user would "
|
|
520
|
+
"actually want to recall later — NOT a quote of the original conversation, "
|
|
521
|
+
"and NOT a half-fact that loses the actionable detail.\n"
|
|
522
|
+
"COMPLETENESS OVER COMPACTNESS: if a cluster contains a number, a name, a "
|
|
523
|
+
"decision, a constraint, an error message, or a workaround, that detail "
|
|
524
|
+
"MUST land on the relevant page. The user will rely on this knowledge base "
|
|
525
|
+
"to skip re-deriving facts. Losing a fact is much worse than a longer page.\n"
|
|
526
|
+
"ALWAYS bucket into one of these dimensions, and make the slug reflect the topic:\n"
|
|
527
|
+
" preferences-<topic>, decision-<topic>, project-<topic>, domain-<topic>, "
|
|
528
|
+
"feedback-<topic>. Examples: 'prefers-dark-mode', 'decision-batch-size-50', "
|
|
529
|
+
"'project-loop-memory', 'domain-crypto-swing-trades', 'feedback-no-mixed-lang'.\n"
|
|
530
|
+
"Each page MUST have:\n"
|
|
531
|
+
' slug: lowercase, hyphen-separated, prefixed with the dimension; no hard '
|
|
532
|
+
'length cap, but keep it readable in a URL. NEVER a truncated user prompt.\n'
|
|
533
|
+
' title: a real noun phrase that names the topic; no hard length cap, but '
|
|
534
|
+
'keep it under ~12 words. NEVER a truncated user prompt.\n'
|
|
535
|
+
' summary: 1-3 sentence definition that stands on its own; no hard length '
|
|
536
|
+
'cap, no truncation mid-clause, MUST be understandable without the source.\n'
|
|
537
|
+
' body: bullet-point markdown, each bullet starting with "- ". One atomic '
|
|
538
|
+
'fact per bullet. NO hard cap on bullet count — use as many bullets as the '
|
|
539
|
+
'source clusters justify, typically 4-20 for a real page. Every decision, '
|
|
540
|
+
'number, name, error, constraint, or workaround from the source MUST appear '
|
|
541
|
+
'in at least one bullet. No prose paragraphs. No "Outcome: ..." echoes. '
|
|
542
|
+
'Code fences ONLY when the fact is literally a command or config snippet.\n'
|
|
543
|
+
' tags: 3-6 lowercase tags, snake_case\n'
|
|
544
|
+
' importance: 0..1 (1 = critical user preference/project, 0.3 = transient detail)\n'
|
|
545
|
+
' evidence_ids: list of memory ids that back this page (cite real ids from the input)\n'
|
|
546
|
+
"SKIP a cluster summary if it is just a user prompt, status update, or repeats "
|
|
547
|
+
"another cluster. PREFER updating an existing page (same slug) over creating a "
|
|
548
|
+
"near-duplicate — when updating, APPEND new atomic facts rather than rewriting "
|
|
549
|
+
"existing ones, so cumulative knowledge is preserved. Reply with JSON: "
|
|
550
|
+
"{\"pages\": [...]}. If nothing adds new info, reply {\"pages\": []}. No prose, "
|
|
551
|
+
"no markdown outside the JSON."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ----------------------------------------------------------------------------
|
|
556
|
+
# Public dataclass
|
|
557
|
+
# ----------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@dataclass
|
|
561
|
+
class EvolutionStats:
|
|
562
|
+
scanned: int = 0
|
|
563
|
+
rescored: int = 0
|
|
564
|
+
dropped: int = 0
|
|
565
|
+
deduped: int = 0 # near-duplicate memories collapsed
|
|
566
|
+
resummarized: int = 0
|
|
567
|
+
clusters: int = 0
|
|
568
|
+
cluster_calls: int = 0
|
|
569
|
+
wiki_calls: int = 0
|
|
570
|
+
wiki_created: int = 0
|
|
571
|
+
wiki_updated: int = 0
|
|
572
|
+
wiki_retired: int = 0 # noisy legacy wiki pages removed
|
|
573
|
+
elapsed_ms: float = 0.0
|
|
574
|
+
notes: list[str] = field(default_factory=list)
|
|
575
|
+
stages: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
576
|
+
|
|
577
|
+
def to_dict(self) -> dict[str, Any]:
|
|
578
|
+
return {
|
|
579
|
+
"scanned": self.scanned,
|
|
580
|
+
"rescored": self.rescored,
|
|
581
|
+
"dropped": self.dropped,
|
|
582
|
+
"deduped": self.deduped,
|
|
583
|
+
"resummarized": self.resummarized,
|
|
584
|
+
"clusters": self.clusters,
|
|
585
|
+
"cluster_calls": self.cluster_calls,
|
|
586
|
+
"wiki_calls": self.wiki_calls,
|
|
587
|
+
"wiki_created": self.wiki_created,
|
|
588
|
+
"wiki_updated": self.wiki_updated,
|
|
589
|
+
"wiki_retired": self.wiki_retired,
|
|
590
|
+
"elapsed_ms": round(self.elapsed_ms, 2),
|
|
591
|
+
"notes": self.notes,
|
|
592
|
+
"stages": self.stages,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ----------------------------------------------------------------------------
|
|
597
|
+
# Helpers
|
|
598
|
+
# ----------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _extract_json(text: str) -> Any | None:
|
|
602
|
+
"""Pull the first balanced JSON object out of an LLM reply."""
|
|
603
|
+
import re
|
|
604
|
+
if not text:
|
|
605
|
+
return None
|
|
606
|
+
# direct
|
|
607
|
+
try:
|
|
608
|
+
return json.loads(text)
|
|
609
|
+
except Exception:
|
|
610
|
+
pass
|
|
611
|
+
# fenced ```json ... ```
|
|
612
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", text, re.S)
|
|
613
|
+
if m:
|
|
614
|
+
try:
|
|
615
|
+
return json.loads(m.group(1))
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
# first {...} or first [...]
|
|
619
|
+
for opener, closer in [("{", "}"), ("[", "]")]:
|
|
620
|
+
i = text.find(opener)
|
|
621
|
+
if i < 0:
|
|
622
|
+
continue
|
|
623
|
+
depth = 0
|
|
624
|
+
for j in range(i, len(text)):
|
|
625
|
+
c = text[j]
|
|
626
|
+
if c == opener:
|
|
627
|
+
depth += 1
|
|
628
|
+
elif c == closer:
|
|
629
|
+
depth -= 1
|
|
630
|
+
if depth == 0:
|
|
631
|
+
try:
|
|
632
|
+
return json.loads(text[i : j + 1])
|
|
633
|
+
except Exception:
|
|
634
|
+
break
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _hash_embed(text: str, dim: int = 128) -> list[float]:
|
|
639
|
+
"""Deterministic 128-dim embedding (feature hashing). Cheap fallback so
|
|
640
|
+
semantic batching works even without sentence-transformers installed."""
|
|
641
|
+
v = [0.0] * dim
|
|
642
|
+
tokens = (text or "").lower().split()
|
|
643
|
+
if not tokens:
|
|
644
|
+
return v
|
|
645
|
+
for tok in tokens:
|
|
646
|
+
h = hashlib.md5(tok.encode("utf-8"), usedforsecurity=False).digest()
|
|
647
|
+
idx = h[0] % dim
|
|
648
|
+
sign = 1.0 if (h[1] & 1) else -1.0
|
|
649
|
+
v[idx] += sign
|
|
650
|
+
n = math.sqrt(sum(x * x for x in v)) or 1.0
|
|
651
|
+
return [x / n for x in v]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _cos(a: list[float], b: list[float]) -> float:
|
|
655
|
+
if not a or not b or len(a) != len(b):
|
|
656
|
+
return 0.0
|
|
657
|
+
return sum(x * y for x, y in zip(a, b, strict=False))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ----------------------------------------------------------------------------
|
|
661
|
+
# Consolidator
|
|
662
|
+
# ----------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class EvolutionConsolidator:
|
|
666
|
+
"""5-stage distillation pipeline. Drop-in replacement for
|
|
667
|
+
``LLMConsolidator.run``."""
|
|
668
|
+
|
|
669
|
+
def __init__(
|
|
670
|
+
self,
|
|
671
|
+
store: MemoryStore,
|
|
672
|
+
provider: LLMClient,
|
|
673
|
+
config: dict[str, Any] | None = None,
|
|
674
|
+
) -> None:
|
|
675
|
+
self.store = store
|
|
676
|
+
self.provider = provider
|
|
677
|
+
cfg = dict(config or {})
|
|
678
|
+
self.config = cfg
|
|
679
|
+
# Output locale for wiki synthesis (default ``zh`` — the same
|
|
680
|
+
# default the front-end uses when ``store.lang`` is unset).
|
|
681
|
+
# Honoured by ``wiki_system_prompt(self._lang)`` in Stage 4 and
|
|
682
|
+
# by the expansion retry inside ``_stage4_wiki_synthesis``.
|
|
683
|
+
self._lang: str = normalise_lang(cfg.get("lang"))
|
|
684
|
+
self._cache: dict[str, str] = {}
|
|
685
|
+
self._cache_ttl = 300.0
|
|
686
|
+
self._cache_ts: dict[str, float] = {}
|
|
687
|
+
self._run_id: str | None = None
|
|
688
|
+
|
|
689
|
+
# --- public ----------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
def set_run_id(self, run_id: str | None) -> None:
|
|
692
|
+
self._run_id = run_id
|
|
693
|
+
|
|
694
|
+
def _classify_wiki_scope(
|
|
695
|
+
self,
|
|
696
|
+
*,
|
|
697
|
+
title: str,
|
|
698
|
+
body: str,
|
|
699
|
+
summary: str,
|
|
700
|
+
tags: list[str],
|
|
701
|
+
evidence_ids: list[str],
|
|
702
|
+
existing: dict | None,
|
|
703
|
+
) -> tuple[str, dict]:
|
|
704
|
+
"""Route evolution-generated pages without widening old scopes."""
|
|
705
|
+
from ..wiki.classifier import classify_page
|
|
706
|
+
from ..wiki.scope import (
|
|
707
|
+
auto_scope_config,
|
|
708
|
+
build_scope_audit,
|
|
709
|
+
derive_default_scope,
|
|
710
|
+
)
|
|
711
|
+
scope_cfg = auto_scope_config(self.store)
|
|
712
|
+
enabled = bool(scope_cfg["enabled"])
|
|
713
|
+
mode = str(scope_cfg["mode"])
|
|
714
|
+
try:
|
|
715
|
+
memories = self.store.list_memories(
|
|
716
|
+
ids=list(evidence_ids), limit=max(1, len(evidence_ids))
|
|
717
|
+
)
|
|
718
|
+
except Exception:
|
|
719
|
+
memories = []
|
|
720
|
+
evidence_sources = [
|
|
721
|
+
str(getattr(memory, "source", "") or "").strip()
|
|
722
|
+
for memory in memories
|
|
723
|
+
if getattr(memory, "source", None)
|
|
724
|
+
]
|
|
725
|
+
classification = classify_page(
|
|
726
|
+
title=title,
|
|
727
|
+
body=body,
|
|
728
|
+
summary=summary,
|
|
729
|
+
tags=tags,
|
|
730
|
+
evidence_sources=evidence_sources,
|
|
731
|
+
mode=mode if enabled else "off",
|
|
732
|
+
)
|
|
733
|
+
stale_default_scope = False
|
|
734
|
+
if existing and existing.get("scope") and not (existing.get("evidence_ids") or []):
|
|
735
|
+
audit = existing.get("auto_classification") or {}
|
|
736
|
+
if isinstance(audit, str):
|
|
737
|
+
try:
|
|
738
|
+
audit = json.loads(audit)
|
|
739
|
+
except (TypeError, ValueError):
|
|
740
|
+
audit = {}
|
|
741
|
+
stale_default_scope = bool(
|
|
742
|
+
isinstance(audit, dict)
|
|
743
|
+
and audit.get("scope_decision") == "default-source"
|
|
744
|
+
and not audit.get("source_hint")
|
|
745
|
+
)
|
|
746
|
+
if existing and existing.get("scope") and not stale_default_scope:
|
|
747
|
+
scope = str(existing["scope"]).strip().lower()
|
|
748
|
+
decision = "preserved-existing"
|
|
749
|
+
elif enabled and classification.auto_global:
|
|
750
|
+
scope = "global"
|
|
751
|
+
decision = "auto-global"
|
|
752
|
+
else:
|
|
753
|
+
scope = derive_default_scope(evidence_ids=evidence_ids, store=self.store)
|
|
754
|
+
decision = "repaired-evidence-source" if stale_default_scope else "default-source"
|
|
755
|
+
return scope, build_scope_audit(
|
|
756
|
+
classification,
|
|
757
|
+
scope=scope,
|
|
758
|
+
decision=decision,
|
|
759
|
+
enabled=enabled,
|
|
760
|
+
mode=mode,
|
|
761
|
+
existing=existing,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
def run(
|
|
765
|
+
self,
|
|
766
|
+
memories: list[StoredMemory] | None = None,
|
|
767
|
+
progress: Callable[[int, int], None] | None = None,
|
|
768
|
+
limit: int = 300,
|
|
769
|
+
) -> EvolutionStats:
|
|
770
|
+
t0 = time.time()
|
|
771
|
+
stats = EvolutionStats()
|
|
772
|
+
cfg = self.config
|
|
773
|
+
dry_run = bool(cfg.get("dry_run", False))
|
|
774
|
+
|
|
775
|
+
if memories is None:
|
|
776
|
+
memories = self.store.list_memories(limit=limit)
|
|
777
|
+
memories = list(memories)
|
|
778
|
+
stats.scanned = len(memories)
|
|
779
|
+
if not memories:
|
|
780
|
+
stats.notes.append("no memories")
|
|
781
|
+
stats.elapsed_ms = (time.time() - t0) * 1000
|
|
782
|
+
return stats
|
|
783
|
+
|
|
784
|
+
if progress:
|
|
785
|
+
try:
|
|
786
|
+
progress(0, len(memories))
|
|
787
|
+
except Exception:
|
|
788
|
+
pass
|
|
789
|
+
|
|
790
|
+
# Stage 0: Pre-cluster cleanup. Drop pure-status noise and collapse
|
|
791
|
+
# near-duplicate memories so the cluster LLM sees focused input.
|
|
792
|
+
s0_t = time.time()
|
|
793
|
+
pre_count = len(memories)
|
|
794
|
+
memories = self._stage0_filter_noise(memories)
|
|
795
|
+
memories = self._stage0_dedup_memories(memories, stats)
|
|
796
|
+
s0 = {
|
|
797
|
+
"in": pre_count,
|
|
798
|
+
"out": len(memories),
|
|
799
|
+
"ms": round((time.time() - s0_t) * 1000, 1),
|
|
800
|
+
"note": f"noise-filtered {pre_count - stats.deduped - len(memories) + pre_count} · deduped {stats.deduped}",
|
|
801
|
+
"evidence_ids": [m.id for m in memories[:200]],
|
|
802
|
+
}
|
|
803
|
+
stats.stages["clean"] = s0
|
|
804
|
+
self._record_stage("clean", pre_count, len(memories), s0["note"], s0)
|
|
805
|
+
if not memories:
|
|
806
|
+
stats.notes.append("no memories after cleanup")
|
|
807
|
+
stats.elapsed_ms = (time.time() - t0) * 1000
|
|
808
|
+
return stats
|
|
809
|
+
|
|
810
|
+
# Stage 1: Signal-aware rescoring
|
|
811
|
+
s1_t = time.time()
|
|
812
|
+
stage1_in = len(memories)
|
|
813
|
+
rescored_map = self._stage1_signal_scoring(memories)
|
|
814
|
+
stats.rescored = sum(1 for v in rescored_map.values() if v)
|
|
815
|
+
# After stage 1 we run rescore_all to update the score column.
|
|
816
|
+
# Capture how many actually changed.
|
|
817
|
+
rescore_changed = 0
|
|
818
|
+
try:
|
|
819
|
+
rescore_changed = self.store.rescore_all(half_life_days=30.0)
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
822
|
+
s1 = {
|
|
823
|
+
"in": stage1_in,
|
|
824
|
+
"out": rescore_changed or stage1_in,
|
|
825
|
+
"ms": round((time.time() - s1_t) * 1000, 1),
|
|
826
|
+
"note": f"rescored {rescore_changed}/{stage1_in} memories",
|
|
827
|
+
"evidence_ids": [m.id for m in memories[:200]],
|
|
828
|
+
}
|
|
829
|
+
stats.stages["score"] = s1
|
|
830
|
+
self._record_stage("score", stage1_in, s1["out"], s1["note"], s1)
|
|
831
|
+
if progress:
|
|
832
|
+
try:
|
|
833
|
+
progress(int(len(memories) * 0.2), len(memories))
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
836
|
+
|
|
837
|
+
# Stage 2: Semantic batching
|
|
838
|
+
s2_t = time.time()
|
|
839
|
+
clusters = self._stage2_cluster(memories, max_per_cluster=CLUSTER_MAX)
|
|
840
|
+
stats.clusters = len(clusters)
|
|
841
|
+
s2 = {
|
|
842
|
+
"in": stage1_in,
|
|
843
|
+
"out": len(clusters),
|
|
844
|
+
"ms": round((time.time() - s2_t) * 1000, 1),
|
|
845
|
+
"note": f"formed {len(clusters)} clusters",
|
|
846
|
+
}
|
|
847
|
+
stats.stages["cluster"] = s2
|
|
848
|
+
self._record_stage("cluster", stage1_in, len(clusters), s2["note"], s2)
|
|
849
|
+
if progress:
|
|
850
|
+
try:
|
|
851
|
+
progress(int(len(memories) * 0.4), len(memories))
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
|
|
855
|
+
# Stage 3: Per-cluster distillation
|
|
856
|
+
s3_t = time.time()
|
|
857
|
+
cluster_summaries: list[dict[str, Any]] = []
|
|
858
|
+
kept_ids: set = set()
|
|
859
|
+
dropped_ids: set = set()
|
|
860
|
+
is_rule = self._echo_provider()
|
|
861
|
+
for ci, cluster in enumerate(clusters):
|
|
862
|
+
if is_rule:
|
|
863
|
+
# No LLM -> keep everything as-is. Build a summary from
|
|
864
|
+
# the top important items so Stage 4 still has signal.
|
|
865
|
+
# We attach the cleaned, top-N items directly so Stage 4
|
|
866
|
+
# can build real bullets (instead of one stitched blob).
|
|
867
|
+
ranked = sorted(
|
|
868
|
+
cluster,
|
|
869
|
+
key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
|
|
870
|
+
)
|
|
871
|
+
top = ranked[:7]
|
|
872
|
+
summary_text = " / ".join((m.text or "")[:120] for m in top[:3])[:400]
|
|
873
|
+
kinds = [m.kind for m in cluster if m.kind]
|
|
874
|
+
kind = max(set(kinds), key=kinds.count) if kinds else ""
|
|
875
|
+
all_tags = [t for m in cluster for t in (m.tags or []) if t]
|
|
876
|
+
dom_tag = max(set(all_tags), key=all_tags.count) if all_tags else ""
|
|
877
|
+
avg_imp = sum((m.importance or 0) for m in cluster) / max(1, len(cluster))
|
|
878
|
+
summary = {
|
|
879
|
+
"text": summary_text,
|
|
880
|
+
"size": len(cluster),
|
|
881
|
+
"kept": len(cluster),
|
|
882
|
+
"dropped": 0,
|
|
883
|
+
"evidence_ids": [m.id for m in cluster][:50],
|
|
884
|
+
"kind": kind,
|
|
885
|
+
"dominating_tag": dom_tag,
|
|
886
|
+
"avg_importance": round(avg_imp, 3),
|
|
887
|
+
# Top-ranked items (cleaned) so Stage 4 can build
|
|
888
|
+
# real bullets, not raw concatenation.
|
|
889
|
+
"items": top,
|
|
890
|
+
}
|
|
891
|
+
actions = {m.id: {"keep": True, "importance": m.importance, "distill": "", "tags": list(m.tags or [])} for m in cluster}
|
|
892
|
+
else:
|
|
893
|
+
summary, actions = self._stage3_distill_cluster(cluster, cfg, stats)
|
|
894
|
+
cluster_summaries.append(summary)
|
|
895
|
+
if not dry_run:
|
|
896
|
+
kept = self._apply_actions(cluster, actions, stats)
|
|
897
|
+
kept_ids |= kept
|
|
898
|
+
for m in cluster:
|
|
899
|
+
if m.id not in kept:
|
|
900
|
+
dropped_ids.add(m.id)
|
|
901
|
+
if progress:
|
|
902
|
+
try:
|
|
903
|
+
progress(int(len(memories) * (0.4 + 0.4 * (ci + 1) / max(1, len(clusters)))), len(memories))
|
|
904
|
+
except Exception:
|
|
905
|
+
pass
|
|
906
|
+
s3 = {
|
|
907
|
+
"in": len(clusters),
|
|
908
|
+
"out": len([s for s in cluster_summaries if s.get("text")]),
|
|
909
|
+
"ms": round((time.time() - s3_t) * 1000, 1),
|
|
910
|
+
"note": f"{stats.cluster_calls} LLM calls · {len(clusters)} clusters · {len(kept_ids)} kept / {len(dropped_ids)} dropped",
|
|
911
|
+
"evidence_ids": list(kept_ids)[:200],
|
|
912
|
+
"kept_ids": list(kept_ids)[:200],
|
|
913
|
+
"dropped_ids": list(dropped_ids)[:200],
|
|
914
|
+
}
|
|
915
|
+
stats.stages["distill"] = s3
|
|
916
|
+
self._record_stage("distill", len(clusters), s3["out"], s3["note"], s3)
|
|
917
|
+
|
|
918
|
+
# Stage 4: Hierarchical wiki synthesis
|
|
919
|
+
s4_t = time.time()
|
|
920
|
+
wiki_pages = self._stage4_wiki_synthesis(cluster_summaries, cfg, stats)
|
|
921
|
+
if not dry_run:
|
|
922
|
+
stats.wiki_created = wiki_pages.get("created", 0)
|
|
923
|
+
stats.wiki_updated = wiki_pages.get("updated", 0)
|
|
924
|
+
# Collect evidence ids from the wiki pages so drill-down can list them
|
|
925
|
+
wiki_evidence: list = []
|
|
926
|
+
try:
|
|
927
|
+
for pg in self.store.list_wiki_pages(limit=50):
|
|
928
|
+
eids = pg.get("evidence_ids") or []
|
|
929
|
+
if isinstance(eids, list):
|
|
930
|
+
wiki_evidence.extend([str(x) for x in eids])
|
|
931
|
+
except Exception:
|
|
932
|
+
pass
|
|
933
|
+
s4 = {
|
|
934
|
+
"in": len(cluster_summaries),
|
|
935
|
+
"out": stats.wiki_created + stats.wiki_updated,
|
|
936
|
+
"ms": round((time.time() - s4_t) * 1000, 1),
|
|
937
|
+
"note": f"created={stats.wiki_created} updated={stats.wiki_updated}",
|
|
938
|
+
"evidence_ids": wiki_evidence[:200],
|
|
939
|
+
}
|
|
940
|
+
stats.stages["wiki"] = s4
|
|
941
|
+
self._record_stage("wiki", len(cluster_summaries), s4["out"], s4["note"], s4)
|
|
942
|
+
|
|
943
|
+
# Stage 4.5: Retire noisy wiki pages. Conservative: only touches
|
|
944
|
+
# pages whose title is a raw user prompt, body is glued fragments,
|
|
945
|
+
# or body has no bullets. Well-formed LLM-authored pages never
|
|
946
|
+
# match these heuristics.
|
|
947
|
+
if not dry_run:
|
|
948
|
+
try:
|
|
949
|
+
retired = self._stage4_cleanup_wiki(stats)
|
|
950
|
+
stats.wiki_retired = retired
|
|
951
|
+
except Exception:
|
|
952
|
+
pass
|
|
953
|
+
if progress:
|
|
954
|
+
try:
|
|
955
|
+
progress(len(memories), len(memories))
|
|
956
|
+
except Exception:
|
|
957
|
+
pass
|
|
958
|
+
|
|
959
|
+
# Stage 5: Evolution memo
|
|
960
|
+
s5_t = time.time()
|
|
961
|
+
self._stage5_memo(stats)
|
|
962
|
+
s5 = {
|
|
963
|
+
"in": stats.wiki_created + stats.wiki_updated,
|
|
964
|
+
"out": 1,
|
|
965
|
+
"ms": round((time.time() - s5_t) * 1000, 1),
|
|
966
|
+
"note": "evolution memo updated",
|
|
967
|
+
}
|
|
968
|
+
stats.stages["memo"] = s5
|
|
969
|
+
self._record_stage("memo", s5["in"], 1, s5["note"], s5)
|
|
970
|
+
|
|
971
|
+
# Rescore from new importance
|
|
972
|
+
if not dry_run and stats.rescored:
|
|
973
|
+
try:
|
|
974
|
+
self.store.rescore_all(half_life_days=30.0)
|
|
975
|
+
except Exception:
|
|
976
|
+
pass
|
|
977
|
+
|
|
978
|
+
stats.elapsed_ms = (time.time() - t0) * 1000
|
|
979
|
+
return stats
|
|
980
|
+
|
|
981
|
+
# --- Stage 1: Signal-aware scoring ----------------------------------
|
|
982
|
+
|
|
983
|
+
def _stage1_signal_scoring(
|
|
984
|
+
self, memories: list[StoredMemory]
|
|
985
|
+
) -> dict[str, bool]:
|
|
986
|
+
"""Blend original importance with behavioural signals. We do not
|
|
987
|
+
write back here — the per-cluster LLM pass is what writes the new
|
|
988
|
+
importance. This stage just gives the LLM richer ranking input."""
|
|
989
|
+
rescored: dict[str, bool] = {}
|
|
990
|
+
for m in memories:
|
|
991
|
+
sig = self.store.get_signal(m.id)
|
|
992
|
+
boost = min(W_RECALL, W_RECALL * sig["recall_count"] / RECALL_SATURATION)
|
|
993
|
+
damp = min(W_NEGATIVE, W_NEGATIVE * sig["negative"] / NEGATIVE_SATURATION)
|
|
994
|
+
adj = (m.importance or 0.0) + boost - damp
|
|
995
|
+
adj = max(0.0, min(1.0, adj))
|
|
996
|
+
if abs(adj - (m.importance or 0.0)) > 0.05:
|
|
997
|
+
rescored[m.id] = True
|
|
998
|
+
return rescored
|
|
999
|
+
|
|
1000
|
+
# --- Stage 2: Semantic batching -------------------------------------
|
|
1001
|
+
|
|
1002
|
+
def _stage2_cluster(
|
|
1003
|
+
self,
|
|
1004
|
+
memories: list[StoredMemory],
|
|
1005
|
+
max_per_cluster: int = CLUSTER_MAX,
|
|
1006
|
+
) -> list[list[StoredMemory]]:
|
|
1007
|
+
"""Greedy cosine clustering using a hashed embedding. Memories that
|
|
1008
|
+
lack enough text to embed fall into a 'misc' cluster of their own
|
|
1009
|
+
so we never lose them."""
|
|
1010
|
+
if not memories:
|
|
1011
|
+
return []
|
|
1012
|
+
|
|
1013
|
+
# Sort by adjusted importance desc so high-signal memories seed clusters
|
|
1014
|
+
def _score(m: StoredMemory) -> float:
|
|
1015
|
+
sig = self.store.get_signal(m.id)
|
|
1016
|
+
boost = min(0.1, 0.1 * sig["recall_count"] / RECALL_SATURATION)
|
|
1017
|
+
damp = min(0.15, 0.15 * sig["negative"] / NEGATIVE_SATURATION)
|
|
1018
|
+
return (m.importance or 0.0) + boost - damp
|
|
1019
|
+
|
|
1020
|
+
ranked = sorted(memories, key=_score, reverse=True)
|
|
1021
|
+
|
|
1022
|
+
clusters: list[dict[str, Any]] = [] # {centroid, items}
|
|
1023
|
+
for m in ranked:
|
|
1024
|
+
text = (m.text or "").strip()
|
|
1025
|
+
if len(text) < 8:
|
|
1026
|
+
# super short items go to a misc cluster at the end
|
|
1027
|
+
clusters.append({"centroid": None, "items": [m], "misc": True})
|
|
1028
|
+
continue
|
|
1029
|
+
emb = _hash_embed(text)
|
|
1030
|
+
placed = False
|
|
1031
|
+
for cl in clusters:
|
|
1032
|
+
if cl.get("misc") or len(cl["items"]) >= max_per_cluster:
|
|
1033
|
+
continue
|
|
1034
|
+
sim = _cos(emb, cl["centroid"])
|
|
1035
|
+
if sim >= 0.35: # hashed embeddings are noisier, lower threshold
|
|
1036
|
+
cl["items"].append(m)
|
|
1037
|
+
# update centroid (running mean)
|
|
1038
|
+
n = len(cl["items"])
|
|
1039
|
+
cl["centroid"] = [
|
|
1040
|
+
(cl["centroid"][i] * (n - 1) + emb[i]) / n for i in range(len(emb))
|
|
1041
|
+
]
|
|
1042
|
+
placed = True
|
|
1043
|
+
break
|
|
1044
|
+
if not placed:
|
|
1045
|
+
clusters.append({"centroid": emb, "items": [m], "misc": False})
|
|
1046
|
+
|
|
1047
|
+
# Second pass: SESSION-AWARE MERGE. The user explicitly asked
|
|
1048
|
+
# that a single conversation not be fragmented into too many
|
|
1049
|
+
# knowledge pieces. After the cosine pass, we look for small
|
|
1050
|
+
# clusters (<= 8 items) that share session_ids with other
|
|
1051
|
+
# clusters and merge them so a single conversation produces
|
|
1052
|
+
# one wiki page, not many. We cap the merge to avoid giant
|
|
1053
|
+
# mixed clusters; only merge into the cluster with the highest
|
|
1054
|
+
# cumulative importance.
|
|
1055
|
+
def _session_counts(items):
|
|
1056
|
+
counts: dict[str, int] = {}
|
|
1057
|
+
for it in items:
|
|
1058
|
+
sid = getattr(it, "session_id", None)
|
|
1059
|
+
if sid:
|
|
1060
|
+
counts[sid] = counts.get(sid, 0) + 1
|
|
1061
|
+
return counts
|
|
1062
|
+
|
|
1063
|
+
# Build a list of (cluster, session_counts) for non-misc clusters
|
|
1064
|
+
non_misc = [c for c in clusters if not c.get("misc")]
|
|
1065
|
+
for c in non_misc:
|
|
1066
|
+
c["_sc"] = _session_counts(c["items"])
|
|
1067
|
+
# Repeat: find the smallest cluster, see if any other cluster
|
|
1068
|
+
# shares a session, and merge into the one with higher total
|
|
1069
|
+
# importance. Bound iterations to keep this O(N) not O(N²).
|
|
1070
|
+
for _ in range(20):
|
|
1071
|
+
merged = False
|
|
1072
|
+
non_misc.sort(key=lambda c: (len(c["items"]), -sum(getattr(it, "importance", 0) or 0 for it in c["items"])))
|
|
1073
|
+
for i, small in enumerate(non_misc):
|
|
1074
|
+
if len(small["items"]) >= 9:
|
|
1075
|
+
continue # only merge small clusters
|
|
1076
|
+
if not small["_sc"]:
|
|
1077
|
+
continue
|
|
1078
|
+
best_j = -1
|
|
1079
|
+
best_overlap = 0
|
|
1080
|
+
for j, big in enumerate(non_misc):
|
|
1081
|
+
if i == j:
|
|
1082
|
+
continue
|
|
1083
|
+
if len(big["items"]) >= max_per_cluster:
|
|
1084
|
+
continue
|
|
1085
|
+
overlap = sum(
|
|
1086
|
+
min(small["_sc"].get(s, 0), big["_sc"].get(s, 0))
|
|
1087
|
+
for s in small["_sc"]
|
|
1088
|
+
)
|
|
1089
|
+
if overlap >= 2 and overlap > best_overlap:
|
|
1090
|
+
best_overlap = overlap
|
|
1091
|
+
best_j = j
|
|
1092
|
+
if best_j >= 0:
|
|
1093
|
+
big = non_misc[best_j]
|
|
1094
|
+
big["items"].extend(small["items"])
|
|
1095
|
+
# Recompute centroid (running mean)
|
|
1096
|
+
n = len(big["items"])
|
|
1097
|
+
if big["centroid"] is not None:
|
|
1098
|
+
# Re-derive centroid from all members (cheap)
|
|
1099
|
+
embs = [_hash_embed(getattr(m, "text", "") or "") for m in big["items"]]
|
|
1100
|
+
dim = len(embs[0]) if embs else 128
|
|
1101
|
+
cent = [0.0] * dim
|
|
1102
|
+
for e in embs:
|
|
1103
|
+
for k in range(dim):
|
|
1104
|
+
cent[k] += e[k]
|
|
1105
|
+
big["centroid"] = [x / n for x in cent]
|
|
1106
|
+
big["_sc"] = _session_counts(big["items"])
|
|
1107
|
+
non_misc.remove(small)
|
|
1108
|
+
merged = True
|
|
1109
|
+
break
|
|
1110
|
+
if not merged:
|
|
1111
|
+
break
|
|
1112
|
+
|
|
1113
|
+
# Final sort: largest first, misc last
|
|
1114
|
+
clusters = non_misc + [c for c in clusters if c.get("misc")]
|
|
1115
|
+
clusters.sort(key=lambda c: (c.get("misc", False), -len(c["items"])))
|
|
1116
|
+
return [c["items"] for c in clusters]
|
|
1117
|
+
|
|
1118
|
+
# --- Stage 3: Per-cluster distillation ------------------------------
|
|
1119
|
+
|
|
1120
|
+
def _stage3_distill_cluster(
|
|
1121
|
+
self,
|
|
1122
|
+
cluster: list[StoredMemory],
|
|
1123
|
+
cfg: dict[str, Any],
|
|
1124
|
+
stats: EvolutionStats,
|
|
1125
|
+
) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
1126
|
+
"""Return (cluster_summary, per-item actions)."""
|
|
1127
|
+
# Build user payload
|
|
1128
|
+
payload = []
|
|
1129
|
+
for m in cluster:
|
|
1130
|
+
sig = self.store.get_signal(m.id)
|
|
1131
|
+
payload.append({
|
|
1132
|
+
"id": m.id,
|
|
1133
|
+
"kind": m.kind,
|
|
1134
|
+
"tags": list(m.tags or []),
|
|
1135
|
+
"importance": round(m.importance or 0.0, 3),
|
|
1136
|
+
"recall_count": sig["recall_count"],
|
|
1137
|
+
"negative": sig["negative"],
|
|
1138
|
+
"text": (m.text or "")[:600],
|
|
1139
|
+
})
|
|
1140
|
+
user_prompt = json.dumps({"items": payload}, ensure_ascii=False)
|
|
1141
|
+
cache_key = hashlib.sha1(
|
|
1142
|
+
(user_prompt + "||" + (getattr(self.provider, "model", "?") or "?")
|
|
1143
|
+
+ "||cluster||" + str(float(cfg.get("temperature") or 0.2))).encode()
|
|
1144
|
+
, usedforsecurity=False).hexdigest()
|
|
1145
|
+
reply = self._cached_call(cache_key, _CLUSTER_SYSTEM, user_prompt, cfg, stats, kind="cluster")
|
|
1146
|
+
|
|
1147
|
+
actions: dict[str, dict[str, Any]] = {}
|
|
1148
|
+
parsed = _extract_json(reply or "")
|
|
1149
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
|
|
1150
|
+
for it in parsed["items"]:
|
|
1151
|
+
if isinstance(it, dict) and "id" in it:
|
|
1152
|
+
actions[str(it["id"])] = it
|
|
1153
|
+
|
|
1154
|
+
# Build a 1-sentence cluster summary from the LLM reply (or fall
|
|
1155
|
+
# back to a stitched top-3 important items).
|
|
1156
|
+
cluster_text = ""
|
|
1157
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("summary"), str):
|
|
1158
|
+
cluster_text = parsed["summary"].strip()[:400]
|
|
1159
|
+
if not cluster_text:
|
|
1160
|
+
top = sorted(cluster, key=lambda m: -(m.importance or 0))[:3]
|
|
1161
|
+
cluster_text = " / ".join((m.text or "")[:120] for m in top)[:400]
|
|
1162
|
+
|
|
1163
|
+
# Pull memory ids in this cluster so downstream wiki synthesis
|
|
1164
|
+
# can cite them as evidence. Use the kept ones when the LLM
|
|
1165
|
+
# classified them, otherwise all cluster items.
|
|
1166
|
+
kept_set = {m_id for m_id, a in actions.items() if a.get("keep") is True}
|
|
1167
|
+
evidence_ids = [m.id for m in cluster if (not kept_set or m.id in kept_set)]
|
|
1168
|
+
# Most-common kind and tag — used by the rule-based wiki step to
|
|
1169
|
+
# produce a meaningful title when no LLM is involved.
|
|
1170
|
+
kinds = [m.kind for m in cluster if m.kind]
|
|
1171
|
+
kind = max(set(kinds), key=kinds.count) if kinds else ""
|
|
1172
|
+
all_tags = [t for m in cluster for t in (m.tags or []) if t]
|
|
1173
|
+
dom_tag = max(set(all_tags), key=all_tags.count) if all_tags else ""
|
|
1174
|
+
avg_imp = sum((m.importance or 0) for m in cluster) / max(1, len(cluster))
|
|
1175
|
+
|
|
1176
|
+
summary = {
|
|
1177
|
+
"text": cluster_text,
|
|
1178
|
+
"size": len(cluster),
|
|
1179
|
+
"kept": len(evidence_ids) if evidence_ids else sum(1 for a in actions.values() if a.get("keep") is True),
|
|
1180
|
+
"dropped": len(cluster) - (len(evidence_ids) if evidence_ids else sum(1 for a in actions.values() if a.get("keep") is True)),
|
|
1181
|
+
"evidence_ids": evidence_ids[:50],
|
|
1182
|
+
"kind": kind,
|
|
1183
|
+
"dominating_tag": dom_tag,
|
|
1184
|
+
"avg_importance": round(avg_imp, 3),
|
|
1185
|
+
}
|
|
1186
|
+
return summary, actions
|
|
1187
|
+
|
|
1188
|
+
def _apply_actions(
|
|
1189
|
+
self,
|
|
1190
|
+
cluster: list[StoredMemory],
|
|
1191
|
+
actions: dict[str, dict[str, Any]],
|
|
1192
|
+
stats: EvolutionStats,
|
|
1193
|
+
) -> set:
|
|
1194
|
+
"""Apply keep / drop / rewrite actions to the DB. Returns the set of
|
|
1195
|
+
kept memory ids."""
|
|
1196
|
+
kept: set = set()
|
|
1197
|
+
for m in cluster:
|
|
1198
|
+
act = actions.get(m.id)
|
|
1199
|
+
if not act:
|
|
1200
|
+
# no LLM action => keep as-is
|
|
1201
|
+
kept.add(m.id)
|
|
1202
|
+
continue
|
|
1203
|
+
if act.get("keep") is False:
|
|
1204
|
+
try:
|
|
1205
|
+
self.store.delete_memory(m.id)
|
|
1206
|
+
stats.dropped += 1
|
|
1207
|
+
except Exception:
|
|
1208
|
+
kept.add(m.id)
|
|
1209
|
+
continue
|
|
1210
|
+
new_text = (act.get("distill") or "").strip()
|
|
1211
|
+
new_importance = act.get("importance")
|
|
1212
|
+
new_tags = act.get("tags")
|
|
1213
|
+
updates: dict[str, Any] = {}
|
|
1214
|
+
try:
|
|
1215
|
+
if isinstance(new_importance, (int, float)):
|
|
1216
|
+
ni = max(0.0, min(1.0, float(new_importance)))
|
|
1217
|
+
if abs(ni - (m.importance or 0.0)) > 1e-3:
|
|
1218
|
+
updates["importance"] = ni
|
|
1219
|
+
stats.rescored += 1
|
|
1220
|
+
except Exception:
|
|
1221
|
+
pass
|
|
1222
|
+
if new_text and new_text != (m.text or "") and len(new_text) <= 400:
|
|
1223
|
+
updates["text"] = new_text
|
|
1224
|
+
stats.resummarized += 1
|
|
1225
|
+
if isinstance(new_tags, list):
|
|
1226
|
+
tags_clean = [str(t).strip().lower() for t in new_tags if t and str(t).strip()][:6]
|
|
1227
|
+
if tags_clean:
|
|
1228
|
+
updates["tags"] = tags_clean
|
|
1229
|
+
if updates:
|
|
1230
|
+
try:
|
|
1231
|
+
self.store.upsert_memory(
|
|
1232
|
+
id=m.id,
|
|
1233
|
+
kind=m.kind,
|
|
1234
|
+
text=updates.get("text", m.text),
|
|
1235
|
+
importance=updates.get("importance", m.importance),
|
|
1236
|
+
source=m.source,
|
|
1237
|
+
session_id=m.session_id,
|
|
1238
|
+
created_at=m.created_at,
|
|
1239
|
+
updated_at=time.time(),
|
|
1240
|
+
ttl=m.ttl,
|
|
1241
|
+
tags=updates.get("tags", m.tags),
|
|
1242
|
+
embedding=m.embedding,
|
|
1243
|
+
)
|
|
1244
|
+
except Exception:
|
|
1245
|
+
pass
|
|
1246
|
+
kept.add(m.id)
|
|
1247
|
+
return kept
|
|
1248
|
+
|
|
1249
|
+
# --- Stage 3.5: Wiki cleanup ---------------------------------------
|
|
1250
|
+
|
|
1251
|
+
def _stage4_cleanup_wiki(self, stats: EvolutionStats) -> int:
|
|
1252
|
+
"""Retire noisy legacy wiki pages produced by older rule-based runs.
|
|
1253
|
+
|
|
1254
|
+
A page is considered noisy when:
|
|
1255
|
+
* its body contains the "/ " separator glue (old fallback signature)
|
|
1256
|
+
* its title is literally a truncated user prompt (>40 chars starting
|
|
1257
|
+
with [codex] / [claude] / User intent / Outcome: / 帮我 / 分析)
|
|
1258
|
+
* its importance is < 0.35 AND it has no evidence ids
|
|
1259
|
+
* it has zero bullets in its body (no real content)
|
|
1260
|
+
|
|
1261
|
+
Returns the number of pages retired. We never retire pages with
|
|
1262
|
+
importance >= 0.5 even if they look messy — the user might still
|
|
1263
|
+
rely on them.
|
|
1264
|
+
"""
|
|
1265
|
+
try:
|
|
1266
|
+
pages = self.store.list_wiki_pages(limit=200)
|
|
1267
|
+
except Exception:
|
|
1268
|
+
return 0
|
|
1269
|
+
retired = 0
|
|
1270
|
+
for p in pages:
|
|
1271
|
+
pid = p.get("id")
|
|
1272
|
+
if not pid:
|
|
1273
|
+
continue
|
|
1274
|
+
title = (p.get("title") or "").strip()
|
|
1275
|
+
body = (p.get("body") or "")
|
|
1276
|
+
evidence = p.get("evidence_ids") or []
|
|
1277
|
+
has_bullets = body.count("\n- ") + (1 if body.startswith("- ") else 0)
|
|
1278
|
+
# A title that is literally a session-source prefix followed by a
|
|
1279
|
+
# raw user prompt is ALWAYS a "list of recent user prompts" page
|
|
1280
|
+
# — there is no atomic knowledge here. Bail the early-skip.
|
|
1281
|
+
title_prefix_prompt = title.startswith((
|
|
1282
|
+
"[codex]", "[claude]", "[hermes]", "[openclaw]",
|
|
1283
|
+
"User intent:", "Outcome:", "You are ", "Review ",
|
|
1284
|
+
"帮我", "分析", "请立即", "如何", "How to", "Please ",
|
|
1285
|
+
"请", "你可以干嘛", "User said:",
|
|
1286
|
+
)) and len(title) > 20
|
|
1287
|
+
if title_prefix_prompt:
|
|
1288
|
+
noisy = True
|
|
1289
|
+
else:
|
|
1290
|
+
# Glue signature: lots of "/ " separators (old fallback glued raw text)
|
|
1291
|
+
if body.count(" / ") >= 2:
|
|
1292
|
+
noisy = True
|
|
1293
|
+
# Title is a truncated user prompt
|
|
1294
|
+
if title.startswith(("[codex]", "[claude]", "[hermes]", "[openclaw]",
|
|
1295
|
+
"User intent:", "Outcome:", "帮我", "分析",
|
|
1296
|
+
"How to", "Please", "请", "你可以干嘛",
|
|
1297
|
+
"User said:")) and len(title) > 20:
|
|
1298
|
+
noisy = True
|
|
1299
|
+
# No bullets and no evidence
|
|
1300
|
+
if has_bullets == 0 and not evidence:
|
|
1301
|
+
noisy = True
|
|
1302
|
+
# Body has 0 newlines AND no bullets -> just a single glued blob
|
|
1303
|
+
if body.count("\n") < 2 and has_bullets == 0:
|
|
1304
|
+
noisy = True
|
|
1305
|
+
# Bail only if the page has real bullets AND evidence AND a clean title
|
|
1306
|
+
if has_bullets >= 2 and len(evidence) >= 2:
|
|
1307
|
+
continue
|
|
1308
|
+
if noisy:
|
|
1309
|
+
try:
|
|
1310
|
+
self.store.delete_wiki_page(pid)
|
|
1311
|
+
retired += 1
|
|
1312
|
+
stats.notes.append(f"wiki retired: {title[:40]!r}")
|
|
1313
|
+
except Exception:
|
|
1314
|
+
pass
|
|
1315
|
+
# Second sweep: REWRITE every existing page's bullets through
|
|
1316
|
+
# the new cleaner + raw-prompt filter. The previous run may have
|
|
1317
|
+
# produced bullets that look like raw user prompts (especially
|
|
1318
|
+
# legacy pages from before _looks_like_raw_prompt existed). We
|
|
1319
|
+
# rebuild the body from the surviving bullets, re-extract the
|
|
1320
|
+
# title, and re-summarize. This is the cheapest way to bring
|
|
1321
|
+
# legacy pages up to the new quality bar.
|
|
1322
|
+
for p2 in self.store.list_wiki_pages(limit=200):
|
|
1323
|
+
slug = (p2.get("slug") or "")
|
|
1324
|
+
if not slug:
|
|
1325
|
+
continue
|
|
1326
|
+
body = p2.get("body") or ""
|
|
1327
|
+
# Skip the auto-aggregator pages — handled below.
|
|
1328
|
+
if slug in ("auto-episode", "auto-fact"):
|
|
1329
|
+
continue
|
|
1330
|
+
# Collect surviving bullets after raw-prompt / low-signal filter
|
|
1331
|
+
bullet_lines = [b for b in body.split("\n") if b.startswith("- ")]
|
|
1332
|
+
surviving: list[str] = []
|
|
1333
|
+
seen: set[str] = set()
|
|
1334
|
+
for b in bullet_lines:
|
|
1335
|
+
txt = b[2:].strip()
|
|
1336
|
+
if _looks_like_raw_prompt(txt) or _is_low_signal(txt):
|
|
1337
|
+
continue
|
|
1338
|
+
# Re-run the cleaner for the new max_len (slightly longer)
|
|
1339
|
+
clean = _clean_noise(txt, max_len=200)
|
|
1340
|
+
if not clean or len(clean) < 6:
|
|
1341
|
+
continue
|
|
1342
|
+
key = clean[:60].lower()
|
|
1343
|
+
if key in seen:
|
|
1344
|
+
continue
|
|
1345
|
+
seen.add(key)
|
|
1346
|
+
surviving.append(f"- {clean}")
|
|
1347
|
+
if len(surviving) >= 8:
|
|
1348
|
+
break
|
|
1349
|
+
if not surviving:
|
|
1350
|
+
# All bullets filtered out -> retire the page.
|
|
1351
|
+
try:
|
|
1352
|
+
self.store.delete_wiki_page(p2["id"])
|
|
1353
|
+
retired += 1
|
|
1354
|
+
stats.notes.append(f"wiki retired (empty after re-clean): {slug}")
|
|
1355
|
+
except Exception:
|
|
1356
|
+
pass
|
|
1357
|
+
continue
|
|
1358
|
+
# If we have new surviving bullets, rewrite the body.
|
|
1359
|
+
if len(surviving) != len(bullet_lines):
|
|
1360
|
+
new_body = "\n".join(surviving)
|
|
1361
|
+
evidence = p2.get("evidence_ids") or []
|
|
1362
|
+
if evidence:
|
|
1363
|
+
new_body += f"\n\n_Source: {len(evidence)} memories (rewritten by Stage 4.6)_"
|
|
1364
|
+
# Re-extract title + summary from the new top bullet
|
|
1365
|
+
top = surviving[0].lstrip("- ")
|
|
1366
|
+
new_title = _title_from(top, fallback_kind="", max_len=58)
|
|
1367
|
+
new_summary = top[:200].rstrip(" .,;:")
|
|
1368
|
+
try:
|
|
1369
|
+
scope, auto_classification = self._classify_wiki_scope(
|
|
1370
|
+
title=new_title,
|
|
1371
|
+
body=new_body,
|
|
1372
|
+
summary=new_summary,
|
|
1373
|
+
tags=p2.get("tags", []),
|
|
1374
|
+
evidence_ids=evidence,
|
|
1375
|
+
existing=p2,
|
|
1376
|
+
)
|
|
1377
|
+
self.store.upsert_wiki_page(
|
|
1378
|
+
slug=slug,
|
|
1379
|
+
title=new_title,
|
|
1380
|
+
body=new_body,
|
|
1381
|
+
summary=new_summary,
|
|
1382
|
+
tags=p2.get("tags", []),
|
|
1383
|
+
importance=p2.get("importance", 0.5),
|
|
1384
|
+
evidence_ids=evidence,
|
|
1385
|
+
run_id=self._run_id,
|
|
1386
|
+
scope=scope,
|
|
1387
|
+
auto_classification=auto_classification,
|
|
1388
|
+
)
|
|
1389
|
+
stats.notes.append(f"wiki re-cleaned: {slug} ({len(bullet_lines)}→{len(surviving)} bullets)")
|
|
1390
|
+
except Exception:
|
|
1391
|
+
pass
|
|
1392
|
+
# Third sweep: rewrite auto-aggregator pages (auto-episode /
|
|
1393
|
+
# auto-fact) using fresh bullet bodies if their current body still
|
|
1394
|
+
# contains glued fragments. We never delete these — they back many
|
|
1395
|
+
# cross-session facts — but we DO clean their body.
|
|
1396
|
+
for p2 in self.store.list_wiki_pages(limit=20):
|
|
1397
|
+
slug = (p2.get("slug") or "")
|
|
1398
|
+
if slug not in ("auto-episode", "auto-fact"):
|
|
1399
|
+
continue
|
|
1400
|
+
body = p2.get("body") or ""
|
|
1401
|
+
# If the body has many "/ " separators or many duplicate
|
|
1402
|
+
# bullets, it's a candidate for a re-synthesis pass.
|
|
1403
|
+
glue = body.count(" / ")
|
|
1404
|
+
# Cheap dedupe: count repeated leading tokens.
|
|
1405
|
+
bullets = [b for b in body.split("\n") if b.startswith("- ")]
|
|
1406
|
+
seen = set()
|
|
1407
|
+
unique = []
|
|
1408
|
+
for b in bullets:
|
|
1409
|
+
key = b[:80].lower()
|
|
1410
|
+
if key in seen:
|
|
1411
|
+
continue
|
|
1412
|
+
seen.add(key)
|
|
1413
|
+
unique.append(b)
|
|
1414
|
+
if glue >= 3 or len(unique) < len(bullets) * 0.7:
|
|
1415
|
+
# Replace body with the deduped + cleaned bullets.
|
|
1416
|
+
if unique:
|
|
1417
|
+
new_body = "\n".join(unique[:25])
|
|
1418
|
+
else:
|
|
1419
|
+
continue
|
|
1420
|
+
try:
|
|
1421
|
+
scope, auto_classification = self._classify_wiki_scope(
|
|
1422
|
+
title=p2.get("title", ""),
|
|
1423
|
+
body=new_body,
|
|
1424
|
+
summary=p2.get("summary", ""),
|
|
1425
|
+
tags=p2.get("tags", []),
|
|
1426
|
+
evidence_ids=p2.get("evidence_ids", []),
|
|
1427
|
+
existing=p2,
|
|
1428
|
+
)
|
|
1429
|
+
self.store.upsert_wiki_page(
|
|
1430
|
+
slug=slug,
|
|
1431
|
+
title=p2.get("title", ""),
|
|
1432
|
+
body=new_body,
|
|
1433
|
+
summary=p2.get("summary", ""),
|
|
1434
|
+
tags=p2.get("tags", []),
|
|
1435
|
+
importance=p2.get("importance", 0.5),
|
|
1436
|
+
evidence_ids=p2.get("evidence_ids", []),
|
|
1437
|
+
run_id=self._run_id,
|
|
1438
|
+
scope=scope,
|
|
1439
|
+
auto_classification=auto_classification,
|
|
1440
|
+
)
|
|
1441
|
+
stats.notes.append(f"wiki re-synthesized: {slug}")
|
|
1442
|
+
except Exception:
|
|
1443
|
+
pass
|
|
1444
|
+
return retired
|
|
1445
|
+
|
|
1446
|
+
# --- Stage 4: Wiki synthesis ----------------------------------------
|
|
1447
|
+
|
|
1448
|
+
def _stage4_wiki_synthesis(
|
|
1449
|
+
self,
|
|
1450
|
+
cluster_summaries: list[dict[str, Any]],
|
|
1451
|
+
cfg: dict[str, Any],
|
|
1452
|
+
stats: EvolutionStats,
|
|
1453
|
+
) -> dict[str, int]:
|
|
1454
|
+
if not cluster_summaries:
|
|
1455
|
+
return {"created": 0, "updated": 0, "calls": 0}
|
|
1456
|
+
|
|
1457
|
+
# Cap input
|
|
1458
|
+
clusters = cluster_summaries[:WIKI_INPUT_CLUSTERS]
|
|
1459
|
+
|
|
1460
|
+
# Rule-based fast path: skip the wiki LLM call (it would just echo
|
|
1461
|
+
# back non-JSON) and synthesize wiki pages deterministically by
|
|
1462
|
+
# clustering by kind. Real wiki text comes from cluster summaries.
|
|
1463
|
+
if self._echo_provider():
|
|
1464
|
+
return self._stage4_rules(clusters, stats)
|
|
1465
|
+
|
|
1466
|
+
existing = self.store.list_wiki_pages(limit=50)
|
|
1467
|
+
existing_payload = [
|
|
1468
|
+
{"slug": p.get("slug", ""), "title": p.get("title", ""),
|
|
1469
|
+
"summary": (p.get("summary") or "")[:200],
|
|
1470
|
+
"tags": list(p.get("tags") or []),
|
|
1471
|
+
"importance": round(p.get("importance") or 0, 2)}
|
|
1472
|
+
for p in existing
|
|
1473
|
+
]
|
|
1474
|
+
# Evolution memo: last 3 runs
|
|
1475
|
+
memo = self.store.get_setting("evolution_memo", "") or ""
|
|
1476
|
+
|
|
1477
|
+
user_payload = {
|
|
1478
|
+
"profile_dimensions": list(PROFILE_DIMS),
|
|
1479
|
+
"evolution_memo": memo[:1500] if isinstance(memo, str) else "",
|
|
1480
|
+
"existing_wiki": existing_payload,
|
|
1481
|
+
"cluster_summaries": [
|
|
1482
|
+
{
|
|
1483
|
+
"text": cs["text"],
|
|
1484
|
+
"size": cs["size"],
|
|
1485
|
+
"evidence_ids": list(cs.get("evidence_ids") or [])[:50],
|
|
1486
|
+
}
|
|
1487
|
+
for cs in clusters
|
|
1488
|
+
],
|
|
1489
|
+
}
|
|
1490
|
+
user_prompt = json.dumps(user_payload, ensure_ascii=False)
|
|
1491
|
+
cache_key = hashlib.sha1(
|
|
1492
|
+
(user_prompt + "||" + (getattr(self.provider, "model", "?") or "?")
|
|
1493
|
+
+ "||wiki-evo||" + str(float(cfg.get("temperature") or 0.3))).encode()
|
|
1494
|
+
, usedforsecurity=False).hexdigest()
|
|
1495
|
+
wiki_system = wiki_system_prompt(self._lang)
|
|
1496
|
+
reply = self._cached_call(cache_key, wiki_system, user_prompt, cfg, stats, kind="wiki")
|
|
1497
|
+
|
|
1498
|
+
parsed = _extract_json(reply or "")
|
|
1499
|
+
if not isinstance(parsed, dict):
|
|
1500
|
+
# LLM unreachable or returned junk — fall back to rule-based
|
|
1501
|
+
# synthesis so the wiki still grows even without an LLM.
|
|
1502
|
+
stats.notes.append("wiki LLM reply was not valid JSON — falling back to rule-based synthesis")
|
|
1503
|
+
return self._stage4_rules(clusters, stats)
|
|
1504
|
+
pages = parsed.get("pages") or []
|
|
1505
|
+
if not isinstance(pages, list) or len(pages) == 0:
|
|
1506
|
+
# LLM produced no pages — fall back too so the user sees
|
|
1507
|
+
# real wiki content immediately.
|
|
1508
|
+
stats.notes.append("wiki LLM returned 0 pages — falling back to rule-based synthesis")
|
|
1509
|
+
return self._stage4_rules(clusters, stats)
|
|
1510
|
+
|
|
1511
|
+
# -----------------------------------------------------------------
|
|
1512
|
+
# Length-floor recovery (v3). The wiki prompts promise at least
|
|
1513
|
+
# ``MIN_BULLETS_PER_PAGE`` bullets per page and at least
|
|
1514
|
+
# ``MIN_BODY_CHARS`` characters of body. If the LLM shipped a
|
|
1515
|
+
# thinner page anyway (some open-source models follow lengths
|
|
1516
|
+
# inconsistently), we send a locale-aware expansion request and
|
|
1517
|
+
# patch the returned page's body in place. We retry at most
|
|
1518
|
+
# ``MAX_WIKI_PROMPTS_PER_PAGE - 1`` extra times so the LLM
|
|
1519
|
+
# budget for this Stage-4 call stays bounded.
|
|
1520
|
+
# -----------------------------------------------------------------
|
|
1521
|
+
pages = self._expand_under_floor_pages(
|
|
1522
|
+
pages, clusters, user_prompt, wiki_system, cfg, stats
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
created = 0
|
|
1526
|
+
updated = 0
|
|
1527
|
+
recovered_evidence_pages = 0
|
|
1528
|
+
under_floor_slugs: list[str] = []
|
|
1529
|
+
for p in pages:
|
|
1530
|
+
if not isinstance(p, dict):
|
|
1531
|
+
continue
|
|
1532
|
+
slug = (p.get("slug") or "").strip().lower().replace(" ", "-")[:80]
|
|
1533
|
+
title = (p.get("title") or "").strip()
|
|
1534
|
+
body = (p.get("body") or "").strip()
|
|
1535
|
+
if not slug or not title or not body:
|
|
1536
|
+
continue
|
|
1537
|
+
if not meets_body_floor(body):
|
|
1538
|
+
under_floor_slugs.append(slug)
|
|
1539
|
+
tags = p.get("tags") or []
|
|
1540
|
+
if not isinstance(tags, list):
|
|
1541
|
+
tags = []
|
|
1542
|
+
tags = [str(t).strip().lower() for t in tags if t and str(t).strip()][:8]
|
|
1543
|
+
try:
|
|
1544
|
+
importance = max(0.0, min(1.0, float(p.get("importance") or 0.5)))
|
|
1545
|
+
except Exception:
|
|
1546
|
+
importance = 0.5
|
|
1547
|
+
supplied_evidence = p.get("evidence_ids") or []
|
|
1548
|
+
evidence = _recover_page_evidence(p, clusters)
|
|
1549
|
+
if evidence and not supplied_evidence:
|
|
1550
|
+
recovered_evidence_pages += 1
|
|
1551
|
+
summary = (p.get("summary") or "").strip()[:400]
|
|
1552
|
+
existing_p = self.store.get_wiki_page_by_slug(slug)
|
|
1553
|
+
try:
|
|
1554
|
+
scope, auto_classification = self._classify_wiki_scope(
|
|
1555
|
+
title=title,
|
|
1556
|
+
body=body,
|
|
1557
|
+
summary=summary,
|
|
1558
|
+
tags=tags,
|
|
1559
|
+
evidence_ids=evidence,
|
|
1560
|
+
existing=existing_p,
|
|
1561
|
+
)
|
|
1562
|
+
upserted = self.store.upsert_wiki_page(
|
|
1563
|
+
slug=slug, title=title, body=body, summary=summary,
|
|
1564
|
+
tags=tags, importance=importance, evidence_ids=evidence,
|
|
1565
|
+
key_facts=p.get("key_facts") or [],
|
|
1566
|
+
run_id=self._run_id,
|
|
1567
|
+
scope=scope,
|
|
1568
|
+
auto_classification=auto_classification,
|
|
1569
|
+
)
|
|
1570
|
+
except Exception as e:
|
|
1571
|
+
stats.notes.append(f"wiki upsert err: {e}")
|
|
1572
|
+
continue
|
|
1573
|
+
# Post-write hook: scan this page for contradictions
|
|
1574
|
+
# against the rest of the wiki. Cheap (one Jaccard per
|
|
1575
|
+
# candidate) and keeps the "needs review" UI list fresh
|
|
1576
|
+
# without requiring a manual re-scan.
|
|
1577
|
+
try:
|
|
1578
|
+
from .contradiction import detect_for_page, write_contradicting_ids
|
|
1579
|
+
matches = detect_for_page(self.store, upserted["id"], threshold=0.45)
|
|
1580
|
+
if matches:
|
|
1581
|
+
n = write_contradicting_ids(self.store, matches)
|
|
1582
|
+
if n:
|
|
1583
|
+
stats.notes.append(f"contradictions: {n} pages flagged")
|
|
1584
|
+
except Exception as e:
|
|
1585
|
+
stats.notes.append(f"contradiction scan skipped: {e}")
|
|
1586
|
+
if existing_p is None:
|
|
1587
|
+
created += 1
|
|
1588
|
+
else:
|
|
1589
|
+
updated += 1
|
|
1590
|
+
if under_floor_slugs:
|
|
1591
|
+
stats.notes.append(
|
|
1592
|
+
"wiki under floor (" + str(len(under_floor_slugs)) + "): "
|
|
1593
|
+
+ ", ".join(under_floor_slugs[:5])
|
|
1594
|
+
+ (" ..." if len(under_floor_slugs) > 5 else "")
|
|
1595
|
+
)
|
|
1596
|
+
if recovered_evidence_pages:
|
|
1597
|
+
stats.notes.append(
|
|
1598
|
+
f"wiki evidence recovered for {recovered_evidence_pages} page(s)"
|
|
1599
|
+
)
|
|
1600
|
+
return {"created": created, "updated": updated, "calls": 1}
|
|
1601
|
+
|
|
1602
|
+
# -----------------------------------------------------------------
|
|
1603
|
+
# Length-floor recovery
|
|
1604
|
+
# -----------------------------------------------------------------
|
|
1605
|
+
def _expand_under_floor_pages(
|
|
1606
|
+
self,
|
|
1607
|
+
pages: list[dict[str, Any]],
|
|
1608
|
+
clusters: list[dict[str, Any]],
|
|
1609
|
+
user_prompt: str,
|
|
1610
|
+
wiki_system: str,
|
|
1611
|
+
cfg: dict[str, Any],
|
|
1612
|
+
stats: EvolutionStats,
|
|
1613
|
+
) -> list[dict[str, Any]]:
|
|
1614
|
+
"""Re-prompt the LLM for any page that came back under the body
|
|
1615
|
+
floor. Mutates-and-returns ``pages``.
|
|
1616
|
+
|
|
1617
|
+
The first LLM call is the regular Stage-4 prompt. When this
|
|
1618
|
+
helper sees a page whose body clears neither the bullet count
|
|
1619
|
+
nor the character count, it sends a short locale-aware
|
|
1620
|
+
"please append more bullets, do not rewrite" follow-up and
|
|
1621
|
+
tries to merge the answer into the same page dict. The merge
|
|
1622
|
+
is bullet-only and idempotent: we keep the original bullets
|
|
1623
|
+
in order, then append only NEW bullets whose leading 60 chars
|
|
1624
|
+
are not already present.
|
|
1625
|
+
|
|
1626
|
+
We retry at most ``MAX_WIKI_PROMPTS_PER_PAGE - 1`` extra
|
|
1627
|
+
times so a chatty Stage-4 can't blow the LLM budget for the
|
|
1628
|
+
whole run.
|
|
1629
|
+
"""
|
|
1630
|
+
if not pages:
|
|
1631
|
+
return pages
|
|
1632
|
+
try:
|
|
1633
|
+
from ._extract_json import _extract_json as _ej # type: ignore
|
|
1634
|
+
except Exception:
|
|
1635
|
+
_ej = None
|
|
1636
|
+
for attempt in range(MAX_WIKI_PROMPTS_PER_PAGE - 1):
|
|
1637
|
+
weak = []
|
|
1638
|
+
for p in pages:
|
|
1639
|
+
if not isinstance(p, dict):
|
|
1640
|
+
continue
|
|
1641
|
+
body = (p.get("body") or "").strip()
|
|
1642
|
+
if not meets_body_floor(body):
|
|
1643
|
+
weak.append(p)
|
|
1644
|
+
if not weak:
|
|
1645
|
+
return pages
|
|
1646
|
+
try:
|
|
1647
|
+
follow_up = expansion_prompt(self._lang)
|
|
1648
|
+
history_payload = (
|
|
1649
|
+
user_prompt
|
|
1650
|
+
+ "\n\n# Pages already returned:\n"
|
|
1651
|
+
+ json.dumps(weak, ensure_ascii=False)
|
|
1652
|
+
+ "\n\n# Instruction:\n"
|
|
1653
|
+
+ follow_up
|
|
1654
|
+
)
|
|
1655
|
+
cache_blob = (
|
|
1656
|
+
history_payload
|
|
1657
|
+
+ "||"
|
|
1658
|
+
+ (getattr(self.provider, "model", "?") or "?")
|
|
1659
|
+
+ "||wiki-evo-expand||"
|
|
1660
|
+
+ str(float(cfg.get("temperature") or 0.3))
|
|
1661
|
+
+ "||" + self._lang
|
|
1662
|
+
)
|
|
1663
|
+
cache_key = hashlib.sha1(
|
|
1664
|
+
cache_blob.encode(), usedforsecurity=False
|
|
1665
|
+
).hexdigest()
|
|
1666
|
+
reply = self._cached_call(
|
|
1667
|
+
cache_key, wiki_system, history_payload,
|
|
1668
|
+
cfg, stats, kind="wiki",
|
|
1669
|
+
)
|
|
1670
|
+
except Exception as e:
|
|
1671
|
+
stats.notes.append(f"wiki expansion call error: {e}")
|
|
1672
|
+
return pages
|
|
1673
|
+
extract = _ej or _extract_json
|
|
1674
|
+
parsed = extract(reply or "")
|
|
1675
|
+
if not isinstance(parsed, dict):
|
|
1676
|
+
return pages
|
|
1677
|
+
new_pages = parsed.get("pages") or []
|
|
1678
|
+
if not isinstance(new_pages, list) or not new_pages:
|
|
1679
|
+
return pages
|
|
1680
|
+
by_slug = {str(p.get("slug") or "").strip().lower(): p for p in pages if isinstance(p, dict)}
|
|
1681
|
+
for np in new_pages:
|
|
1682
|
+
if not isinstance(np, dict):
|
|
1683
|
+
continue
|
|
1684
|
+
slug = str(np.get("slug") or "").strip().lower()
|
|
1685
|
+
target = by_slug.get(slug)
|
|
1686
|
+
if target is None:
|
|
1687
|
+
continue
|
|
1688
|
+
target_body = (target.get("body") or "").strip()
|
|
1689
|
+
np_body = (np.get("body") or "").strip()
|
|
1690
|
+
if not np_body:
|
|
1691
|
+
continue
|
|
1692
|
+
merged = _merge_bullet_lists(target_body, np_body)
|
|
1693
|
+
target["body"] = merged
|
|
1694
|
+
# Refresh fields the LLM may have re-written
|
|
1695
|
+
for fld in ("title", "summary", "tags", "key_facts", "importance", "evidence_ids"):
|
|
1696
|
+
v = np.get(fld)
|
|
1697
|
+
if v:
|
|
1698
|
+
target[fld] = v
|
|
1699
|
+
stats.notes.append(
|
|
1700
|
+
f"wiki expansion pass {attempt + 1}/{MAX_WIKI_PROMPTS_PER_PAGE - 1}"
|
|
1701
|
+
)
|
|
1702
|
+
return pages
|
|
1703
|
+
|
|
1704
|
+
def _echo_provider(self) -> bool:
|
|
1705
|
+
return type(self.provider).__name__ == "RuleBasedProvider"
|
|
1706
|
+
|
|
1707
|
+
def _stage4_rules(
|
|
1708
|
+
self,
|
|
1709
|
+
clusters: list[dict[str, Any]],
|
|
1710
|
+
stats: EvolutionStats,
|
|
1711
|
+
) -> dict[str, int]:
|
|
1712
|
+
"""Rule-based wiki synthesis: one page per cluster.
|
|
1713
|
+
|
|
1714
|
+
Produces real, browsable wiki pages even when no LLM is
|
|
1715
|
+
configured (or when the configured LLM is failing). Each page
|
|
1716
|
+
has a real noun-phrase title, a 1-line summary, and a body of
|
|
1717
|
+
dynamic-length atomic bullets — never a glued-up concatenation of
|
|
1718
|
+
raw memory text. This is the fallback that drives the entire wiki
|
|
1719
|
+
when the user has not yet configured an API key.
|
|
1720
|
+
|
|
1721
|
+
Bullet count is **dynamic** (2..8) based on cluster size and
|
|
1722
|
+
quality. We aggressively filter out raw user prompts and pure
|
|
1723
|
+
status pings so the wiki stays dense and useful.
|
|
1724
|
+
"""
|
|
1725
|
+
import hashlib as _h
|
|
1726
|
+
created = 0
|
|
1727
|
+
updated = 0
|
|
1728
|
+
for i, cs in enumerate(clusters):
|
|
1729
|
+
memories: list = cs.get("items") or cs.get("memories") or []
|
|
1730
|
+
if not memories:
|
|
1731
|
+
continue
|
|
1732
|
+
ranked = sorted(
|
|
1733
|
+
memories,
|
|
1734
|
+
key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
|
|
1735
|
+
)
|
|
1736
|
+
# Dynamic max bullets: small cluster -> few bullets, big -> more.
|
|
1737
|
+
n = len(ranked)
|
|
1738
|
+
if n <= 2:
|
|
1739
|
+
max_bullets = 2
|
|
1740
|
+
elif n <= 5:
|
|
1741
|
+
max_bullets = 3
|
|
1742
|
+
elif n <= 10:
|
|
1743
|
+
max_bullets = 5
|
|
1744
|
+
else:
|
|
1745
|
+
max_bullets = 7
|
|
1746
|
+
# Hard ceiling so a noisy session never produces a wall of bullets.
|
|
1747
|
+
max_bullets = min(max_bullets, 8)
|
|
1748
|
+
bullets: list[str] = []
|
|
1749
|
+
seen: set[str] = set()
|
|
1750
|
+
skipped_prompts = 0
|
|
1751
|
+
for m in ranked:
|
|
1752
|
+
txt = getattr(m, "text", "") or ""
|
|
1753
|
+
# Drop raw user prompts outright: they add zero atomic value.
|
|
1754
|
+
if _looks_like_raw_prompt(txt):
|
|
1755
|
+
skipped_prompts += 1
|
|
1756
|
+
continue
|
|
1757
|
+
bullet = _clean_noise(txt, max_len=160)
|
|
1758
|
+
if not bullet:
|
|
1759
|
+
continue
|
|
1760
|
+
# Even after cleaning, a memory can still be pure status —
|
|
1761
|
+
# be conservative and skip if it's just a low-signal ping.
|
|
1762
|
+
if _is_low_signal(bullet):
|
|
1763
|
+
continue
|
|
1764
|
+
# Bullet-length sanity: drop bullets that are still
|
|
1765
|
+
# "raw prompt in disguise" (long, imperative, no period).
|
|
1766
|
+
if len(bullet) > 140 and "." not in bullet[:80]:
|
|
1767
|
+
continue
|
|
1768
|
+
key = bullet[:60].lower()
|
|
1769
|
+
if key in seen:
|
|
1770
|
+
continue
|
|
1771
|
+
seen.add(key)
|
|
1772
|
+
if len(bullet) < 6:
|
|
1773
|
+
continue
|
|
1774
|
+
bullets.append(f"- {bullet}")
|
|
1775
|
+
if len(bullets) >= max_bullets:
|
|
1776
|
+
break
|
|
1777
|
+
if not bullets:
|
|
1778
|
+
continue
|
|
1779
|
+
kind_hint = (cs.get("kind") or cs.get("dominating_tag") or "").lower().strip()
|
|
1780
|
+
# Use the FIRST surviving bullet for the topic + title — never
|
|
1781
|
+
# the cluster summary text, which can be a raw user prompt.
|
|
1782
|
+
topic_src = bullets[0].lstrip("- ")
|
|
1783
|
+
# Strip any lingering prompt words from the title.
|
|
1784
|
+
topic_src = re.sub(
|
|
1785
|
+
r"^(帮我|请立即|请|如何|怎么|thoroughly explore\s+the\s+|please\s+)\S*",
|
|
1786
|
+
"",
|
|
1787
|
+
topic_src,
|
|
1788
|
+
flags=re.I,
|
|
1789
|
+
).strip(" ,;:|/。")
|
|
1790
|
+
if not topic_src:
|
|
1791
|
+
topic_src = bullets[0].lstrip("- ")
|
|
1792
|
+
words = re.findall(r"[a-z0-9一-鿿]+", topic_src.lower())
|
|
1793
|
+
topic = "-".join([w for w in words if len(w) > 1][:4]) or f"cluster-{i+1}"
|
|
1794
|
+
slug_src = f"{kind_hint}-{topic}" if kind_hint else topic
|
|
1795
|
+
slug = re.sub(r"[^a-z0-9一-鿿-]+", "-", slug_src).strip("-").lower()[:60]
|
|
1796
|
+
if not slug:
|
|
1797
|
+
slug = "auto-cluster-" + _h.md5(slug_src.encode("utf-8"), usedforsecurity=False).hexdigest()[:10]
|
|
1798
|
+
title = _title_from(topic_src, fallback_kind=kind_hint, max_len=58)
|
|
1799
|
+
summary = bullets[0].lstrip("- ")[:200].rstrip(" .,;:")
|
|
1800
|
+
if not summary:
|
|
1801
|
+
continue
|
|
1802
|
+
evidence_ids: list[str] = []
|
|
1803
|
+
for m in ranked:
|
|
1804
|
+
mid = getattr(m, "id", None)
|
|
1805
|
+
if mid and str(mid) not in evidence_ids:
|
|
1806
|
+
evidence_ids.append(str(mid))
|
|
1807
|
+
if len(evidence_ids) >= 12:
|
|
1808
|
+
break
|
|
1809
|
+
body_lines = list(bullets)
|
|
1810
|
+
if evidence_ids:
|
|
1811
|
+
body_lines.append(
|
|
1812
|
+
f"\n_Source: {len(evidence_ids)} memories · importance-weighted top-{len(ranked)}_"
|
|
1813
|
+
)
|
|
1814
|
+
body = "\n".join(body_lines)
|
|
1815
|
+
tags = ["auto", "rule-based"]
|
|
1816
|
+
if kind_hint:
|
|
1817
|
+
tags.append(kind_hint)
|
|
1818
|
+
top_imp = [float(getattr(m, "importance", 0.0) or 0.0) for m in ranked[:5]]
|
|
1819
|
+
importance = max(0.35, sum(top_imp) / max(1, len(top_imp)))
|
|
1820
|
+
importance = round(min(1.0, importance), 2)
|
|
1821
|
+
existing_p = self.store.get_wiki_page_by_slug(slug)
|
|
1822
|
+
try:
|
|
1823
|
+
scope, auto_classification = self._classify_wiki_scope(
|
|
1824
|
+
title=title,
|
|
1825
|
+
body=body,
|
|
1826
|
+
summary=summary,
|
|
1827
|
+
tags=tags[:6],
|
|
1828
|
+
evidence_ids=evidence_ids,
|
|
1829
|
+
existing=existing_p,
|
|
1830
|
+
)
|
|
1831
|
+
self.store.upsert_wiki_page(
|
|
1832
|
+
slug=slug, title=title, body=body, summary=summary,
|
|
1833
|
+
tags=tags[:6], importance=importance,
|
|
1834
|
+
evidence_ids=evidence_ids, run_id=self._run_id,
|
|
1835
|
+
scope=scope,
|
|
1836
|
+
auto_classification=auto_classification,
|
|
1837
|
+
)
|
|
1838
|
+
except Exception:
|
|
1839
|
+
continue
|
|
1840
|
+
if existing_p is None:
|
|
1841
|
+
created += 1
|
|
1842
|
+
else:
|
|
1843
|
+
updated += 1
|
|
1844
|
+
if skipped_prompts:
|
|
1845
|
+
stats.notes.append(
|
|
1846
|
+
f"rule-wiki {slug[:30]}: skipped {skipped_prompts} raw prompt(s)"
|
|
1847
|
+
)
|
|
1848
|
+
stats.wiki_calls += 1
|
|
1849
|
+
return {"created": created, "updated": updated, "calls": 1}
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
# --- Stage 0: Pre-cluster cleanup -----------------------------------
|
|
1853
|
+
|
|
1854
|
+
def _stage0_filter_noise(
|
|
1855
|
+
self, memories: list[StoredMemory]
|
|
1856
|
+
) -> list[StoredMemory]:
|
|
1857
|
+
"""Drop memories that carry no actionable information: short
|
|
1858
|
+
status pings, single-line acknowledgements, raw user prompts, etc.
|
|
1859
|
+
These dilute the cluster LLM and inflate the wiki page count
|
|
1860
|
+
without value. We keep raw user prompts in the *store* (for
|
|
1861
|
+
session replay and contradiction detection) but filter them from
|
|
1862
|
+
the distillation path."""
|
|
1863
|
+
kept: list[StoredMemory] = []
|
|
1864
|
+
for m in memories:
|
|
1865
|
+
text = (m.text or "").strip()
|
|
1866
|
+
if _is_low_signal(text):
|
|
1867
|
+
continue
|
|
1868
|
+
if len(text) < 16:
|
|
1869
|
+
continue
|
|
1870
|
+
# Hard filter: raw user prompts should not pollute the wiki.
|
|
1871
|
+
# We classify them as noise for the distillation pipeline.
|
|
1872
|
+
if _looks_like_raw_prompt(text):
|
|
1873
|
+
continue
|
|
1874
|
+
kept.append(m)
|
|
1875
|
+
return kept
|
|
1876
|
+
|
|
1877
|
+
def _stage0_dedup_memories(
|
|
1878
|
+
self,
|
|
1879
|
+
memories: list[StoredMemory],
|
|
1880
|
+
stats: EvolutionStats,
|
|
1881
|
+
) -> list[StoredMemory]:
|
|
1882
|
+
"""Collapse near-duplicate memories into one. We hash-embed each
|
|
1883
|
+
memory (cheap, deterministic), then group by cosine similarity
|
|
1884
|
+
>= 0.85 within the same kind. Comparison is against EVERY member
|
|
1885
|
+
of the existing group (not just the centroid), so partial matches
|
|
1886
|
+
chain into the right cluster. The highest-importance memory
|
|
1887
|
+
survives; the rest are deleted from the store and the survivor's
|
|
1888
|
+
importance is bumped slightly so the merged fact rises above
|
|
1889
|
+
the noise."""
|
|
1890
|
+
if not memories:
|
|
1891
|
+
return memories
|
|
1892
|
+
ranked = sorted(
|
|
1893
|
+
memories,
|
|
1894
|
+
key=lambda m: -(float(getattr(m, "importance", 0.0) or 0.0)),
|
|
1895
|
+
)
|
|
1896
|
+
groups: list[list[StoredMemory]] = []
|
|
1897
|
+
group_embs: list[list[list[float]]] = []
|
|
1898
|
+
for m in ranked:
|
|
1899
|
+
text = (m.text or "").strip()
|
|
1900
|
+
if not text:
|
|
1901
|
+
continue
|
|
1902
|
+
emb = _hash_embed(text)
|
|
1903
|
+
placed = False
|
|
1904
|
+
for gi, grp in enumerate(groups):
|
|
1905
|
+
if grp[0].kind != m.kind:
|
|
1906
|
+
continue
|
|
1907
|
+
# Compare against every existing member's embedding
|
|
1908
|
+
for prev_emb in group_embs[gi]:
|
|
1909
|
+
if _cos(emb, prev_emb) >= 0.85:
|
|
1910
|
+
grp.append(m)
|
|
1911
|
+
group_embs[gi].append(emb)
|
|
1912
|
+
placed = True
|
|
1913
|
+
break
|
|
1914
|
+
if placed:
|
|
1915
|
+
break
|
|
1916
|
+
if not placed:
|
|
1917
|
+
groups.append([m])
|
|
1918
|
+
group_embs.append([emb])
|
|
1919
|
+
merged = 0
|
|
1920
|
+
survivors: set[str] = set()
|
|
1921
|
+
for grp in groups:
|
|
1922
|
+
if not grp:
|
|
1923
|
+
continue
|
|
1924
|
+
head = grp[0]
|
|
1925
|
+
survivors.add(head.id)
|
|
1926
|
+
if len(grp) <= 1:
|
|
1927
|
+
continue
|
|
1928
|
+
head_imp = float(getattr(head, "importance", 0.0) or 0.0)
|
|
1929
|
+
for dup in grp[1:]:
|
|
1930
|
+
head_imp = min(1.0, head_imp + 0.02)
|
|
1931
|
+
try:
|
|
1932
|
+
self.store.delete_memory(dup.id)
|
|
1933
|
+
merged += 1
|
|
1934
|
+
except Exception:
|
|
1935
|
+
pass
|
|
1936
|
+
if head_imp > float(getattr(head, "importance", 0.0) or 0.0):
|
|
1937
|
+
try:
|
|
1938
|
+
self.store.upsert_memory(
|
|
1939
|
+
id=head.id, kind=head.kind,
|
|
1940
|
+
text=head.text, importance=head_imp,
|
|
1941
|
+
source=head.source, session_id=head.session_id,
|
|
1942
|
+
created_at=head.created_at, updated_at=time.time(),
|
|
1943
|
+
ttl=head.ttl, tags=list(head.tags or []),
|
|
1944
|
+
embedding=head.embedding,
|
|
1945
|
+
)
|
|
1946
|
+
except Exception:
|
|
1947
|
+
pass
|
|
1948
|
+
stats.deduped = merged
|
|
1949
|
+
return [m for m in memories if m.id in survivors]
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
# --- Stage 5: Evolution memo ----------------------------------------
|
|
1953
|
+
|
|
1954
|
+
def _stage5_memo(self, stats: EvolutionStats) -> None:
|
|
1955
|
+
"""Persist a short memo describing what this run changed so the next
|
|
1956
|
+
run's wiki prompt can use it as evolution context."""
|
|
1957
|
+
memo = {
|
|
1958
|
+
"ts": time.time(),
|
|
1959
|
+
"rescored": stats.rescored,
|
|
1960
|
+
"dropped": stats.dropped,
|
|
1961
|
+
"resummarized": stats.resummarized,
|
|
1962
|
+
"clusters": stats.clusters,
|
|
1963
|
+
"wiki_created": stats.wiki_created,
|
|
1964
|
+
"wiki_updated": stats.wiki_updated,
|
|
1965
|
+
"notes": stats.notes[:3],
|
|
1966
|
+
}
|
|
1967
|
+
text = json.dumps(memo, ensure_ascii=False)
|
|
1968
|
+
try:
|
|
1969
|
+
self.store.set_setting("evolution_memo", text)
|
|
1970
|
+
except Exception:
|
|
1971
|
+
pass
|
|
1972
|
+
|
|
1973
|
+
# --- utilities -------------------------------------------------------
|
|
1974
|
+
|
|
1975
|
+
def _cached_call(
|
|
1976
|
+
self,
|
|
1977
|
+
cache_key: str,
|
|
1978
|
+
system: str,
|
|
1979
|
+
user_prompt: str,
|
|
1980
|
+
cfg: dict[str, Any],
|
|
1981
|
+
stats: EvolutionStats,
|
|
1982
|
+
*,
|
|
1983
|
+
kind: str = "",
|
|
1984
|
+
) -> str:
|
|
1985
|
+
now = time.time()
|
|
1986
|
+
cached = self._cache.get(cache_key)
|
|
1987
|
+
if cached is not None and (now - self._cache_ts.get(cache_key, 0)) < self._cache_ttl:
|
|
1988
|
+
return cached
|
|
1989
|
+
history = ChatHistory(system=system, messages=[Message(role="user", content=user_prompt)])
|
|
1990
|
+
try:
|
|
1991
|
+
reply = self.provider.complete(
|
|
1992
|
+
history,
|
|
1993
|
+
temperature=float(cfg.get("temperature") or 0.3),
|
|
1994
|
+
max_tokens=int(cfg.get("max_output_tokens") or 4096),
|
|
1995
|
+
) or ""
|
|
1996
|
+
except Exception as e:
|
|
1997
|
+
stats.notes.append(f"{kind} llm error: {type(e).__name__}: {e}")
|
|
1998
|
+
return ""
|
|
1999
|
+
self._cache[cache_key] = reply
|
|
2000
|
+
self._cache_ts[cache_key] = now
|
|
2001
|
+
if kind == "cluster":
|
|
2002
|
+
stats.cluster_calls += 1
|
|
2003
|
+
elif kind == "wiki":
|
|
2004
|
+
stats.wiki_calls += 1
|
|
2005
|
+
return reply
|
|
2006
|
+
|
|
2007
|
+
def _record_stage(
|
|
2008
|
+
self,
|
|
2009
|
+
stage: str,
|
|
2010
|
+
in_count: int,
|
|
2011
|
+
out_count: int,
|
|
2012
|
+
note: str,
|
|
2013
|
+
stats: dict[str, Any],
|
|
2014
|
+
) -> None:
|
|
2015
|
+
try:
|
|
2016
|
+
rid = self.store.start_pipeline_run(stage)
|
|
2017
|
+
self.store.finish_pipeline_run(
|
|
2018
|
+
rid, in_count=in_count, out_count=out_count, note=note, stats=stats,
|
|
2019
|
+
)
|
|
2020
|
+
except Exception:
|
|
2021
|
+
pass
|