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,281 @@
|
|
|
1
|
+
"""Wiki-page contradiction detection.
|
|
2
|
+
|
|
3
|
+
When the consolidator writes a new or updated wiki page, we don't just
|
|
4
|
+
stop at "it's a paragraph about X". Two pages about the *same* topic
|
|
5
|
+
might disagree — "user prefers tabs" vs. "user prefers spaces" — and
|
|
6
|
+
the UI should surface that for the user to merge. We do this cheaply
|
|
7
|
+
by comparing ``key_facts`` rather than full bodies: a page's key
|
|
8
|
+
facts are short, single-sentence bullets produced by the LLM, so
|
|
9
|
+
high Jaccard similarity over them strongly suggests "same topic".
|
|
10
|
+
|
|
11
|
+
Why not whole-body similarity? Bodies are long and chatty; LLM
|
|
12
|
+
paraphrasing inflates their divergence even when the underlying
|
|
13
|
+
meaning matches. ``key_facts`` are constrained to single-sentence
|
|
14
|
+
statements which makes them a much sharper comparable unit.
|
|
15
|
+
|
|
16
|
+
Detection runs as a post-write hook in the consolidator pipeline,
|
|
17
|
+
*and* on demand via the API so the UI can re-scan after the user
|
|
18
|
+
adds new wiki pages by hand.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Iterable
|
|
28
|
+
|
|
29
|
+
from ..storage.sqlite_store import MemoryStore
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- tokenization ------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# We split on whitespace + ASCII punctuation, but keep CJK unigrams
|
|
37
|
+
# + 2-grams so "偏好 Tab 缩进" and "使用 Tab 缩进" still produce
|
|
38
|
+
# overlapping tokens. We lowercase ASCII; CJK is left as-is to keep
|
|
39
|
+
# 2-grams intact.
|
|
40
|
+
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+|[\u4e00-\u9fff]", re.UNICODE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tokenize(text: str) -> set[str]:
|
|
44
|
+
text = (text or "").lower()
|
|
45
|
+
raw = _TOKEN_RE.findall(text)
|
|
46
|
+
grams: set[str] = set()
|
|
47
|
+
for i, t in enumerate(raw):
|
|
48
|
+
grams.add(t)
|
|
49
|
+
if i + 1 < len(raw) and len(t) == 1 and len(raw[i + 1]) == 1:
|
|
50
|
+
# CJK bigram
|
|
51
|
+
grams.add(t + raw[i + 1])
|
|
52
|
+
return grams
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fact_set(key_facts: list[str] | None) -> set[str]:
|
|
56
|
+
out: set[str] = set()
|
|
57
|
+
for f in key_facts or []:
|
|
58
|
+
out |= _tokenize(f)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- scoring -----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# A page is considered a contradiction candidate when its key_facts
|
|
65
|
+
# Jaccard overlap with another page is at or above this threshold AND
|
|
66
|
+
# the page shares at least one tag in common. Both gates together
|
|
67
|
+
# dramatically reduce false positives (the store has many overlapping
|
|
68
|
+
# but non-conflicting facts).
|
|
69
|
+
DEFAULT_THRESHOLD = 0.45
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
73
|
+
if not a or not b:
|
|
74
|
+
return 0.0
|
|
75
|
+
inter = a & b
|
|
76
|
+
union = a | b
|
|
77
|
+
return len(inter) / len(union)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ContradictionMatch:
|
|
82
|
+
a_id: str
|
|
83
|
+
b_id: str
|
|
84
|
+
score: float
|
|
85
|
+
shared_tags: list[str]
|
|
86
|
+
a_title: str
|
|
87
|
+
b_title: str
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"a_id": self.a_id,
|
|
92
|
+
"b_id": self.b_id,
|
|
93
|
+
"score": round(self.score, 3),
|
|
94
|
+
"shared_tags": self.shared_tags,
|
|
95
|
+
"a_title": self.a_title,
|
|
96
|
+
"b_title": self.b_title,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_for_page(
|
|
101
|
+
store: MemoryStore,
|
|
102
|
+
page_id: str,
|
|
103
|
+
*,
|
|
104
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
105
|
+
max_candidates: int = 5,
|
|
106
|
+
) -> list[ContradictionMatch]:
|
|
107
|
+
"""Find contradiction candidates for a single wiki page.
|
|
108
|
+
|
|
109
|
+
Returns up to ``max_candidates`` matches ordered by descending
|
|
110
|
+
similarity. The candidates are NOT written to the store — the
|
|
111
|
+
caller decides whether to persist via ``write_contradicting_ids``.
|
|
112
|
+
"""
|
|
113
|
+
target = store.get_wiki_page(page_id)
|
|
114
|
+
if not target:
|
|
115
|
+
return []
|
|
116
|
+
target_facts = _fact_set(target.get("key_facts") or [])
|
|
117
|
+
if not target_facts:
|
|
118
|
+
return []
|
|
119
|
+
target_tags = set(target.get("tags") or [])
|
|
120
|
+
target_scope = target.get("scope") or "global"
|
|
121
|
+
|
|
122
|
+
candidates: list[ContradictionMatch] = []
|
|
123
|
+
# Pull pages in the same scope first (cheaper and more relevant);
|
|
124
|
+
# fall back to global if nothing surfaces.
|
|
125
|
+
pool = list(store.list_wiki_pages(limit=500, scope=target_scope))
|
|
126
|
+
if len(pool) < 5:
|
|
127
|
+
pool = list(store.list_wiki_pages(limit=500))
|
|
128
|
+
|
|
129
|
+
for other in pool:
|
|
130
|
+
if other["id"] == page_id:
|
|
131
|
+
continue
|
|
132
|
+
if (other.get("contradicting_ids") or []):
|
|
133
|
+
# already has a partner — skip so we don't duplicate work
|
|
134
|
+
if page_id in (other.get("contradicting_ids") or []):
|
|
135
|
+
continue
|
|
136
|
+
other_facts = _fact_set(other.get("key_facts") or [])
|
|
137
|
+
if not other_facts:
|
|
138
|
+
continue
|
|
139
|
+
other_tags = set(other.get("tags") or [])
|
|
140
|
+
shared = sorted(target_tags & other_tags)
|
|
141
|
+
if not shared and target_scope not in {"global", "all"}:
|
|
142
|
+
# Different topic — skip unless we're in global scope
|
|
143
|
+
continue
|
|
144
|
+
score = _jaccard(target_facts, other_facts)
|
|
145
|
+
if score >= threshold:
|
|
146
|
+
candidates.append(ContradictionMatch(
|
|
147
|
+
a_id=page_id,
|
|
148
|
+
b_id=other["id"],
|
|
149
|
+
score=score,
|
|
150
|
+
shared_tags=shared,
|
|
151
|
+
a_title=target.get("title") or "",
|
|
152
|
+
b_title=other.get("title") or "",
|
|
153
|
+
))
|
|
154
|
+
candidates.sort(key=lambda m: m.score, reverse=True)
|
|
155
|
+
return candidates[:max_candidates]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def write_contradicting_ids(
|
|
159
|
+
store: MemoryStore,
|
|
160
|
+
matches: Iterable[ContradictionMatch],
|
|
161
|
+
) -> int:
|
|
162
|
+
"""Persist the symmetric ``contradicting_ids`` columns on both
|
|
163
|
+
sides of every match. Returns the number of pages updated."""
|
|
164
|
+
by_page: dict[str, set[str]] = {}
|
|
165
|
+
for m in matches:
|
|
166
|
+
by_page.setdefault(m.a_id, set()).add(m.b_id)
|
|
167
|
+
by_page.setdefault(m.b_id, set()).add(m.a_id)
|
|
168
|
+
n = 0
|
|
169
|
+
for page_id, ids in by_page.items():
|
|
170
|
+
cur = store.get_wiki_page(page_id)
|
|
171
|
+
if not cur:
|
|
172
|
+
continue
|
|
173
|
+
existing = set(cur.get("contradicting_ids") or [])
|
|
174
|
+
merged = sorted(existing | ids)
|
|
175
|
+
if sorted(existing) == merged:
|
|
176
|
+
continue
|
|
177
|
+
# Re-upsert by slug so we don't depend on upsert supporting
|
|
178
|
+
# the page_id kwarg (it doesn't — slug is the lookup key).
|
|
179
|
+
store.upsert_wiki_page(
|
|
180
|
+
slug=cur.get("slug") or "",
|
|
181
|
+
title=cur.get("title") or "",
|
|
182
|
+
body=cur.get("body") or "",
|
|
183
|
+
summary=cur.get("summary") or "",
|
|
184
|
+
tags=cur.get("tags") or [],
|
|
185
|
+
importance=float(cur.get("importance") or 0.5),
|
|
186
|
+
evidence_ids=cur.get("evidence_ids") or [],
|
|
187
|
+
run_id=cur.get("run_id"),
|
|
188
|
+
scope=cur.get("scope") or "global",
|
|
189
|
+
key_facts=cur.get("key_facts") or [],
|
|
190
|
+
contradicting_ids=merged,
|
|
191
|
+
)
|
|
192
|
+
n += 1
|
|
193
|
+
return n
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def scan_all(
|
|
197
|
+
store: MemoryStore,
|
|
198
|
+
*,
|
|
199
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
200
|
+
progress: Any = None,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""Re-scan every wiki page for contradictions.
|
|
203
|
+
|
|
204
|
+
Used after the user has manually edited pages or imported new
|
|
205
|
+
ones. Cheap enough to run interactively for stores with < 1000
|
|
206
|
+
pages.
|
|
207
|
+
"""
|
|
208
|
+
t0 = time.time()
|
|
209
|
+
pages = store.list_wiki_pages(limit=2000)
|
|
210
|
+
all_matches: list[ContradictionMatch] = []
|
|
211
|
+
for i, p in enumerate(pages):
|
|
212
|
+
if progress:
|
|
213
|
+
try:
|
|
214
|
+
progress(i, len(pages), p.get("title") or p["id"])
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
m = detect_for_page(store, p["id"], threshold=threshold)
|
|
218
|
+
all_matches.extend(m)
|
|
219
|
+
# Symmetric write — pass unique pair tuples (sorted ids so each
|
|
220
|
+
# pair only gets written once).
|
|
221
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
222
|
+
deduped: list[ContradictionMatch] = []
|
|
223
|
+
for m in all_matches:
|
|
224
|
+
key = tuple(sorted([m.a_id, m.b_id]))
|
|
225
|
+
if key in seen_pairs:
|
|
226
|
+
continue
|
|
227
|
+
seen_pairs.add(key)
|
|
228
|
+
deduped.append(m)
|
|
229
|
+
n_updated = write_contradicting_ids(store, deduped)
|
|
230
|
+
return {
|
|
231
|
+
"pages_scanned": len(pages),
|
|
232
|
+
"matches": [m.to_dict() for m in deduped],
|
|
233
|
+
"pages_updated": n_updated,
|
|
234
|
+
"elapsed_ms": round((time.time() - t0) * 1000, 1),
|
|
235
|
+
"threshold": threshold,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def list_contradictions(store: MemoryStore) -> list[dict[str, Any]]:
|
|
240
|
+
"""Return every wiki page that has at least one contradicting id.
|
|
241
|
+
|
|
242
|
+
Each entry carries the page metadata plus a list of partner
|
|
243
|
+
summaries so the UI can render a one-row-per-conflict table.
|
|
244
|
+
"""
|
|
245
|
+
pages = store.list_wiki_pages(limit=2000)
|
|
246
|
+
out: list[dict[str, Any]] = []
|
|
247
|
+
for p in pages:
|
|
248
|
+
cids = p.get("contradicting_ids") or []
|
|
249
|
+
if not cids:
|
|
250
|
+
continue
|
|
251
|
+
partners = []
|
|
252
|
+
for cid in cids:
|
|
253
|
+
other = store.get_wiki_page(cid)
|
|
254
|
+
if other:
|
|
255
|
+
partners.append({
|
|
256
|
+
"id": cid,
|
|
257
|
+
"title": other.get("title") or "",
|
|
258
|
+
"summary": (other.get("summary") or "")[:200],
|
|
259
|
+
"importance": float(other.get("importance") or 0),
|
|
260
|
+
"scope": other.get("scope") or "global",
|
|
261
|
+
})
|
|
262
|
+
out.append({
|
|
263
|
+
"id": p["id"],
|
|
264
|
+
"title": p.get("title") or "",
|
|
265
|
+
"summary": (p.get("summary") or "")[:200],
|
|
266
|
+
"importance": float(p.get("importance") or 0),
|
|
267
|
+
"scope": p.get("scope") or "global",
|
|
268
|
+
"key_facts": p.get("key_facts") or [],
|
|
269
|
+
"partners": partners,
|
|
270
|
+
})
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
__all__ = [
|
|
275
|
+
"detect_for_page",
|
|
276
|
+
"write_contradicting_ids",
|
|
277
|
+
"scan_all",
|
|
278
|
+
"list_contradictions",
|
|
279
|
+
"ContradictionMatch",
|
|
280
|
+
"DEFAULT_THRESHOLD",
|
|
281
|
+
]
|