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,812 @@
|
|
|
1
|
+
"""Route group: wiki.
|
|
2
|
+
|
|
3
|
+
Wiki CRUD + export/import/ask/contradictions + /api/v1/wiki/versions.
|
|
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
|
+
from fastapi import FastAPI, HTTPException
|
|
15
|
+
from fastapi.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from ...storage.sqlite_store import MemoryStore
|
|
18
|
+
from ._shared import _memory_to_dict, _export_safe_segment
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _wiki_scope_config(store: MemoryStore) -> dict:
|
|
22
|
+
from ...wiki.scope import auto_scope_config
|
|
23
|
+
return auto_scope_config(store)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _wiki_source_hint(payload: dict) -> str | None:
|
|
27
|
+
for key in ("source", "client", "agent", "agent_id"):
|
|
28
|
+
value = payload.get(key)
|
|
29
|
+
if value is not None and str(value).strip():
|
|
30
|
+
return str(value).strip()
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _wiki_evidence_sources(store: MemoryStore, evidence_ids: list) -> list[str]:
|
|
35
|
+
if not evidence_ids:
|
|
36
|
+
return []
|
|
37
|
+
try:
|
|
38
|
+
rows = store.list_memories(ids=[str(x) for x in evidence_ids], limit=max(1, len(evidence_ids)))
|
|
39
|
+
except Exception:
|
|
40
|
+
rows = []
|
|
41
|
+
return [str(getattr(row, "source", "") or "").strip() for row in rows if getattr(row, "source", None)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_wiki_scope(
|
|
45
|
+
store: MemoryStore,
|
|
46
|
+
*,
|
|
47
|
+
title: str,
|
|
48
|
+
body: str,
|
|
49
|
+
summary: str,
|
|
50
|
+
tags: list,
|
|
51
|
+
evidence_ids: list,
|
|
52
|
+
requested_scope: object,
|
|
53
|
+
source_hint: str | None = None,
|
|
54
|
+
existing: dict | None = None,
|
|
55
|
+
preserve_existing: bool = False,
|
|
56
|
+
) -> tuple[str, dict]:
|
|
57
|
+
"""Classify a page and return ``(scope, audit_json)``.
|
|
58
|
+
|
|
59
|
+
An explicit scope is authoritative. Missing/``auto`` scope is routed by
|
|
60
|
+
the deterministic classifier; non-security pages go to the evidence or
|
|
61
|
+
client source and never default to global. Existing rows are preserved
|
|
62
|
+
when an update omits scope, which protects legacy/manual overrides.
|
|
63
|
+
"""
|
|
64
|
+
from ...wiki.classifier import classify_page
|
|
65
|
+
from ...wiki.scope import (
|
|
66
|
+
build_scope_audit,
|
|
67
|
+
derive_default_scope,
|
|
68
|
+
normalise_scope,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
cfg = _wiki_scope_config(store)
|
|
72
|
+
classification = classify_page(
|
|
73
|
+
title=title,
|
|
74
|
+
body=body,
|
|
75
|
+
summary=summary,
|
|
76
|
+
tags=tags,
|
|
77
|
+
evidence_sources=_wiki_evidence_sources(store, evidence_ids),
|
|
78
|
+
mode=cfg["mode"] if cfg["enabled"] else "off",
|
|
79
|
+
)
|
|
80
|
+
raw_requested = "" if requested_scope is None else str(requested_scope).strip().lower()
|
|
81
|
+
explicit = bool(raw_requested and raw_requested != "auto")
|
|
82
|
+
if explicit:
|
|
83
|
+
try:
|
|
84
|
+
scope = normalise_scope(requested_scope)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise HTTPException(400, str(exc)) from exc
|
|
87
|
+
decision = "explicit"
|
|
88
|
+
elif preserve_existing and existing and existing.get("scope"):
|
|
89
|
+
scope = str(existing["scope"]).strip().lower() or derive_default_scope(
|
|
90
|
+
source_hint, evidence_ids, store
|
|
91
|
+
)
|
|
92
|
+
decision = "preserved-existing"
|
|
93
|
+
elif cfg["enabled"] and classification.auto_global:
|
|
94
|
+
scope = "global"
|
|
95
|
+
decision = "auto-global"
|
|
96
|
+
else:
|
|
97
|
+
scope = derive_default_scope(source_hint, evidence_ids, store)
|
|
98
|
+
decision = "default-source"
|
|
99
|
+
audit = build_scope_audit(
|
|
100
|
+
classification,
|
|
101
|
+
scope=scope,
|
|
102
|
+
decision=decision,
|
|
103
|
+
source_hint=source_hint,
|
|
104
|
+
enabled=cfg["enabled"],
|
|
105
|
+
mode=cfg["mode"],
|
|
106
|
+
existing=existing,
|
|
107
|
+
)
|
|
108
|
+
return scope, audit
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
|
|
112
|
+
"""Mount every route in this bucket onto ``app``.
|
|
113
|
+
|
|
114
|
+
``store`` and ``scheduler`` are captured in the route closures so
|
|
115
|
+
the function bodies stay byte-identical to the pre-split layout.
|
|
116
|
+
"""
|
|
117
|
+
@app.post("/api/contradictions/resolve")
|
|
118
|
+
def resolve_contradiction(a: str, b: str, action: str = "ignore",
|
|
119
|
+
keep: str | None = None):
|
|
120
|
+
"""Resolve a contradiction pair surfaced on the dashboard.
|
|
121
|
+
|
|
122
|
+
``action``:
|
|
123
|
+
- ``ignore`` — hide this pair from future pulses (default)
|
|
124
|
+
- ``merge`` — fuse the two into one memory (winner keeps its
|
|
125
|
+
row, loser's text is appended, the loser is deleted)
|
|
126
|
+
- ``keepA`` — explicitly delete side B
|
|
127
|
+
- ``keepB`` — explicitly delete side A
|
|
128
|
+
"""
|
|
129
|
+
a_id = str(a or "").strip()
|
|
130
|
+
b_id = str(b or "").strip()
|
|
131
|
+
if not a_id or not b_id or a_id == b_id:
|
|
132
|
+
raise HTTPException(400, "need two distinct memory ids")
|
|
133
|
+
action_raw = action or "ignore"
|
|
134
|
+
action = action_raw.lower()
|
|
135
|
+
result = {"ok": True, "action": action_raw, "deleted": []}
|
|
136
|
+
# Always remember the ignore so the pair doesn't reappear next refresh
|
|
137
|
+
store.ignore_contradiction(a_id, b_id)
|
|
138
|
+
if action == "ignore":
|
|
139
|
+
# Record a soft "down" on both so the consolidator weighs them down.
|
|
140
|
+
try:
|
|
141
|
+
store.record_signal(a_id, positive=False)
|
|
142
|
+
store.record_signal(b_id, positive=False)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
elif action == "merge":
|
|
146
|
+
# True fusion: keep the higher-scored side as the row that
|
|
147
|
+
# survives, append the loser's text to it (de-duplicated),
|
|
148
|
+
# bump importance/score to the max of the two, then delete
|
|
149
|
+
# the loser in the same transaction. ``merge_memories``
|
|
150
|
+
# also writes the pair into ``contradiction_ignored`` so
|
|
151
|
+
# the pulse does not surface it again.
|
|
152
|
+
try:
|
|
153
|
+
merge_info = store.merge_memories(a_id, b_id)
|
|
154
|
+
except ValueError as e:
|
|
155
|
+
raise HTTPException(400, str(e))
|
|
156
|
+
if not merge_info.get("merged"):
|
|
157
|
+
# One of the sides was already gone; nothing to merge.
|
|
158
|
+
result["merged"] = False
|
|
159
|
+
result["loser"] = merge_info.get("lost")
|
|
160
|
+
if merge_info.get("reason") == "neither_exists":
|
|
161
|
+
raise HTTPException(404, "neither memory exists")
|
|
162
|
+
else:
|
|
163
|
+
result["merged"] = True
|
|
164
|
+
result["winner"] = merge_info["kept"]
|
|
165
|
+
result["loser"] = merge_info["lost"]
|
|
166
|
+
result["appended"] = merge_info.get("appended", False)
|
|
167
|
+
result["new_length"] = merge_info.get("new_length", 0)
|
|
168
|
+
try:
|
|
169
|
+
store.record_signal(merge_info["kept"], positive=True)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
elif action in ("keepa", "keepb"):
|
|
173
|
+
loser = b_id if action == "keepa" else a_id
|
|
174
|
+
store.delete_memory(loser)
|
|
175
|
+
result["deleted"].append({"id": loser, "kept": (a_id if loser == b_id else b_id)})
|
|
176
|
+
else:
|
|
177
|
+
raise HTTPException(400, f"unknown action {action!r}")
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# /api/v1/memories — Universal Agent Memory API
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Designed for any Agent (Codex / Claude / Hermes / OpenClaw /
|
|
184
|
+
# LangChain / AutoGPT / a custom internal bot) to remember facts,
|
|
185
|
+
# recall context, give feedback, and forget — without coupling to
|
|
186
|
+
# the in-process store or the existing /api/* surface. Idempotent
|
|
187
|
+
# writes are keyed on (agent_id, user_id, external_id) so a retry
|
|
188
|
+
# of the same tool call updates the row in place.
|
|
189
|
+
#
|
|
190
|
+
# This block is intentionally placed alongside the legacy
|
|
191
|
+
# ``/api/memories`` routes for discoverability — the OpenAPI doc
|
|
192
|
+
# at ``/openapi.json`` lists every route side-by-side.
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/api/v1/wiki/versions")
|
|
197
|
+
def v1_wiki_versions(page_id: str | None = None,
|
|
198
|
+
branch_tag: str | None = None, limit: int = 200):
|
|
199
|
+
rows = store.list_wiki_versions(
|
|
200
|
+
page_id=page_id, branch_tag=branch_tag, limit=limit,
|
|
201
|
+
)
|
|
202
|
+
return {"rows": rows, "count": len(rows)}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.get("/api/wiki/contradictions")
|
|
206
|
+
def wiki_contradictions():
|
|
207
|
+
"""Return every wiki page that has at least one partner in
|
|
208
|
+
its ``contradicting_ids`` list, with the partner summaries
|
|
209
|
+
inline. The UI uses this to render the "needs review" list
|
|
210
|
+
on the Wiki page.
|
|
211
|
+
"""
|
|
212
|
+
from ...jobs.contradiction import list_contradictions
|
|
213
|
+
rows = list_contradictions(store)
|
|
214
|
+
return {"count": len(rows), "items": rows}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.post("/api/wiki/contradictions/scan")
|
|
218
|
+
def wiki_contradictions_scan(threshold: float = 0.45):
|
|
219
|
+
"""Re-scan every wiki page for contradictions. Cheap for
|
|
220
|
+
<1000 pages; intended for manual triggers from the UI after
|
|
221
|
+
the user adds or edits pages."""
|
|
222
|
+
from ...jobs.contradiction import scan_all
|
|
223
|
+
return scan_all(store, threshold=threshold)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.post("/api/wiki/{page_id}/merge")
|
|
227
|
+
def wiki_merge(page_id: str, body: dict):
|
|
228
|
+
"""Merge ``page_id`` with the page in ``body.loser_id``.
|
|
229
|
+
|
|
230
|
+
Body keys:
|
|
231
|
+
|
|
232
|
+
* ``loser_id`` (str) — the page to dissolve into ``page_id``
|
|
233
|
+
* ``merged_body`` (str, optional)
|
|
234
|
+
* ``merged_summary`` (str, optional)
|
|
235
|
+
* ``merged_key_facts`` (list[str], optional)
|
|
236
|
+
* ``merged_importance`` (float, optional)
|
|
237
|
+
* ``merged_tags`` (list[str], optional)
|
|
238
|
+
|
|
239
|
+
The winner keeps its id; the loser is deleted. The winner's
|
|
240
|
+
``contradicting_ids`` column is cleared because the conflict
|
|
241
|
+
is resolved.
|
|
242
|
+
"""
|
|
243
|
+
loser_id = body.get("loser_id") or body.get("loserId")
|
|
244
|
+
if not loser_id:
|
|
245
|
+
raise HTTPException(400, "loser_id is required")
|
|
246
|
+
return store.merge_wiki_pages(
|
|
247
|
+
winner_id=page_id,
|
|
248
|
+
loser_id=loser_id,
|
|
249
|
+
merged_body=body.get("merged_body") or body.get("mergedBody"),
|
|
250
|
+
merged_summary=body.get("merged_summary") or body.get("mergedSummary"),
|
|
251
|
+
merged_key_facts=body.get("merged_key_facts") or body.get("mergedKeyFacts"),
|
|
252
|
+
merged_importance=body.get("merged_importance") or body.get("mergedImportance"),
|
|
253
|
+
merged_tags=body.get("merged_tags") or body.get("mergedTags"),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.post("/api/wiki/{page_id}/resolve")
|
|
258
|
+
def wiki_resolve(page_id: str):
|
|
259
|
+
"""Clear a page's ``contradicting_ids`` so it disappears
|
|
260
|
+
from the contradiction list. Use when the user inspects and
|
|
261
|
+
decides the two pages are not actually in conflict.
|
|
262
|
+
"""
|
|
263
|
+
ok = store.resolve_contradiction(page_id)
|
|
264
|
+
return {"ok": bool(ok)}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.get("/api/wiki/export")
|
|
268
|
+
def wiki_export(format: str = "markdown", limit: int = 500,
|
|
269
|
+
q: str | None = None):
|
|
270
|
+
"""Dump all (or matched) wiki pages for backup / sharing.
|
|
271
|
+
|
|
272
|
+
``format``:
|
|
273
|
+
- ``markdown`` (default) — single markdown document.
|
|
274
|
+
- ``json`` — round-trippable JSON object
|
|
275
|
+
(``{format, count, pages}``)
|
|
276
|
+
that the import endpoint can
|
|
277
|
+
re-ingest without loss.
|
|
278
|
+
``q`` (optional) filters by substring match against title/body.
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
pages = store.list_wiki_pages(limit=limit, query=q)
|
|
282
|
+
except Exception:
|
|
283
|
+
pages = []
|
|
284
|
+
fmt = (format or "markdown").lower()
|
|
285
|
+
if fmt == "json":
|
|
286
|
+
return {
|
|
287
|
+
"format": "json",
|
|
288
|
+
"count": len(pages),
|
|
289
|
+
"pages": [dict(p) for p in pages],
|
|
290
|
+
}
|
|
291
|
+
out_lines = ["# Loop Memory — Distilled Knowledge", ""]
|
|
292
|
+
out_lines.append(f"_Exported {len(pages)} wiki pages._")
|
|
293
|
+
out_lines.append("")
|
|
294
|
+
for p in pages:
|
|
295
|
+
title = _export_safe_segment(p.get("title"), fallback="untitled", kind="title")
|
|
296
|
+
body = _export_safe_segment(p.get("body"), fallback="", kind="body")
|
|
297
|
+
summary = _export_safe_segment(p.get("summary"), fallback="", kind="summary")
|
|
298
|
+
# Anchor the body on its own paragraph so a malicious body
|
|
299
|
+
# starting with "# " or "## " can't merge with the heading
|
|
300
|
+
# line and steal the title's section.
|
|
301
|
+
out_lines.append(f"## {title}")
|
|
302
|
+
out_lines.append("")
|
|
303
|
+
if summary and summary != title:
|
|
304
|
+
out_lines.append(f"> {summary}")
|
|
305
|
+
out_lines.append("")
|
|
306
|
+
out_lines.append(body)
|
|
307
|
+
if body and not body.endswith("\n"):
|
|
308
|
+
out_lines.append("")
|
|
309
|
+
out_lines.append("")
|
|
310
|
+
evidence = p.get("evidence_ids") or []
|
|
311
|
+
if evidence:
|
|
312
|
+
ev = ", ".join(str(x) for x in evidence[:6])
|
|
313
|
+
out_lines.append(f"<sub>evidence: {ev}… ({len(evidence)} sources)</sub>")
|
|
314
|
+
out_lines.append("")
|
|
315
|
+
return {"format": "markdown", "count": len(pages), "markdown": "\n".join(out_lines)}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.post("/api/wiki/import")
|
|
319
|
+
def wiki_import(body: dict):
|
|
320
|
+
"""Bulk-upsert wiki pages from JSON or Markdown.
|
|
321
|
+
|
|
322
|
+
Body shape (one of):
|
|
323
|
+
- ``{"format": "json", "pages": [...]}`` — round-trip from
|
|
324
|
+
``/api/wiki/export?format=json``. Each entry needs at
|
|
325
|
+
minimum ``slug`` (or ``title``), ``title`` and ``body``.
|
|
326
|
+
- ``{"format": "markdown", "markdown": "..."}`` — text dump
|
|
327
|
+
with ``## title`` sections. The slug is derived from the
|
|
328
|
+
title (lowercase, spaces → dashes, ascii-safe).
|
|
329
|
+
|
|
330
|
+
Returns ``{created, updated, skipped, errors}``. Pages whose
|
|
331
|
+
slug matches an existing one are updated; new slugs are
|
|
332
|
+
created. Malformed entries are skipped (counted in
|
|
333
|
+
``skipped``) and the first error is reported.
|
|
334
|
+
"""
|
|
335
|
+
if not isinstance(body, dict):
|
|
336
|
+
raise HTTPException(400, "body must be an object")
|
|
337
|
+
fmt = (body.get("format") or "json").lower()
|
|
338
|
+
entries: list[dict] = []
|
|
339
|
+
errors: list[str] = []
|
|
340
|
+
|
|
341
|
+
def _slugify(title: str, fallback: str = "") -> str:
|
|
342
|
+
import re as _re
|
|
343
|
+
s = (title or fallback or "").strip().lower()
|
|
344
|
+
s = _re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", s)
|
|
345
|
+
s = s.strip("-")[:80]
|
|
346
|
+
return s or "untitled"
|
|
347
|
+
|
|
348
|
+
if fmt == "json":
|
|
349
|
+
raw = body.get("pages")
|
|
350
|
+
if not isinstance(raw, list):
|
|
351
|
+
raise HTTPException(400, "pages must be a list")
|
|
352
|
+
# Audit O7: cap payload size so a single import can't trigger
|
|
353
|
+
# an unbounded FTS5 rebuild + graph build.
|
|
354
|
+
if len(raw) > 5000:
|
|
355
|
+
raise HTTPException(
|
|
356
|
+
413,
|
|
357
|
+
f"too many pages in one request ({len(raw)} > 5000); "
|
|
358
|
+
"split into chunks or stream via /api/admin/ingest",
|
|
359
|
+
)
|
|
360
|
+
for i, item in enumerate(raw):
|
|
361
|
+
if not isinstance(item, dict):
|
|
362
|
+
errors.append(f"#{i}: not an object"); continue
|
|
363
|
+
title = (item.get("title") or "").lstrip("\ufeff").strip()
|
|
364
|
+
body_text = (item.get("body") or "").lstrip("\ufeff").strip()
|
|
365
|
+
slug = (item.get("slug") or "").strip()
|
|
366
|
+
if not title or not body_text:
|
|
367
|
+
errors.append(f"#{i}: missing title/body"); continue
|
|
368
|
+
if not slug:
|
|
369
|
+
slug = _slugify(title)
|
|
370
|
+
entries.append({
|
|
371
|
+
"slug": slug.lower().replace(" ", "-")[:80],
|
|
372
|
+
"title": title,
|
|
373
|
+
"body": body_text,
|
|
374
|
+
"summary": (item.get("summary") or "").strip(),
|
|
375
|
+
"tags": item.get("tags") or [],
|
|
376
|
+
"importance": float(item.get("importance") or 0.5),
|
|
377
|
+
"evidence_ids": item.get("evidence_ids") or [],
|
|
378
|
+
"requested_scope": item.get("scope"),
|
|
379
|
+
"source_hint": item.get("source") or item.get("client") or item.get("agent"),
|
|
380
|
+
})
|
|
381
|
+
elif fmt == "markdown":
|
|
382
|
+
md = (body.get("markdown") or "")
|
|
383
|
+
# Audit O13: strip UTF-8 BOM and normalise CRLF before
|
|
384
|
+
# splitting on ``\n(?=##\s)`` so a Windows-pasted doc
|
|
385
|
+
# round-trips the same as a Unix-pasted one.
|
|
386
|
+
md = md.lstrip("\ufeff").replace("\r\n", "\n")
|
|
387
|
+
# Cap raw markdown length -- 5 MB is the practical upper
|
|
388
|
+
# bound before the regex split hits pathological O(n^2)
|
|
389
|
+
# behaviour on inputs with millions of ``\n`` chars.
|
|
390
|
+
if len(md) > 5 * 1024 * 1024:
|
|
391
|
+
raise HTTPException(
|
|
392
|
+
413,
|
|
393
|
+
f"markdown body too large ({len(md)} > 5MB); "
|
|
394
|
+
"split into chunks or use JSON format",
|
|
395
|
+
)
|
|
396
|
+
md = md.strip()
|
|
397
|
+
if not md:
|
|
398
|
+
raise HTTPException(400, "markdown is empty")
|
|
399
|
+
# Split on "## " headings. Each chunk becomes a page.
|
|
400
|
+
import re as _re
|
|
401
|
+
chunks = _re.split(r"\n(?=##\s)", md)
|
|
402
|
+
for chunk in chunks:
|
|
403
|
+
chunk = chunk.strip()
|
|
404
|
+
if not chunk.startswith("## "):
|
|
405
|
+
continue
|
|
406
|
+
# Drop leading "## "
|
|
407
|
+
lines = chunk.splitlines()
|
|
408
|
+
title = lines[0][3:].strip()
|
|
409
|
+
rest = "\n".join(lines[1:]).strip()
|
|
410
|
+
# Optional ">" summary at the start
|
|
411
|
+
summary = ""
|
|
412
|
+
if rest.startswith(">"):
|
|
413
|
+
maybe = rest.split("\n", 1)
|
|
414
|
+
summary = maybe[0].lstrip("> ").strip()
|
|
415
|
+
rest = maybe[1].strip() if len(maybe) > 1 else ""
|
|
416
|
+
if not title or not rest:
|
|
417
|
+
continue
|
|
418
|
+
entries.append({
|
|
419
|
+
"slug": _slugify(title),
|
|
420
|
+
"title": title,
|
|
421
|
+
"body": rest,
|
|
422
|
+
"summary": summary,
|
|
423
|
+
"tags": [],
|
|
424
|
+
"importance": 0.5,
|
|
425
|
+
"evidence_ids": [],
|
|
426
|
+
"requested_scope": body.get("scope"),
|
|
427
|
+
"source_hint": body.get("source") or body.get("client"),
|
|
428
|
+
})
|
|
429
|
+
else:
|
|
430
|
+
raise HTTPException(400, f"unknown format {fmt!r}; use 'json' or 'markdown'")
|
|
431
|
+
|
|
432
|
+
created = 0
|
|
433
|
+
updated = 0
|
|
434
|
+
for e in entries:
|
|
435
|
+
try:
|
|
436
|
+
existing = store.get_wiki_page_by_slug(e["slug"])
|
|
437
|
+
tags = e.get("tags") or []
|
|
438
|
+
if not isinstance(tags, list):
|
|
439
|
+
tags = [tags]
|
|
440
|
+
evidence_ids = e.get("evidence_ids") or []
|
|
441
|
+
if not isinstance(evidence_ids, list):
|
|
442
|
+
evidence_ids = [evidence_ids]
|
|
443
|
+
scope, auto_classification = _resolve_wiki_scope(
|
|
444
|
+
store,
|
|
445
|
+
title=e["title"],
|
|
446
|
+
body=e["body"],
|
|
447
|
+
summary=e.get("summary") or "",
|
|
448
|
+
tags=tags,
|
|
449
|
+
evidence_ids=evidence_ids,
|
|
450
|
+
requested_scope=e.get("requested_scope"),
|
|
451
|
+
source_hint=e.get("source_hint"),
|
|
452
|
+
existing=existing,
|
|
453
|
+
preserve_existing=existing is not None and e.get("requested_scope") is None,
|
|
454
|
+
)
|
|
455
|
+
store.upsert_wiki_page(
|
|
456
|
+
slug=e["slug"],
|
|
457
|
+
title=e["title"],
|
|
458
|
+
body=e["body"],
|
|
459
|
+
summary=e.get("summary") or "",
|
|
460
|
+
tags=tags,
|
|
461
|
+
importance=e.get("importance") or 0.5,
|
|
462
|
+
evidence_ids=evidence_ids,
|
|
463
|
+
scope=scope,
|
|
464
|
+
auto_classification=auto_classification,
|
|
465
|
+
)
|
|
466
|
+
if existing: updated += 1
|
|
467
|
+
else: created += 1
|
|
468
|
+
except Exception as ex:
|
|
469
|
+
errors.append(f"{e['slug']}: {ex}")
|
|
470
|
+
return {
|
|
471
|
+
"created": created,
|
|
472
|
+
"updated": updated,
|
|
473
|
+
"skipped": len(errors),
|
|
474
|
+
"errors": errors[:10],
|
|
475
|
+
"total": len(entries),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@app.get("/api/wiki/{page_id}/export")
|
|
480
|
+
def wiki_page_export(page_id: str, format: str = "markdown"):
|
|
481
|
+
"""Export a single wiki page as a context-block ready to paste
|
|
482
|
+
into another LLM client as a system prompt."""
|
|
483
|
+
page = store.get_wiki_page(page_id)
|
|
484
|
+
if page is None:
|
|
485
|
+
raise HTTPException(404, "wiki page not found")
|
|
486
|
+
title = _export_safe_segment(page.get("title"), fallback="untitled", kind="title")
|
|
487
|
+
body = _export_safe_segment(page.get("body"), fallback="", kind="body")
|
|
488
|
+
summary = _export_safe_segment(page.get("summary"), fallback=title, kind="summary")
|
|
489
|
+
md = f"# {title}\n\n{body}"
|
|
490
|
+
ctx = (
|
|
491
|
+
"[Distilled knowledge — use as background context]\n"
|
|
492
|
+
f"Title: {title}\n"
|
|
493
|
+
f"Summary: {summary}\n\n"
|
|
494
|
+
f"{body}\n"
|
|
495
|
+
)
|
|
496
|
+
return {"format": format, "markdown": md, "context": ctx}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@app.post("/api/wiki/ask")
|
|
500
|
+
def wiki_ask(q: str, limit: int = 5):
|
|
501
|
+
"""Recall top wiki pages by keyword match and return a ready-to-paste
|
|
502
|
+
context block plus the matching memory ids."""
|
|
503
|
+
ql = (q or "").lower().strip()
|
|
504
|
+
if not ql:
|
|
505
|
+
raise HTTPException(400, "q query param required")
|
|
506
|
+
try:
|
|
507
|
+
pages = store.list_wiki_pages(limit=200)
|
|
508
|
+
except Exception:
|
|
509
|
+
pages = []
|
|
510
|
+
scored = []
|
|
511
|
+
for p in pages:
|
|
512
|
+
t = (p.get("title") or "").lower()
|
|
513
|
+
b = (p.get("body") or "").lower()
|
|
514
|
+
sm = (p.get("summary") or "").lower()
|
|
515
|
+
score = 0
|
|
516
|
+
for token in ql.split():
|
|
517
|
+
score += t.count(token) * 3 + sm.count(token) * 2 + b.count(token) * 1
|
|
518
|
+
if score > 0:
|
|
519
|
+
scored.append((score, p))
|
|
520
|
+
scored.sort(key=lambda x: -x[0])
|
|
521
|
+
top = scored[:limit]
|
|
522
|
+
if not top:
|
|
523
|
+
return {"q": q, "matches": [], "context": f"(no wiki pages matched {q!r})"}
|
|
524
|
+
ctx_lines = [f"[Distilled knowledge for: {q}]"]
|
|
525
|
+
for _s, p in top:
|
|
526
|
+
_t = _export_safe_segment(p.get("title"), fallback="untitled", kind="title")
|
|
527
|
+
_s = _export_safe_segment(p.get("summary"), fallback="", kind="summary")
|
|
528
|
+
_b = _export_safe_segment(p.get("body"), fallback="", kind="body")
|
|
529
|
+
ctx_lines.append(
|
|
530
|
+
f"\n## {_t}\n"
|
|
531
|
+
f"{_s}\n"
|
|
532
|
+
f"{_b[:800]}"
|
|
533
|
+
)
|
|
534
|
+
return {
|
|
535
|
+
"q": q,
|
|
536
|
+
"matches": [{"id": p["id"], "title": p.get("title"), "score": s} for s, p in top],
|
|
537
|
+
"context": "\n".join(ctx_lines),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@app.get("/api/wiki")
|
|
542
|
+
def wiki_list(limit: int = 200, min_importance: float = 0.0,
|
|
543
|
+
q: str | None = None):
|
|
544
|
+
return store.list_wiki_pages(
|
|
545
|
+
limit=limit,
|
|
546
|
+
min_importance=min_importance if min_importance > 0 else None,
|
|
547
|
+
query=q,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.get("/api/wiki/{page_id}")
|
|
552
|
+
def wiki_get(page_id: str):
|
|
553
|
+
page = store.get_wiki_page(page_id)
|
|
554
|
+
if not page:
|
|
555
|
+
raise HTTPException(404, "wiki page not found")
|
|
556
|
+
return page
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@app.get("/api/wiki/{page_id}/classification-history")
|
|
560
|
+
def wiki_classification_history(page_id: str):
|
|
561
|
+
page = store.get_wiki_page(page_id)
|
|
562
|
+
if not page:
|
|
563
|
+
raise HTTPException(404, "wiki page not found")
|
|
564
|
+
current = page.get("auto_classification")
|
|
565
|
+
history = []
|
|
566
|
+
if isinstance(current, dict):
|
|
567
|
+
raw_history = current.get("history")
|
|
568
|
+
if isinstance(raw_history, list):
|
|
569
|
+
history.extend(item for item in raw_history if isinstance(item, dict))
|
|
570
|
+
history.append({key: value for key, value in current.items() if key != "history"})
|
|
571
|
+
return {
|
|
572
|
+
"page_id": page_id,
|
|
573
|
+
"scope": page.get("scope") or "global",
|
|
574
|
+
"history": history,
|
|
575
|
+
"current": current,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.post("/api/wiki/classify")
|
|
580
|
+
def wiki_classify(body: dict):
|
|
581
|
+
if not isinstance(body, dict):
|
|
582
|
+
raise HTTPException(400, "body must be an object")
|
|
583
|
+
title = str(body.get("title") or "").strip()
|
|
584
|
+
body_text = str(body.get("body") or "").strip()
|
|
585
|
+
summary = str(body.get("summary") or "").strip()
|
|
586
|
+
tags = body.get("tags") or []
|
|
587
|
+
if not isinstance(tags, list):
|
|
588
|
+
tags = [tags]
|
|
589
|
+
evidence_ids = body.get("evidence_ids") or []
|
|
590
|
+
if not isinstance(evidence_ids, list):
|
|
591
|
+
evidence_ids = [evidence_ids]
|
|
592
|
+
scope, audit = _resolve_wiki_scope(
|
|
593
|
+
store,
|
|
594
|
+
title=title,
|
|
595
|
+
body=body_text,
|
|
596
|
+
summary=summary,
|
|
597
|
+
tags=tags,
|
|
598
|
+
evidence_ids=evidence_ids,
|
|
599
|
+
requested_scope=body.get("scope"),
|
|
600
|
+
source_hint=_wiki_source_hint(body),
|
|
601
|
+
)
|
|
602
|
+
return {
|
|
603
|
+
"classification": audit,
|
|
604
|
+
"recommended_scope": scope,
|
|
605
|
+
"scope": scope,
|
|
606
|
+
"config": _wiki_scope_config(store),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@app.post("/api/wiki")
|
|
611
|
+
def wiki_create(body: dict):
|
|
612
|
+
if not isinstance(body, dict):
|
|
613
|
+
raise HTTPException(400, "body must be an object")
|
|
614
|
+
slug = (body.get("slug") or "").strip()
|
|
615
|
+
title = (body.get("title") or "").strip()
|
|
616
|
+
body_text = (body.get("body") or "").strip()
|
|
617
|
+
if not slug or not title or not body_text:
|
|
618
|
+
raise HTTPException(400, "slug, title and body are required")
|
|
619
|
+
# Don't let manual creates clobber a consolidated page for the
|
|
620
|
+
# same slug; if it already exists, route them to PUT instead.
|
|
621
|
+
if store.get_wiki_page_by_slug(slug):
|
|
622
|
+
raise HTTPException(409, "wiki page with this slug already exists")
|
|
623
|
+
tags = body.get("tags") or []
|
|
624
|
+
if not isinstance(tags, list):
|
|
625
|
+
tags = [tags]
|
|
626
|
+
evidence_ids = body.get("evidence_ids") or []
|
|
627
|
+
if not isinstance(evidence_ids, list):
|
|
628
|
+
evidence_ids = [evidence_ids]
|
|
629
|
+
normalised, auto_classification = _resolve_wiki_scope(
|
|
630
|
+
store,
|
|
631
|
+
title=title,
|
|
632
|
+
body=body_text,
|
|
633
|
+
summary=str(body.get("summary") or "").strip(),
|
|
634
|
+
tags=tags,
|
|
635
|
+
evidence_ids=evidence_ids,
|
|
636
|
+
requested_scope=body.get("scope"),
|
|
637
|
+
source_hint=_wiki_source_hint(body),
|
|
638
|
+
)
|
|
639
|
+
return store.upsert_wiki_page(
|
|
640
|
+
slug=slug.lower().replace(" ", "-")[:80],
|
|
641
|
+
title=title,
|
|
642
|
+
body=body_text,
|
|
643
|
+
summary=(body.get("summary") or "").strip(),
|
|
644
|
+
tags=tags,
|
|
645
|
+
importance=float(body.get("importance") or 0.5),
|
|
646
|
+
evidence_ids=evidence_ids,
|
|
647
|
+
scope=normalised,
|
|
648
|
+
auto_classification=auto_classification,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@app.put("/api/wiki/{page_id}")
|
|
653
|
+
def wiki_update(page_id: str, body: dict):
|
|
654
|
+
existing = store.get_wiki_page(page_id)
|
|
655
|
+
if not existing:
|
|
656
|
+
raise HTTPException(404, "wiki page not found")
|
|
657
|
+
if not isinstance(body, dict):
|
|
658
|
+
raise HTTPException(400, "body must be an object")
|
|
659
|
+
slug = (body.get("slug") or existing["slug"]).strip()
|
|
660
|
+
title = (body.get("title") or existing["title"]).strip()
|
|
661
|
+
body_text = (body.get("body") or existing["body"]).strip()
|
|
662
|
+
summary = (body.get("summary") or existing.get("summary") or "").strip()
|
|
663
|
+
tags = body.get("tags") if "tags" in body else existing.get("tags") or []
|
|
664
|
+
if not isinstance(tags, list):
|
|
665
|
+
tags = [tags]
|
|
666
|
+
evidence_ids = body.get("evidence_ids") if "evidence_ids" in body else existing.get("evidence_ids") or []
|
|
667
|
+
if not isinstance(evidence_ids, list):
|
|
668
|
+
evidence_ids = [evidence_ids]
|
|
669
|
+
scope_val, auto_classification = _resolve_wiki_scope(
|
|
670
|
+
store,
|
|
671
|
+
title=title,
|
|
672
|
+
body=body_text,
|
|
673
|
+
summary=summary,
|
|
674
|
+
tags=tags,
|
|
675
|
+
evidence_ids=evidence_ids,
|
|
676
|
+
requested_scope=body.get("scope") if "scope" in body else None,
|
|
677
|
+
source_hint=_wiki_source_hint(body),
|
|
678
|
+
existing=existing,
|
|
679
|
+
preserve_existing="scope" not in body,
|
|
680
|
+
)
|
|
681
|
+
return store.upsert_wiki_page(
|
|
682
|
+
slug=slug.lower().replace(" ", "-")[:80],
|
|
683
|
+
title=title,
|
|
684
|
+
body=body_text,
|
|
685
|
+
summary=summary,
|
|
686
|
+
tags=tags,
|
|
687
|
+
importance=float(body.get("importance")
|
|
688
|
+
if "importance" in body else existing.get("importance") or 0.5),
|
|
689
|
+
evidence_ids=evidence_ids,
|
|
690
|
+
scope=scope_val,
|
|
691
|
+
auto_classification=auto_classification,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@app.delete("/api/wiki/{page_id}")
|
|
696
|
+
def wiki_delete(page_id: str):
|
|
697
|
+
ok = store.delete_wiki_page(page_id)
|
|
698
|
+
if not ok:
|
|
699
|
+
raise HTTPException(404, "wiki page not found")
|
|
700
|
+
return {"ok": True}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@app.post("/api/wiki/bulk-scope")
|
|
704
|
+
def wiki_bulk_scope(body: dict):
|
|
705
|
+
"""Set the ``scope`` field on multiple wiki pages in one shot.
|
|
706
|
+
|
|
707
|
+
Drives the master 全局 toggle in the Wiki tab. When the user
|
|
708
|
+
flips the toolbar toggle ON, we bulk-update every page to
|
|
709
|
+
``scope="global"``. When they flip a single page OFF while the
|
|
710
|
+
master is ON, the master auto-flips OFF — but this endpoint
|
|
711
|
+
is also exposed so the front-end can keep the bulk fast-path
|
|
712
|
+
(single round-trip) without iterating over PUTs.
|
|
713
|
+
|
|
714
|
+
Body:
|
|
715
|
+
``scope`` (str, required) — the new scope value. Allowed
|
|
716
|
+
tokens: ``global``, ``codex``, ``claude``, ``hermes``,
|
|
717
|
+
``openclaw``. Comma-separated for per-client lists.
|
|
718
|
+
``page_ids`` (list[str], optional) — when provided, only
|
|
719
|
+
these pages are updated. When omitted, ALL pages are
|
|
720
|
+
updated. Use the explicit list for the "user
|
|
721
|
+
manually toggled one off" path, omit for the master
|
|
722
|
+
toggle's bulk-ON case.
|
|
723
|
+
"""
|
|
724
|
+
if not isinstance(body, dict):
|
|
725
|
+
raise HTTPException(400, "body must be an object")
|
|
726
|
+
raw_scope = (body.get("scope") or "").strip().lower()
|
|
727
|
+
if not raw_scope:
|
|
728
|
+
raise HTTPException(400, "scope is required")
|
|
729
|
+
from ...wiki.scope import normalise_scope
|
|
730
|
+
try:
|
|
731
|
+
normalised = normalise_scope(raw_scope)
|
|
732
|
+
except ValueError as exc:
|
|
733
|
+
raise HTTPException(400, str(exc)) from exc
|
|
734
|
+
page_ids = body.get("page_ids")
|
|
735
|
+
if page_ids is not None:
|
|
736
|
+
if not isinstance(page_ids, list) or not all(
|
|
737
|
+
isinstance(x, str) for x in page_ids
|
|
738
|
+
):
|
|
739
|
+
raise HTTPException(400, "page_ids must be a list of strings")
|
|
740
|
+
targets = page_ids
|
|
741
|
+
else:
|
|
742
|
+
# No explicit list = apply to every page. Used by the
|
|
743
|
+
# master "全局" toggle's bulk-ON path.
|
|
744
|
+
targets = [p["id"] for p in store.list_wiki_pages(limit=10000)]
|
|
745
|
+
updated = 0
|
|
746
|
+
for pid in targets:
|
|
747
|
+
existing = store.get_wiki_page(pid)
|
|
748
|
+
if not existing:
|
|
749
|
+
continue
|
|
750
|
+
# Avoid bumping updated_at / version when scope is
|
|
751
|
+
# already at the target value.
|
|
752
|
+
if (existing.get("scope") or "global") == normalised:
|
|
753
|
+
updated += 1
|
|
754
|
+
continue
|
|
755
|
+
from ...wiki.scope import build_scope_audit
|
|
756
|
+
scope_cfg = _wiki_scope_config(store)
|
|
757
|
+
manual_audit = build_scope_audit(
|
|
758
|
+
existing.get("auto_classification") or {},
|
|
759
|
+
scope=normalised,
|
|
760
|
+
decision="manual-override",
|
|
761
|
+
enabled=scope_cfg["enabled"],
|
|
762
|
+
mode=scope_cfg["mode"],
|
|
763
|
+
existing=existing,
|
|
764
|
+
)
|
|
765
|
+
store.upsert_wiki_page(
|
|
766
|
+
slug=existing["slug"],
|
|
767
|
+
title=existing["title"],
|
|
768
|
+
body=existing["body"],
|
|
769
|
+
summary=existing.get("summary") or "",
|
|
770
|
+
tags=existing.get("tags") or [],
|
|
771
|
+
importance=existing.get("importance") or 0.5,
|
|
772
|
+
evidence_ids=existing.get("evidence_ids") or [],
|
|
773
|
+
run_id=existing.get("run_id"),
|
|
774
|
+
scope=normalised,
|
|
775
|
+
auto_classification=manual_audit,
|
|
776
|
+
)
|
|
777
|
+
updated += 1
|
|
778
|
+
return {"ok": True, "updated": updated, "scope": normalised}
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@app.post("/api/wiki/{page_id}/resummarize")
|
|
782
|
+
def wiki_resummarize(page_id: str):
|
|
783
|
+
"""Re-run the consolidator's wiki step targeting just this page.
|
|
784
|
+
|
|
785
|
+
Useful when the user has edited a page manually and wants the
|
|
786
|
+
next consolidation pass to refresh its evidence list, or when
|
|
787
|
+
they want to regenerate a single page without touching others.
|
|
788
|
+
"""
|
|
789
|
+
existing = store.get_wiki_page(page_id)
|
|
790
|
+
if not existing:
|
|
791
|
+
raise HTTPException(404, "wiki page not found")
|
|
792
|
+
from ...llm.providers import build_provider, default_config, validate_config
|
|
793
|
+
cfg, _ = validate_config(store.get_setting("llm_consolidator", default_config()))
|
|
794
|
+
provider = build_provider(cfg)
|
|
795
|
+
from ...jobs.llm_consolidate import LLMConsolidator
|
|
796
|
+
cons = LLMConsolidator(store, provider, cfg.get("behaviour") or {})
|
|
797
|
+
# Synthesize on the memories currently pointed at by evidence_ids.
|
|
798
|
+
evidence = existing.get("evidence_ids") or []
|
|
799
|
+
memories = []
|
|
800
|
+
for mid in evidence:
|
|
801
|
+
m = store.get_memory(mid)
|
|
802
|
+
if m is not None:
|
|
803
|
+
memories.append(m)
|
|
804
|
+
if not memories:
|
|
805
|
+
raise HTTPException(400,
|
|
806
|
+
"no evidence memories for this page; rerun consolidation to refresh")
|
|
807
|
+
result = cons._synth_wiki_pages(
|
|
808
|
+
memories=memories, pre_drop=set(), cfg=cfg.get("behaviour") or {},
|
|
809
|
+
stats=type("S", (), {"notes": [], "llm_calls": 0})(),
|
|
810
|
+
run_id=None,
|
|
811
|
+
)
|
|
812
|
+
return {"ok": True, **result}
|