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,702 @@
|
|
|
1
|
+
"""Route group: insights.
|
|
2
|
+
|
|
3
|
+
Heavy computed views (insights + weekly report).
|
|
4
|
+
|
|
5
|
+
All routes were extracted from ``serve/app.py`` as part of the O1
|
|
6
|
+
refactor to keep the central ``create_app`` small. Each block lives
|
|
7
|
+
inside ``register(app, store, scheduler=None)`` so closures over the
|
|
8
|
+
three captured variables work unchanged from the original layout.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import time
|
|
18
|
+
import json
|
|
19
|
+
from fastapi import FastAPI, HTTPException
|
|
20
|
+
from fastapi.responses import JSONResponse
|
|
21
|
+
|
|
22
|
+
from ...storage.sqlite_store import MemoryStore
|
|
23
|
+
from ._shared import _memory_to_dict, _split_think, _export_safe_segment
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
|
|
27
|
+
"""Mount every route in this bucket onto ``app``.
|
|
28
|
+
|
|
29
|
+
``store`` and ``scheduler`` are captured in the route closures so
|
|
30
|
+
the function bodies stay byte-identical to the pre-split layout.
|
|
31
|
+
"""
|
|
32
|
+
@app.get("/api/insights")
|
|
33
|
+
def insights():
|
|
34
|
+
"""All the data the Insights dashboard needs in one shot.
|
|
35
|
+
|
|
36
|
+
Powers the Stats overview + 生命周期 + Self-improvement Pulse
|
|
37
|
+
+ 记忆压缩 + 记忆粒度 + 数据分布 widgets. Cheap enough to
|
|
38
|
+
poll every 5-10s.
|
|
39
|
+
"""
|
|
40
|
+
import time as _time
|
|
41
|
+
now = _time.time()
|
|
42
|
+
with store._conn() as c:
|
|
43
|
+
# --- 1) Stats overview ---
|
|
44
|
+
n_total = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
|
|
45
|
+
n_today = c.execute(
|
|
46
|
+
"SELECT COUNT(*) c FROM memories "
|
|
47
|
+
"WHERE created_at >= strftime('%s','now','start of day')"
|
|
48
|
+
).fetchone()["c"]
|
|
49
|
+
n_active = c.execute(
|
|
50
|
+
"SELECT COUNT(*) c FROM memories "
|
|
51
|
+
"WHERE score >= 0.3 AND created_at >= ?",
|
|
52
|
+
(now - 7 * 86400,),
|
|
53
|
+
).fetchone()["c"]
|
|
54
|
+
n_links = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"]
|
|
55
|
+
# Merge group count: tag-cluster clusters from evolution runs
|
|
56
|
+
n_clusters = c.execute(
|
|
57
|
+
"SELECT COUNT(*) c FROM (SELECT DISTINCT slug FROM wiki_pages)"
|
|
58
|
+
).fetchone()["c"]
|
|
59
|
+
avg_score = c.execute(
|
|
60
|
+
"SELECT AVG(score) FROM memories"
|
|
61
|
+
).fetchone()[0] or 0.0
|
|
62
|
+
n_decayed = c.execute(
|
|
63
|
+
"SELECT COUNT(*) c FROM memories WHERE score < 0.3"
|
|
64
|
+
).fetchone()["c"]
|
|
65
|
+
# entities are our "向量数"
|
|
66
|
+
n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"]
|
|
67
|
+
# Occupation = % of memories that are High+Mid importance
|
|
68
|
+
n_high = c.execute(
|
|
69
|
+
"SELECT COUNT(*) c FROM memories WHERE importance >= 0.7"
|
|
70
|
+
).fetchone()["c"]
|
|
71
|
+
n_mid = c.execute(
|
|
72
|
+
"SELECT COUNT(*) c FROM memories WHERE importance >= 0.4 AND importance < 0.7"
|
|
73
|
+
).fetchone()["c"]
|
|
74
|
+
occupation = ((n_high + n_mid) / max(1, n_total)) * 100.0
|
|
75
|
+
# Citation rate = share of memories that were recalled >= 1 time
|
|
76
|
+
n_recalled = c.execute(
|
|
77
|
+
"SELECT COUNT(DISTINCT s.memory_id) c FROM memory_signals s "
|
|
78
|
+
"WHERE s.recall_count > 0"
|
|
79
|
+
).fetchone()["c"]
|
|
80
|
+
citation = (n_recalled / max(1, n_total)) * 100.0
|
|
81
|
+
# Decay rate = share of memories with score < 0.3
|
|
82
|
+
decay = (n_decayed / max(1, n_total)) * 100.0
|
|
83
|
+
|
|
84
|
+
# --- 2) Lifecycle stage counts ---
|
|
85
|
+
# Six stages: 已提取 / 活跃 / 已衰减 / 已合并 / 已归档 / 已遗忘
|
|
86
|
+
stages = {
|
|
87
|
+
"extracted": c.execute(
|
|
88
|
+
"SELECT COUNT(*) c FROM memories WHERE created_at >= ?",
|
|
89
|
+
(now - 86400,),
|
|
90
|
+
).fetchone()["c"],
|
|
91
|
+
"active": n_active,
|
|
92
|
+
"decayed": n_decayed,
|
|
93
|
+
"merged": c.execute(
|
|
94
|
+
"SELECT COUNT(*) c FROM wiki_pages"
|
|
95
|
+
).fetchone()["c"],
|
|
96
|
+
"archived": c.execute(
|
|
97
|
+
"SELECT COUNT(*) c FROM memories "
|
|
98
|
+
"WHERE score < 0.15 AND score > 0"
|
|
99
|
+
).fetchone()["c"],
|
|
100
|
+
"forgotten": c.execute(
|
|
101
|
+
"SELECT COUNT(*) c FROM memories WHERE score = 0"
|
|
102
|
+
).fetchone()["c"],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# --- 3) Compression candidates ---
|
|
106
|
+
# A memory is "compressible" if its text is long AND it has
|
|
107
|
+
# been distilled into a wiki page.
|
|
108
|
+
wiki_ids = {r[0] for r in c.execute(
|
|
109
|
+
"SELECT id FROM memories WHERE id IN "
|
|
110
|
+
"(SELECT DISTINCT json_each.value FROM wiki_pages, json_each(evidence_ids))"
|
|
111
|
+
).fetchall()}
|
|
112
|
+
compressible = c.execute(
|
|
113
|
+
"SELECT id, kind, text, length(text) AS L, importance, score "
|
|
114
|
+
"FROM memories WHERE length(text) > 280 "
|
|
115
|
+
"ORDER BY length(text) DESC LIMIT 30"
|
|
116
|
+
).fetchall()
|
|
117
|
+
compression = {
|
|
118
|
+
"compressible_count": len(compressible),
|
|
119
|
+
"avg_length": int(c.execute(
|
|
120
|
+
"SELECT AVG(length(text)) FROM memories"
|
|
121
|
+
).fetchone()[0] or 0),
|
|
122
|
+
"compression_progress": 0,
|
|
123
|
+
"items": [
|
|
124
|
+
{
|
|
125
|
+
"id": r["id"],
|
|
126
|
+
"kind": r["kind"],
|
|
127
|
+
"preview": (r["text"] or "")[:80],
|
|
128
|
+
"length": r["L"],
|
|
129
|
+
"importance": r["importance"] or 0.0,
|
|
130
|
+
"score": r["score"] or 0.0,
|
|
131
|
+
"in_wiki": r["id"] in wiki_ids,
|
|
132
|
+
}
|
|
133
|
+
for r in compressible
|
|
134
|
+
],
|
|
135
|
+
}
|
|
136
|
+
# progress = wiki_count / compressible_count
|
|
137
|
+
if compression["compressible_count"] > 0:
|
|
138
|
+
compression["compression_progress"] = round(
|
|
139
|
+
min(100.0, (len(wiki_ids) / compression["compressible_count"]) * 100.0),
|
|
140
|
+
1,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# --- 4) Memory granularity buckets ---
|
|
144
|
+
# Core: kind=fact + importance >= 0.7 + score >= 0.5
|
|
145
|
+
# Working: anything else not in Core/Scratch
|
|
146
|
+
# Scratch: low score AND low importance OR has been merged
|
|
147
|
+
core = c.execute(
|
|
148
|
+
"SELECT id, text, importance, score FROM memories "
|
|
149
|
+
"WHERE kind='fact' AND importance >= 0.7 AND score >= 0.5 "
|
|
150
|
+
"ORDER BY score DESC LIMIT 24"
|
|
151
|
+
).fetchall()
|
|
152
|
+
scratch = c.execute(
|
|
153
|
+
"SELECT id, text, importance, score FROM memories "
|
|
154
|
+
"WHERE (importance < 0.4 AND score < 0.4) OR score < 0.15 "
|
|
155
|
+
"ORDER BY score ASC LIMIT 24"
|
|
156
|
+
).fetchall()
|
|
157
|
+
working = c.execute(
|
|
158
|
+
"SELECT id, text, importance, score FROM memories "
|
|
159
|
+
"WHERE id NOT IN (SELECT id FROM memories WHERE kind='fact' "
|
|
160
|
+
" AND importance >= 0.7 AND score >= 0.5) "
|
|
161
|
+
" AND id NOT IN (SELECT id FROM memories WHERE "
|
|
162
|
+
" (importance < 0.4 AND score < 0.4) OR score < 0.15) "
|
|
163
|
+
"ORDER BY score DESC LIMIT 24"
|
|
164
|
+
).fetchall()
|
|
165
|
+
|
|
166
|
+
def _row(r):
|
|
167
|
+
return {
|
|
168
|
+
"id": r["id"],
|
|
169
|
+
"text": (r["text"] or "")[:60],
|
|
170
|
+
"importance": r["importance"] or 0.0,
|
|
171
|
+
"score": r["score"] or 0.0,
|
|
172
|
+
}
|
|
173
|
+
granularity = {
|
|
174
|
+
"core_count": c.execute(
|
|
175
|
+
"SELECT COUNT(*) c FROM memories "
|
|
176
|
+
"WHERE kind='fact' AND importance >= 0.7 AND score >= 0.5"
|
|
177
|
+
).fetchone()["c"],
|
|
178
|
+
"working_count": c.execute(
|
|
179
|
+
"SELECT COUNT(*) c FROM memories "
|
|
180
|
+
"WHERE NOT (kind='fact' AND importance >= 0.7 AND score >= 0.5) "
|
|
181
|
+
"AND NOT ((importance < 0.4 AND score < 0.4) OR score < 0.15)"
|
|
182
|
+
).fetchone()["c"],
|
|
183
|
+
"scratch_count": c.execute(
|
|
184
|
+
"SELECT COUNT(*) c FROM memories "
|
|
185
|
+
"WHERE (importance < 0.4 AND score < 0.4) OR score < 0.15"
|
|
186
|
+
).fetchone()["c"],
|
|
187
|
+
"core": [_row(r) for r in core],
|
|
188
|
+
"working": [_row(r) for r in working],
|
|
189
|
+
"scratch": [_row(r) for r in scratch],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# --- 5) Data distribution: type / status / 7-day trend ---
|
|
193
|
+
type_rows = c.execute(
|
|
194
|
+
"SELECT kind, COUNT(*) c FROM memories GROUP BY kind ORDER BY c DESC"
|
|
195
|
+
).fetchall()
|
|
196
|
+
status_rows = c.execute(
|
|
197
|
+
"SELECT CASE "
|
|
198
|
+
" WHEN score >= 0.5 THEN 'active' "
|
|
199
|
+
" WHEN score >= 0.15 THEN 'decayed' "
|
|
200
|
+
" WHEN score = 0 THEN 'forgotten' "
|
|
201
|
+
" ELSE 'archived' "
|
|
202
|
+
"END AS s, COUNT(*) c FROM memories GROUP BY s"
|
|
203
|
+
).fetchall()
|
|
204
|
+
trend_rows = c.execute(
|
|
205
|
+
"SELECT date(created_at, 'unixepoch', 'localtime') AS d, COUNT(*) c "
|
|
206
|
+
"FROM memories WHERE created_at >= strftime('%s','now','-7 days') "
|
|
207
|
+
"GROUP BY d ORDER BY d"
|
|
208
|
+
).fetchall()
|
|
209
|
+
# Build a dense 7-day series (filling gaps with 0)
|
|
210
|
+
import datetime as _dt
|
|
211
|
+
today = _dt.date.today()
|
|
212
|
+
trend_map = {r["d"]: r["c"] for r in trend_rows}
|
|
213
|
+
trend_series = []
|
|
214
|
+
for i in range(6, -1, -1):
|
|
215
|
+
d = (today - _dt.timedelta(days=i)).isoformat()
|
|
216
|
+
trend_series.append({"date": d, "count": trend_map.get(d, 0)})
|
|
217
|
+
|
|
218
|
+
distribution = {
|
|
219
|
+
"types": [{"kind": r["kind"], "count": r["c"]} for r in type_rows],
|
|
220
|
+
"status": [{"status": r["s"], "count": r["c"]} for r in status_rows],
|
|
221
|
+
"trend": trend_series,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# --- 6) Self-improvement Pulse: contradiction pairs ---
|
|
225
|
+
# Cheap heuristic: 2 memories share >= 4 distinct tags and
|
|
226
|
+
# have contradicting importance deltas (one very high, one
|
|
227
|
+
# mid/low), suggesting they should be merged.
|
|
228
|
+
# --- 5b) Sources breakdown (real, not synthetic) ---
|
|
229
|
+
# Normalize per-thread codex sources to a single bucket.
|
|
230
|
+
src_rows = c.execute(
|
|
231
|
+
"SELECT source, COUNT(*) c FROM memories "
|
|
232
|
+
"WHERE source IS NOT NULL AND source != '' "
|
|
233
|
+
"GROUP BY source ORDER BY c DESC"
|
|
234
|
+
).fetchall()
|
|
235
|
+
bucket = {}
|
|
236
|
+
for r in src_rows:
|
|
237
|
+
s = r["source"] or "unknown"
|
|
238
|
+
# Collapse long thread ids into their parent prefix
|
|
239
|
+
key = s.split("/")[0] if "/" in s else s
|
|
240
|
+
bucket[key] = bucket.get(key, 0) + r["c"]
|
|
241
|
+
sources = [
|
|
242
|
+
{"source": k, "count": v}
|
|
243
|
+
for k, v in sorted(bucket.items(), key=lambda x: -x[1])
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
# --- 5c) Wiki health ---
|
|
247
|
+
wiki_count = c.execute(
|
|
248
|
+
"SELECT COUNT(*) c FROM wiki_pages"
|
|
249
|
+
).fetchone()["c"]
|
|
250
|
+
wiki_avg_imp = c.execute(
|
|
251
|
+
"SELECT AVG(importance) FROM wiki_pages"
|
|
252
|
+
).fetchone()[0] or 0.0
|
|
253
|
+
wiki_total_chars = c.execute(
|
|
254
|
+
"SELECT COALESCE(SUM(length(body)), 0) FROM wiki_pages"
|
|
255
|
+
).fetchone()[0] or 0
|
|
256
|
+
# how many memories are referenced by at least one wiki page
|
|
257
|
+
wiki_ref_count = c.execute(
|
|
258
|
+
"SELECT COUNT(DISTINCT json_each.value) c "
|
|
259
|
+
"FROM wiki_pages, json_each(wiki_pages.evidence_ids) "
|
|
260
|
+
"WHERE evidence_ids IS NOT NULL"
|
|
261
|
+
).fetchone()["c"]
|
|
262
|
+
|
|
263
|
+
# --- 5d) Ingest rate (24 hourly buckets) ---
|
|
264
|
+
hourly_rows = c.execute(
|
|
265
|
+
"SELECT strftime('%H', created_at, 'unixepoch', 'localtime') AS h, "
|
|
266
|
+
"COUNT(*) c FROM memories "
|
|
267
|
+
"WHERE created_at >= strftime('%s','now','-24 hours') "
|
|
268
|
+
"GROUP BY h ORDER BY h"
|
|
269
|
+
).fetchall()
|
|
270
|
+
hourly_map = {r["h"]: r["c"] for r in hourly_rows}
|
|
271
|
+
hourly = [{"hour": f"{int(h):02d}:00", "count": hourly_map.get(f"{int(h):02d}", 0)}
|
|
272
|
+
for h in range(24)]
|
|
273
|
+
|
|
274
|
+
# --- 5e) Recall throughput (last 24h) ---
|
|
275
|
+
recall_rows = c.execute(
|
|
276
|
+
"SELECT COALESCE(SUM(recall_count), 0) AS c, "
|
|
277
|
+
"COUNT(DISTINCT memory_id) AS uniq "
|
|
278
|
+
"FROM memory_signals "
|
|
279
|
+
"WHERE last_recalled_at >= strftime('%s','now','-24 hours')"
|
|
280
|
+
).fetchone()
|
|
281
|
+
recall_total_24h = recall_rows["c"] or 0
|
|
282
|
+
recall_uniq_24h = recall_rows["uniq"] or 0
|
|
283
|
+
|
|
284
|
+
pulse = {"contradictions": [], "score_distribution": []}
|
|
285
|
+
c.execute(
|
|
286
|
+
"SELECT tags FROM memories WHERE tags IS NOT NULL AND tags != '' "
|
|
287
|
+
"AND length(tags) > 5"
|
|
288
|
+
).fetchall()
|
|
289
|
+
# (Real contradiction detection needs an LLM; we surface
|
|
290
|
+
# near-duplicates so the user can pick which to merge.)
|
|
291
|
+
sample = c.execute(
|
|
292
|
+
"SELECT id, text, importance, score, tags FROM memories "
|
|
293
|
+
"WHERE length(text) > 120 ORDER BY created_at DESC LIMIT 80"
|
|
294
|
+
).fetchall()
|
|
295
|
+
from collections import defaultdict
|
|
296
|
+
tag_buckets = defaultdict(list)
|
|
297
|
+
for r in sample:
|
|
298
|
+
try:
|
|
299
|
+
import json as _json
|
|
300
|
+
tags = _json.loads(r["tags"]) if r["tags"] else []
|
|
301
|
+
except Exception:
|
|
302
|
+
tags = []
|
|
303
|
+
for t in tags[:3]:
|
|
304
|
+
tag_buckets[t].append(r)
|
|
305
|
+
# Filter out pairs the user already resolved.
|
|
306
|
+
ignored = store.list_ignored_pairs()
|
|
307
|
+
for tag, rows in list(tag_buckets.items())[:6]:
|
|
308
|
+
if len(rows) >= 2:
|
|
309
|
+
a, b = rows[0], rows[1]
|
|
310
|
+
if store.pair_key(a["id"], b["id"]) in ignored:
|
|
311
|
+
continue
|
|
312
|
+
pulse["contradictions"].append({
|
|
313
|
+
"tag": tag,
|
|
314
|
+
"a": {"id": a["id"], "text": (a["text"] or "")[:120],
|
|
315
|
+
"importance": a["importance"] or 0,
|
|
316
|
+
"score": a["score"] or 0},
|
|
317
|
+
"b": {"id": b["id"], "text": (b["text"] or "")[:120],
|
|
318
|
+
"importance": b["importance"] or 0,
|
|
319
|
+
"score": b["score"] or 0},
|
|
320
|
+
"similarity": 0.78, # placeholder
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
# Score distribution histogram (0.0 .. 1.0 in 0.1 bins)
|
|
324
|
+
for i in range(10):
|
|
325
|
+
lo, hi = i * 0.1, (i + 1) * 0.1
|
|
326
|
+
cnt = c.execute(
|
|
327
|
+
"SELECT COUNT(*) c FROM memories WHERE score >= ? AND score < ?",
|
|
328
|
+
(lo, hi if hi < 1 else 2.0),
|
|
329
|
+
).fetchone()["c"]
|
|
330
|
+
pulse["score_distribution"].append({
|
|
331
|
+
"range": [round(lo, 1), round(hi, 1)],
|
|
332
|
+
"count": cnt,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
# --- 7) Pipeline latency / skill pipeline ---
|
|
336
|
+
# Average ms per stage from pipeline_runs.
|
|
337
|
+
stages_pipeline = []
|
|
338
|
+
for stage in ("score", "cluster", "distill", "wiki", "graph"):
|
|
339
|
+
row = c.execute(
|
|
340
|
+
"SELECT AVG((finished_at - started_at) * 1000) AS ms, COUNT(*) c "
|
|
341
|
+
"FROM pipeline_runs WHERE stage=? AND finished_at IS NOT NULL",
|
|
342
|
+
(stage,),
|
|
343
|
+
).fetchone()
|
|
344
|
+
stages_pipeline.append({
|
|
345
|
+
"stage": stage,
|
|
346
|
+
"avg_ms": int(row["ms"] or 0),
|
|
347
|
+
"count": row["c"] or 0,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"now": now,
|
|
352
|
+
"overview": {
|
|
353
|
+
"total": n_total,
|
|
354
|
+
"today": n_today,
|
|
355
|
+
"active": n_active,
|
|
356
|
+
"links": n_links,
|
|
357
|
+
"clusters": n_clusters,
|
|
358
|
+
"avg_score": round(avg_score, 3),
|
|
359
|
+
"decay_pct": round(decay, 1),
|
|
360
|
+
"entities": n_entities,
|
|
361
|
+
"occupation": round(occupation, 1),
|
|
362
|
+
"citation": round(citation, 1),
|
|
363
|
+
"decay": round(decay, 1),
|
|
364
|
+
},
|
|
365
|
+
"stages": stages,
|
|
366
|
+
"compression": compression,
|
|
367
|
+
"granularity": granularity,
|
|
368
|
+
"distribution": distribution,
|
|
369
|
+
"sources": sources,
|
|
370
|
+
"wiki_health": {
|
|
371
|
+
"pages": wiki_count,
|
|
372
|
+
"avg_importance": round(wiki_avg_imp, 3),
|
|
373
|
+
"total_chars": int(wiki_total_chars),
|
|
374
|
+
"referenced_memories": wiki_ref_count,
|
|
375
|
+
},
|
|
376
|
+
"ingest_rate": hourly,
|
|
377
|
+
"recall_24h": {
|
|
378
|
+
"total": recall_total_24h,
|
|
379
|
+
"unique_memories": recall_uniq_24h,
|
|
380
|
+
},
|
|
381
|
+
"pulse": pulse,
|
|
382
|
+
"pipeline": stages_pipeline,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# ====================================================================
|
|
386
|
+
# /api/weekly-report — natural-language weekly digest of what was learned
|
|
387
|
+
# ====================================================================
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@app.get("/api/weekly-report")
|
|
391
|
+
def weekly_report(
|
|
392
|
+
days: int = 7,
|
|
393
|
+
max_facts: int = 60,
|
|
394
|
+
use_llm: bool = True,
|
|
395
|
+
lang: str = "zh",
|
|
396
|
+
force: bool = False,
|
|
397
|
+
):
|
|
398
|
+
"""Build a Markdown weekly report covering the last ``days`` days.
|
|
399
|
+
|
|
400
|
+
The full LLM-backed report is heavy (several seconds + 600+ tokens
|
|
401
|
+
of output) and the user only needs a new digest when the week
|
|
402
|
+
rolls over. We therefore cache the rendered response to disk
|
|
403
|
+
under ``~/.loop_memory/cache/weekly/{lang}-{days}-{week_start}.json``
|
|
404
|
+
and serve it as-is on subsequent calls within the same ISO week.
|
|
405
|
+
|
|
406
|
+
Pass ``?force=true`` to bypass the cache and regenerate now
|
|
407
|
+
(this is what the manual refresh button wires up).
|
|
408
|
+
"""
|
|
409
|
+
import datetime as _dt
|
|
410
|
+
from ...llm.providers import build_provider, default_config
|
|
411
|
+
from ...llm.base import ChatHistory, Message
|
|
412
|
+
from ...storage.sqlite_store import LLMAuditStore
|
|
413
|
+
|
|
414
|
+
lang = "en" if str(lang).lower().startswith("en") else "zh"
|
|
415
|
+
|
|
416
|
+
# ---- weekly report cache ----------------------------------------
|
|
417
|
+
# The cache key is intentionally simple: same language + same
|
|
418
|
+
# window + same ISO week (Monday 00:00 in the user's local
|
|
419
|
+
# timezone) = same report. When Monday rolls around the key
|
|
420
|
+
# changes and a fresh report is generated.
|
|
421
|
+
cache_dir = Path.home() / ".loop_memory" / "cache" / "weekly"
|
|
422
|
+
_now_local = _dt.datetime.now().astimezone()
|
|
423
|
+
_iso_week = _now_local.isocalendar() # (year, week, weekday)
|
|
424
|
+
_week_start = (_now_local - _dt.timedelta(days=_iso_week.weekday - 1)
|
|
425
|
+
).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
426
|
+
_cache_key = f"{lang}-{days}-{_iso_week.year}-W{_iso_week.week:02d}"
|
|
427
|
+
_cache_path = cache_dir / f"{_cache_key}.json"
|
|
428
|
+
if not force and use_llm:
|
|
429
|
+
try:
|
|
430
|
+
if _cache_path.exists():
|
|
431
|
+
cached = json.loads(_cache_path.read_text(encoding="utf-8"))
|
|
432
|
+
cached["from_cache"] = True
|
|
433
|
+
cached["cache_key"] = _cache_key
|
|
434
|
+
return cached
|
|
435
|
+
except Exception:
|
|
436
|
+
# Corrupt cache file: fall through and regenerate.
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
now = time.time()
|
|
440
|
+
since = now - days * 86400
|
|
441
|
+
with store._conn() as c:
|
|
442
|
+
rows = c.execute(
|
|
443
|
+
"""SELECT id, kind, text, importance, source, tags, score,
|
|
444
|
+
datetime(created_at, 'unixepoch') AS ts
|
|
445
|
+
FROM memories
|
|
446
|
+
WHERE created_at >= ?
|
|
447
|
+
ORDER BY importance DESC, score DESC, created_at DESC
|
|
448
|
+
LIMIT ?""",
|
|
449
|
+
(since, max_facts),
|
|
450
|
+
).fetchall()
|
|
451
|
+
source_rows = c.execute(
|
|
452
|
+
"""SELECT COALESCE(source, 'unknown') AS s, COUNT(*) c
|
|
453
|
+
FROM memories WHERE created_at >= ? GROUP BY s ORDER BY c DESC""",
|
|
454
|
+
(since,),
|
|
455
|
+
).fetchall()
|
|
456
|
+
total_window = c.execute(
|
|
457
|
+
"SELECT COUNT(*) c FROM memories WHERE created_at >= ?", (since,)
|
|
458
|
+
).fetchone()["c"]
|
|
459
|
+
|
|
460
|
+
# Normalize sources: collapse per-thread codex IDs into the parent
|
|
461
|
+
# "codex" bucket so the UI doesn't show one noisy row per thread.
|
|
462
|
+
norm: dict[str, int] = {}
|
|
463
|
+
for r in source_rows:
|
|
464
|
+
key = (r["s"] or "unknown").split("/")[0]
|
|
465
|
+
norm[key] = norm.get(key, 0) + int(r["c"])
|
|
466
|
+
normalized_sources = [
|
|
467
|
+
{"source": k, "count": v} for k, v in sorted(norm.items(), key=lambda x: -x[1])
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
items = [dict(r) for r in rows]
|
|
471
|
+
sources = [{"source": r["s"], "count": r["c"]} for r in source_rows]
|
|
472
|
+
highlights = items[: max(3, max_facts // 4)]
|
|
473
|
+
lowlights = sorted(items, key=lambda x: (x.get("importance") or 0, x.get("score") or 0))[: max(3, max_facts // 6)]
|
|
474
|
+
|
|
475
|
+
stats = {
|
|
476
|
+
"window_days": days,
|
|
477
|
+
"since": since,
|
|
478
|
+
"now": now,
|
|
479
|
+
"total_in_window": total_window,
|
|
480
|
+
"sources": sources,
|
|
481
|
+
"sources_normalized": normalized_sources,
|
|
482
|
+
"highlight_count": len(highlights),
|
|
483
|
+
"lowlight_count": len(lowlights),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
llm_used = False
|
|
487
|
+
summary_md = ""
|
|
488
|
+
thinking_md = ""
|
|
489
|
+
prov_name = "none"
|
|
490
|
+
provider = None
|
|
491
|
+
llm_error = None
|
|
492
|
+
llm_error_kind = None # "no_provider" | "no_key" | "auth_failed" | "network" | "other"
|
|
493
|
+
llm_hint = None # user-facing hint for the banner
|
|
494
|
+
try:
|
|
495
|
+
cfg = store.get_setting("llm_consolidator", default_config()) or {}
|
|
496
|
+
provider = build_provider(cfg)
|
|
497
|
+
prov_name = type(provider).__name__
|
|
498
|
+
except Exception as e:
|
|
499
|
+
llm_error = f"{type(e).__name__}: {e}"
|
|
500
|
+
llm_error_kind = "no_provider"
|
|
501
|
+
llm_hint = (
|
|
502
|
+
"LLM provider not configured. Open Settings → Models to pick one."
|
|
503
|
+
if lang == "en"
|
|
504
|
+
else "尚未配置大模型提供方,请前往“设置 → 模型”选择一个提供方。"
|
|
505
|
+
)
|
|
506
|
+
provider = None
|
|
507
|
+
|
|
508
|
+
# Detect placeholder / empty API keys (the most common cause of "auth
|
|
509
|
+
# failed" on a fresh install). The shipped secret store contains
|
|
510
|
+
# a recognizable placeholder by default — anyone who never opened
|
|
511
|
+
# the settings page is hitting that.
|
|
512
|
+
raw_key = getattr(provider, "api_key", None) or ""
|
|
513
|
+
# Only flag *clearly fake* placeholders, not real keys that happen to
|
|
514
|
+
# contain \'x\'. Common patterns: "sk-xxxx...", "your-api-key",
|
|
515
|
+
# "REPLACE_ME", "<API_KEY>", or fewer than 8 non-whitespace chars.
|
|
516
|
+
if (not raw_key
|
|
517
|
+
or len(raw_key.strip()) < 8
|
|
518
|
+
or re.search(r"x{4,}", raw_key)
|
|
519
|
+
or re.search(r"(your[-_ ]?(api[-_ ]?)?key|replace[-_ ]?me|<api[-_ ]?key>|placeholder)", raw_key, re.I)):
|
|
520
|
+
llm_error_kind = "no_key"
|
|
521
|
+
llm_error = llm_error or "API key not configured"
|
|
522
|
+
llm_hint = (
|
|
523
|
+
f"No real API key for {prov_name}. Open Settings → Models, paste your key, "
|
|
524
|
+
"and Save. Stored locally in ~/.loop_memory/secrets.json — never sent anywhere else."
|
|
525
|
+
if lang == "en"
|
|
526
|
+
else f"{prov_name} 尚未配置有效 API Key。请前往“设置 → 模型”粘贴并保存。"
|
|
527
|
+
"密钥仅保存在本机 ~/.loop_memory/secrets.json,不会发送到其他位置。"
|
|
528
|
+
)
|
|
529
|
+
provider = None # skip the LLM call entirely
|
|
530
|
+
|
|
531
|
+
if use_llm and provider is not None:
|
|
532
|
+
t0 = time.time()
|
|
533
|
+
try:
|
|
534
|
+
if lang == "en":
|
|
535
|
+
sys_prompt = (
|
|
536
|
+
"You are a memory-system reporter. Given a JSON snapshot of a user's "
|
|
537
|
+
"recent memories, write a concise weekly report in English Markdown. "
|
|
538
|
+
"Output exactly 3 sections: ## Highlights (3-5 bullets), "
|
|
539
|
+
"## Lowlights (2-3 bullets, things to review / prune / forget), "
|
|
540
|
+
"## Next focus (1-2 bullets suggesting what to remember better next week). "
|
|
541
|
+
"Keep the total under 250 words."
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
sys_prompt = (
|
|
545
|
+
"你是记忆系统周报助手。根据用户近期记忆的 JSON 快照,使用中文 Markdown "
|
|
546
|
+
"生成简洁周报。必须只输出 3 个章节:## 本周亮点(3-5 条)、"
|
|
547
|
+
"## 待整理内容(2-3 条需要复查、裁剪或遗忘的内容)、"
|
|
548
|
+
"## 下周重点(1-2 条下周应重点积累的知识)。总字数不超过 250 字。"
|
|
549
|
+
)
|
|
550
|
+
user_payload = {
|
|
551
|
+
"window": f"{days} days",
|
|
552
|
+
"total_new_memories": total_window,
|
|
553
|
+
"sources": normalized_sources,
|
|
554
|
+
"top_memories": [
|
|
555
|
+
{
|
|
556
|
+
"kind": i["kind"], "imp": round(i["importance"] or 0, 2),
|
|
557
|
+
"score": round(i["score"] or 0, 2),
|
|
558
|
+
"source": i["source"],
|
|
559
|
+
"text": (i["text"] or "")[:160],
|
|
560
|
+
}
|
|
561
|
+
for i in items[:20]
|
|
562
|
+
],
|
|
563
|
+
}
|
|
564
|
+
user_prompt = json.dumps(user_payload, ensure_ascii=False)
|
|
565
|
+
history = ChatHistory(
|
|
566
|
+
system=sys_prompt,
|
|
567
|
+
messages=[Message(role="user", content=user_prompt)],
|
|
568
|
+
)
|
|
569
|
+
try:
|
|
570
|
+
reply = provider.complete(history, temperature=0.3, max_tokens=1200) or ""
|
|
571
|
+
except Exception as e:
|
|
572
|
+
reply = ""
|
|
573
|
+
raw = f"{type(e).__name__}: {e}"
|
|
574
|
+
llm_error = raw
|
|
575
|
+
# Pull structured fields from LLMHttpError when available.
|
|
576
|
+
status = getattr(e, "status", None)
|
|
577
|
+
pcode = getattr(e, "provider_code", None) or ""
|
|
578
|
+
pmsg = getattr(e, "provider_message", None) or ""
|
|
579
|
+
sl = str(e).lower()
|
|
580
|
+
if status is None:
|
|
581
|
+
m = re.search(r"HTTP\s+(\d+)", str(e))
|
|
582
|
+
status = int(m.group(1)) if m else 0
|
|
583
|
+
if (status in (401, 403)
|
|
584
|
+
or "2049" in pcode or "1004" in pcode
|
|
585
|
+
or "authorized" in sl or "invalid api key" in sl):
|
|
586
|
+
llm_error_kind = "auth_failed"
|
|
587
|
+
elif status == 429 or "rate" in sl:
|
|
588
|
+
llm_error_kind = "rate_limited"
|
|
589
|
+
elif status >= 500 or status == 0 or "timeout" in sl or "connection" in sl:
|
|
590
|
+
llm_error_kind = "network"
|
|
591
|
+
else:
|
|
592
|
+
llm_error_kind = "other"
|
|
593
|
+
llm_hint = _hint_for_llm_error(prov_name, status or 0, pcode, pmsg)
|
|
594
|
+
# Keep the bare kind; provider code goes in its own field.
|
|
595
|
+
llm_provider_code_resp = pcode
|
|
596
|
+
llm_provider_message_resp = pmsg
|
|
597
|
+
log.warning("weekly-report LLM call failed [kind=%s, status=%s, code=%s]: %s",
|
|
598
|
+
llm_error_kind, status, pcode, raw)
|
|
599
|
+
llm_used = bool(reply.strip())
|
|
600
|
+
summary_md, thinking_md = _split_think(reply.strip())
|
|
601
|
+
# Audit
|
|
602
|
+
try:
|
|
603
|
+
LLMAuditStore(store).record(
|
|
604
|
+
provider=type(provider).__name__,
|
|
605
|
+
model=getattr(provider, "model", "?") or "?",
|
|
606
|
+
kind="weekly_report",
|
|
607
|
+
prompt=sys_prompt + "\n" + user_prompt,
|
|
608
|
+
response=reply,
|
|
609
|
+
prompt_tokens=max(1, len(sys_prompt) // 4) + max(1, len(user_prompt) // 4),
|
|
610
|
+
completion_tokens=max(1, len(reply) // 4) if reply else 0,
|
|
611
|
+
cost_usd=0.0,
|
|
612
|
+
latency_ms=int((time.time() - t0) * 1000),
|
|
613
|
+
ok=llm_used,
|
|
614
|
+
)
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
except Exception as e:
|
|
618
|
+
llm_error = f"{type(e).__name__}: {e}"
|
|
619
|
+
|
|
620
|
+
if not summary_md:
|
|
621
|
+
# Templated fallback
|
|
622
|
+
if lang == "en":
|
|
623
|
+
lines = [f"# Memory weekly · {_dt.date.today().isoformat()}", ""]
|
|
624
|
+
lines.append(f"- Window: {days} days")
|
|
625
|
+
lines.append(f"- New memories: {total_window}")
|
|
626
|
+
else:
|
|
627
|
+
lines = [f"# 记忆周报 · {_dt.date.today().isoformat()}", ""]
|
|
628
|
+
lines.append(f"- 时间窗口:{days} 天")
|
|
629
|
+
lines.append(f"- 新增记忆:{total_window} 条")
|
|
630
|
+
if sources:
|
|
631
|
+
src_str = ", ".join(f"{s['source']}={s['count']}" for s in sources[:5])
|
|
632
|
+
separator = ": " if lang == "en" else ":"
|
|
633
|
+
lines.append(f"- {'Source distribution' if lang == 'en' else '来源分布'}{separator}{src_str}")
|
|
634
|
+
lines.append("")
|
|
635
|
+
lines.append("## Highlights" if lang == "en" else "## 本周亮点")
|
|
636
|
+
for h in highlights[:5]:
|
|
637
|
+
lines.append(f"- **[{h['source']}]** {h['text'][:100]}")
|
|
638
|
+
lines.append("")
|
|
639
|
+
lines.append("## Lowlights" if lang == "en" else "## 待整理内容")
|
|
640
|
+
for lowlight in lowlights[:3]:
|
|
641
|
+
lines.append(f"- *[{lowlight['source']}]* {lowlight['text'][:100]}")
|
|
642
|
+
lines.append("")
|
|
643
|
+
lines.append("## Next focus" if lang == "en" else "## 下周重点")
|
|
644
|
+
lines.append(
|
|
645
|
+
"- Keep capturing, distill regularly, and retain high-quality memories"
|
|
646
|
+
if lang == "en"
|
|
647
|
+
else "- 持续记录、定期蒸馏,并保留高质量记忆"
|
|
648
|
+
)
|
|
649
|
+
summary_md = "\n".join(lines)
|
|
650
|
+
|
|
651
|
+
# Surface enough provider / key info that the UI can render a
|
|
652
|
+
# specific "what went wrong" hint (key prefix, provider code, etc.)
|
|
653
|
+
# without making the user re-test from the settings page.
|
|
654
|
+
prov_code = locals().get("llm_provider_code_resp") or None
|
|
655
|
+
prov_msg = locals().get("llm_provider_message_resp") or None
|
|
656
|
+
if provider is not None:
|
|
657
|
+
rk = getattr(provider, "api_key", None) or ""
|
|
658
|
+
kp = (rk[:10] + "...") if len(rk) > 10 else rk
|
|
659
|
+
klen = len(rk)
|
|
660
|
+
else:
|
|
661
|
+
kp = ""
|
|
662
|
+
klen = 0
|
|
663
|
+
result = {
|
|
664
|
+
"markdown": summary_md,
|
|
665
|
+
"stats": stats,
|
|
666
|
+
"highlights": highlights[:5],
|
|
667
|
+
"lowlights": lowlights[:3],
|
|
668
|
+
"llm_used": llm_used,
|
|
669
|
+
"llm_provider": prov_name,
|
|
670
|
+
"llm_error": llm_error,
|
|
671
|
+
"llm_error_kind": llm_error_kind,
|
|
672
|
+
"llm_provider_code": prov_code,
|
|
673
|
+
"llm_provider_message": prov_msg,
|
|
674
|
+
"llm_hint": llm_hint,
|
|
675
|
+
"llm_key_prefix": kp,
|
|
676
|
+
"llm_key_len": klen,
|
|
677
|
+
"thinking": thinking_md,
|
|
678
|
+
"generated_at": now,
|
|
679
|
+
"cache_key": _cache_key,
|
|
680
|
+
"week_start": _week_start.isoformat(),
|
|
681
|
+
"from_cache": False,
|
|
682
|
+
}
|
|
683
|
+
# Persist for next call within the same ISO week. Only cache
|
|
684
|
+
# the LLM-backed reports — the templated fallback is cheap to
|
|
685
|
+
# recompute and changes on every memory write, so caching it
|
|
686
|
+
# would actually make the report go stale.
|
|
687
|
+
if use_llm and llm_used:
|
|
688
|
+
try:
|
|
689
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
690
|
+
_cache_path.write_text(
|
|
691
|
+
json.dumps(result, ensure_ascii=False, default=str),
|
|
692
|
+
encoding="utf-8",
|
|
693
|
+
)
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
return result
|
|
697
|
+
|
|
698
|
+
# ====================================================================
|
|
699
|
+
# /api/llm-audit — surface the LLM audit log
|
|
700
|
+
# ====================================================================
|
|
701
|
+
|
|
702
|
+
|