Coverage for astrocyte/pipeline/wiki_incremental.py: 89%

90 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""M14.2: Karpathy update-affected-wikis. 

2 

3When a new document is retained, identify existing wiki pages whose 

4provenance entities overlap with the new document's entities, then run 

5an LLM update pass per affected wiki with the new content folded in. 

6Models Karpathy's LLM-wiki spec ("update-affected-wikis" op) and 

7Hindsight's observation-consolidation freshness pattern — both share 

8the shape: detect what changed, ripple to dependent compiled artefacts. 

9 

10Why this is the next M14 lever after M14.6 revert: 

11 

12The post-M14.7-revert decision was: stop auto-injecting preferences 

13into the answerer's context (Hindsight design); double down on the 

14consolidation pipeline so retrieved candidates ARE fresh. M14.2 

15attacks that directly: 

16 

171. **knowledge-update** category on LME (current 5/5 at top_20 — already 

18 strong, but only because wikis happen to recompile from scratch each 

19 bench run). Real users add documents incrementally; stale wikis 

20 would otherwise hurt knowledge-update over time. 

212. **multi-session** on LME — questions that span sessions need 

22 cross-document wiki updates to surface coherent state. Currently 

23 wikis are document-local; M14.2 enables document-spanning updates. 

243. **open-domain** + **temporal** on LoCoMo — both benefit from 

25 freshness signal in the wiki content (e.g. "user now lives in 

26 Berlin (previously Paris)" inline in the relevant wiki). 

27 

28Algorithm: 

29 

30:: 

31 

32 on_document_retained(doc): 

33 affected = find_affected_wikis(doc.entities) # entity overlap 

34 for wiki in affected: 

35 ctx = (wiki.content, new_doc.relevant_sections) 

36 out = llm.update_wiki(ctx) # one call per wiki 

37 if out.verdict == "UPDATE": 

38 save_revision(wiki, out.new_content) 

39 

40The "find_affected_wikis" step is a cheap SQL join via 

41``astrocyte_pi_wiki_provenance`` × ``astrocyte_pi_section_entities`` 

42(both already populated by M9 + M12.5). 

43 

44Cost: ~$0.05 per source (one LLM call per affected wiki, typically 

452-5 wikis per source). At LoCoMo 200 docs that's ~$1-2 per bench run. 

46Marginal vs the answerer's per-question cost. 

47 

48Design references: 

49- ``docs/_design/llm-wiki-compile.md`` (Karpathy spec) 

50- ``docs/_design/m13-m14-roadmap.md`` §4 (M14.2 row) 

51- Hindsight: ``hindsight_api/engine/consolidation/consolidator.py`` 

52 (similar shape: identify novel facts → update or create observations) 

53 

54Status: SKELETON — interface + entity-overlap query + LLM prompt 

55designed; ``update_affected_wikis_for_document`` is the entry point. 

56The orchestration wiring into retain (FSM state ``UPDATE_AFFECTED_WIKIS`` 

57in the M14.0 scaffold, or direct call from ``bench_pageindex_locomo``) 

58is the next implementation step. 

59""" 

60 

61from __future__ import annotations 

62 

63import json 

64import logging 

65from dataclasses import dataclass, field 

66from datetime import datetime, timezone 

67from typing import TYPE_CHECKING 

68 

69from astrocyte.pipeline._json_tolerant import tolerant_json_loads_or_raise 

70from astrocyte.types import Message 

71 

72if TYPE_CHECKING: 

73 from astrocyte.provider import LLMProvider, PageIndexStore 

74 from astrocyte.types import WikiPage 

75 

76_logger = logging.getLogger("astrocyte.pipeline.wiki_incremental") 

77 

78 

79# --------------------------------------------------------------------------- 

80# Tunables 

81# --------------------------------------------------------------------------- 

82 

83#: Minimum entities shared between new doc and wiki provenance before we 

84#: consider the wiki "affected". 1 is the loosest threshold; raising to 2 

85#: cuts spurious cross-doc updates but misses true single-entity-overlap 

86#: updates. Calibrated against LoCoMo's entity density (~5-15 unique 

87#: entities per session) — 1 is right for the bench. 

88MIN_ENTITY_OVERLAP: int = 1 

89 

90#: Cap on wikis updated per retain. If a single source touches >N wikis 

91#: we update only the top-N by overlap count — keeps cost bounded. 

92MAX_AFFECTED_WIKIS_PER_SOURCE: int = 8 

93 

94#: max_tokens for the update LLM call. Sized for a 1-3 paragraph wiki 

95#: with revision notes; ample margin for context preservation. 

96UPDATE_LLM_MAX_TOKENS: int = 1500 

97 

98 

99# --------------------------------------------------------------------------- 

100# Result types 

101# --------------------------------------------------------------------------- 

102 

103 

104@dataclass 

105class WikiUpdateResult: 

106 """One wiki's update outcome from an incremental update pass.""" 

107 

108 page_id: str 

109 verdict: str # 'UPDATED' | 'NO_CHANGE' | 'FAILED' 

110 new_revision: int | None = None 

111 detail: str = "" 

112 

113 

114@dataclass 

115class IncrementalUpdateReport: 

116 """Per-document aggregate of wiki update outcomes.""" 

117 

118 document_id: str 

119 affected_count: int # how many wikis the entity-overlap query found 

120 updated: list[WikiUpdateResult] = field(default_factory=list) 

121 skipped: list[WikiUpdateResult] = field(default_factory=list) 

122 failed: list[WikiUpdateResult] = field(default_factory=list) 

123 

124 

125# --------------------------------------------------------------------------- 

126# LLM prompt 

127# --------------------------------------------------------------------------- 

128 

129 

130_UPDATE_PROMPT = """\ 

131You are maintaining a knowledge wiki. A new conversation has been retained \ 

132that overlaps with this existing wiki entry. Decide whether the new \ 

133content REQUIRES the wiki to be updated, or whether the existing wiki \ 

134already captures the new information (no change needed). 

135 

136Existing wiki (title: "{title}", revision {revision}): 

137=== BEGIN WIKI === 

138{wiki_content} 

139=== END WIKI === 

140 

141New content from the retained conversation (chronologically AFTER the \ 

142wiki's existing provenance): 

143=== BEGIN NEW CONTENT === 

144{new_content} 

145=== END NEW CONTENT === 

146 

147Rules: 

148- The wiki tracks LATEST state. If the new content updates a fact \ 

149 the wiki currently states (e.g. user moved cities, changed jobs, \ 

150 finished a project they were planning), emit UPDATE with the \ 

151 revised content reflecting the new state. Preserve the previous \ 

152 state inline as parenthetical when historically relevant (e.g. \ 

153 "(previously lived in Paris)"). 

154- If the new content adds NEW information not covered by the wiki, \ 

155 emit UPDATE with the addition integrated naturally. 

156- If the new content merely re-states what the wiki already says, \ 

157 emit NO_CHANGE. 

158- If the new content is unrelated to the wiki's topic despite the \ 

159 entity overlap (e.g. shared person name in different contexts), \ 

160 emit NO_CHANGE. 

161- Preserve the wiki's voice and structure. Output the FULL revised \ 

162 content, not a diff. 

163 

164Output a JSON object: 

165{{ 

166 "verdict": "UPDATE" or "NO_CHANGE", 

167 "revised_content": "<full revised wiki content; only when UPDATE>", 

168 "revised_title": "<optional new title; only when UPDATE and title needs change>" 

169}} 

170 

171OUTPUT MUST BE VALID JSON. No prose around it. 

172""" 

173 

174 

175# --------------------------------------------------------------------------- 

176# Public entry point 

177# --------------------------------------------------------------------------- 

178 

179 

180async def update_affected_wikis_for_document( # noqa: PLR0913 

181 *, 

182 page_index_store: PageIndexStore, 

183 provider: LLMProvider, 

184 bank_id: str, 

185 document_id: str, 

186 new_entities: list[str], 

187 new_content_excerpts: dict[str, str], 

188 model: str | None = None, 

189 min_overlap: int = MIN_ENTITY_OVERLAP, 

190 max_updates: int = MAX_AFFECTED_WIKIS_PER_SOURCE, 

191) -> IncrementalUpdateReport: 

192 """Run Karpathy update-affected-wikis for one retained source. 

193 

194 Args: 

195 page_index_store: source of the entity-overlap query AND wiki 

196 persistence (``save_wiki_page`` for revised wikis). 

197 provider: LLM provider for the update calls. 

198 bank_id: tenant scope. 

199 document_id: the newly-retained document whose entities trigger 

200 the update sweep. 

201 new_entities: distinct entity names extracted from the new 

202 document (already deduped by caller). 

203 new_content_excerpts: ``entity_name → relevant excerpt`` from 

204 the new document; passed to the update LLM as evidence. 

205 model: LLM model name (defaults to provider's default). 

206 min_overlap: minimum shared entities to flag a wiki as affected. 

207 max_updates: cap on update calls (top-N by overlap count). 

208 

209 Returns: 

210 :class:`IncrementalUpdateReport` with per-wiki outcomes. 

211 

212 Idempotent across re-runs: the second invocation finds the wiki's 

213 revision already reflects the new state and emits NO_CHANGE. 

214 """ 

215 report = IncrementalUpdateReport( 

216 document_id=document_id, 

217 affected_count=0, 

218 ) 

219 

220 if not new_entities: 

221 _logger.debug( 

222 "wiki_incremental: doc=%s has no entities — skip", 

223 document_id, 

224 ) 

225 return report 

226 

227 # ── Find affected wikis via entity overlap (SPI returns full WikiPage rows) ── 

228 try: 

229 affected = await page_index_store.list_wikis_affected_by_entities( 

230 bank_id, 

231 list(new_entities), 

232 min_overlap=min_overlap, 

233 limit=max_updates, 

234 ) 

235 except Exception as exc: # noqa: BLE001 

236 _logger.warning( 

237 "wiki_incremental.spi: list_wikis_affected_by_entities failed doc=%s (%s)", 

238 document_id, 

239 exc, 

240 ) 

241 return report 

242 

243 report.affected_count = len(affected) 

244 if not affected: 

245 _logger.debug( 

246 "wiki_incremental: doc=%s no wikis affected by entities=%s", 

247 document_id, 

248 new_entities[:5], 

249 ) 

250 return report 

251 

252 # ── Per-wiki update calls ──────────────────────────────────────── 

253 for wiki, _overlap_count, shared_entities in affected: 

254 result = await _update_one_wiki( 

255 wiki=wiki, 

256 page_index_store=page_index_store, 

257 provider=provider, 

258 shared_entities=shared_entities, 

259 new_content_excerpts=new_content_excerpts, 

260 model=model, 

261 ) 

262 if result.verdict == "UPDATED": 

263 report.updated.append(result) 

264 elif result.verdict == "NO_CHANGE": 

265 report.skipped.append(result) 

266 else: 

267 report.failed.append(result) 

268 

269 _logger.info( 

270 "wiki_incremental: doc=%s affected=%d updated=%d skipped=%d failed=%d", 

271 document_id, 

272 report.affected_count, 

273 len(report.updated), 

274 len(report.skipped), 

275 len(report.failed), 

276 ) 

277 return report 

278 

279 

280# --------------------------------------------------------------------------- 

281# Internals 

282# --------------------------------------------------------------------------- 

283 

284 

285async def _update_one_wiki( # noqa: PLR0913 

286 *, 

287 wiki: WikiPage, 

288 page_index_store: PageIndexStore, 

289 provider: LLMProvider, 

290 shared_entities: list[str], 

291 new_content_excerpts: dict[str, str], 

292 model: str | None, 

293) -> WikiUpdateResult: 

294 """Run one update prompt and persist the result. 

295 

296 Composes ``new_content`` as the union of excerpts keyed by 

297 entities the wiki and new doc share, so the LLM only sees evidence 

298 relevant to this wiki's topic. 

299 """ 

300 new_content_parts = [ 

301 f"[{e}] {new_content_excerpts.get(e, '').strip()}" 

302 for e in shared_entities 

303 if new_content_excerpts.get(e, "").strip() 

304 ] 

305 if not new_content_parts: 

306 return WikiUpdateResult( 

307 page_id=wiki.page_id, 

308 verdict="NO_CHANGE", 

309 detail="no excerpt content for shared entities", 

310 ) 

311 new_content = "\n\n".join(new_content_parts) 

312 

313 msg = _UPDATE_PROMPT.format( 

314 title=wiki.title, 

315 revision=wiki.revision, 

316 wiki_content=wiki.content, 

317 new_content=new_content, 

318 ) 

319 try: 

320 completion = await provider.complete( 

321 [Message(role="user", content=msg)], 

322 model=model, 

323 max_tokens=UPDATE_LLM_MAX_TOKENS, 

324 temperature=0.0, 

325 response_format={"type": "json_object"}, 

326 ) 

327 except Exception as exc: # noqa: BLE001 

328 _logger.warning( 

329 "wiki_incremental.llm: call failed for page=%s (%s)", 

330 wiki.page_id, 

331 exc, 

332 ) 

333 return WikiUpdateResult( 

334 page_id=wiki.page_id, 

335 verdict="FAILED", 

336 detail=str(exc), 

337 ) 

338 

339 try: 

340 data = tolerant_json_loads_or_raise(completion.text) 

341 except (json.JSONDecodeError, AttributeError) as exc: 

342 _logger.warning( 

343 "wiki_incremental.parse: bad JSON for page=%s (%s)", 

344 wiki.page_id, 

345 exc, 

346 ) 

347 return WikiUpdateResult( 

348 page_id=wiki.page_id, 

349 verdict="FAILED", 

350 detail=f"bad JSON: {exc}", 

351 ) 

352 

353 verdict = str(data.get("verdict", "")).upper() 

354 if verdict == "NO_CHANGE": 

355 return WikiUpdateResult(page_id=wiki.page_id, verdict="NO_CHANGE") 

356 

357 if verdict != "UPDATE": 

358 return WikiUpdateResult( 

359 page_id=wiki.page_id, 

360 verdict="FAILED", 

361 detail=f"unrecognised verdict: {verdict!r}", 

362 ) 

363 

364 revised_content = (data.get("revised_content") or "").strip() 

365 if not revised_content: 

366 return WikiUpdateResult( 

367 page_id=wiki.page_id, 

368 verdict="FAILED", 

369 detail="UPDATE verdict with empty revised_content", 

370 ) 

371 revised_title = (data.get("revised_title") or wiki.title).strip() 

372 

373 # Save as a new revision via ``PageIndexStore.save_wiki_page`` — its 

374 # upsert semantics auto-bump revision and archive the prior version. 

375 # The wiki's existing provenance is preserved: we re-parse it from 

376 # ``source_ids`` (M12.6 format: ``"<doc_id>:<line_num>"``) if present; 

377 # otherwise we pass an empty list (provenance row already exists in 

378 # ``astrocyte_pi_wiki_provenance`` from the initial compile and is 

379 # untouched by content-only revisions). 

380 from dataclasses import replace # noqa: PLC0415 

381 

382 new_wiki = replace( 

383 wiki, 

384 title=revised_title, 

385 content=revised_content, 

386 revision=wiki.revision + 1, 

387 revised_at=datetime.now(tz=timezone.utc), 

388 ) 

389 provenance: list[tuple[str, int]] = [] 

390 for sid in wiki.source_ids or []: 

391 if ":" in sid: 

392 doc_id, _, line_str = sid.rpartition(":") 

393 try: 

394 provenance.append((doc_id, int(line_str))) 

395 except ValueError: 

396 continue 

397 try: 

398 await page_index_store.save_wiki_page( 

399 page=new_wiki, 

400 embedding=None, 

401 provenance=provenance, 

402 ) 

403 except Exception as exc: # noqa: BLE001 

404 _logger.warning( 

405 "wiki_incremental.save: page=%s revision=%d failed (%s)", 

406 wiki.page_id, 

407 new_wiki.revision, 

408 exc, 

409 ) 

410 return WikiUpdateResult( 

411 page_id=wiki.page_id, 

412 verdict="FAILED", 

413 detail=f"save failed: {exc}", 

414 ) 

415 

416 return WikiUpdateResult( 

417 page_id=wiki.page_id, 

418 verdict="UPDATED", 

419 new_revision=new_wiki.revision, 

420 )