Coverage for astrocyte/pipeline/section_compile.py: 42%

183 statements  

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

1"""M10.1 — section-aware consolidation engine. 

2 

3Hindsight-aligned: aggregation happens at retain time so query-time 

4agents can find pre-aggregated observations via wiki recall, instead 

5of trying to count distinct items across many section excerpts (which 

6the LLM is unreliable at). 

7 

8Algorithm per document: 

9 

101. Load sections with ``summary_embedding`` populated. 

112. DBSCAN-cluster by cosine similarity (eps=0.30, min_samples=2 by 

12 default) — same approach as the M8 ``CompileEngine`` over raw 

13 memory_units, just at section grain. 

143. For each cluster, single LLM call synthesizes a short observation: 

15 a 5-10 word title and a 1-3 sentence aggregated claim that cites 

16 specific dates / counts / entities. This is the primitive that 

17 answers "how many doctors did I visit?" without making the agent 

18 count across sessions at query time. 

194. Save each observation as a :class:`~astrocyte.types.WikiPage` 

20 (kind="topic"), with provenance back to the source sections via 

21 ``astrocyte_pi_wiki_provenance`` (migration 015) and an embedding 

22 on ``astrocyte_wiki_pages.current_embedding`` (migration 018). 

23 

24Differs from M8's :class:`~astrocyte.pipeline.compile.CompileEngine`: 

25M8 reads from ``VectorStore`` (memory_units); this module reads from 

26``PageIndexStore`` (sections). Both produce ``WikiPage`` rows; both 

27use the same ``_dbscan`` clustering. The split exists because the 

28PageIndex POC bypasses ``VectorStore`` entirely. 

29 

30See: 

31- ``docs/_design/recall.md`` §12 (PR2.6 close-out, M10 plan) 

32- ``adr/adr-006-three-layer-recall-stack.md`` — Layer 1 wiki tier 

33- ``hindsight/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py`` 

34 — Hindsight's analogous flow over raw memories 

35""" 

36 

37from __future__ import annotations 

38 

39import json 

40import logging 

41import re 

42from datetime import datetime, timezone 

43from typing import TYPE_CHECKING 

44 

45from astrocyte.pipeline._json_tolerant import tolerant_json_loads_or_raise 

46from astrocyte.pipeline.compile import _dbscan # reuse pure-Python DBSCAN 

47from astrocyte.types import Message, WikiPage 

48 

49if TYPE_CHECKING: 

50 from astrocyte.provider import LLMProvider, PageIndexStore 

51 from astrocyte.types import PageIndexSection 

52 

53_logger = logging.getLogger("astrocyte.pipeline.section_compile") 

54 

55 

56# ── Synthesis prompt ──────────────────────────────────────────────── 

57 

58 

59_OBSERVATION_PROMPT = """\ 

60You are an observation synthesizer. You will be given several sections \ 

61from a conversation that all discuss the same topic, presented in \ 

62CHRONOLOGICAL ORDER (oldest first). Your job: write ONE concise \ 

63observation that aggregates the user's experience or stable facts \ 

64across these sections, reflecting the LATEST stable state. 

65 

66Pay attention to LATER sections that may supersede earlier ones. If \ 

67the user changes their mind, switches providers, or adds new items \ 

68over time, the observation should reflect the LATEST state. Capture \ 

69the chronological progression when it matters (e.g. "previously did \ 

70X, now does Y") rather than averaging across time. 

71 

72Output a JSON object with EXACTLY two fields: 

73- "title": a 5-10 word summary that uses CATEGORY nouns the reader \ 

74might query (e.g. "Doctors visited", "Model kits worked on", \ 

75"Camping trips taken"). Title should mention the category (doctor / \ 

76kit / trip / restaurant / etc.) so the wiki search can match it. 

77- "content": 1-3 sentences. MANDATORY: lead with an explicit COUNT \ 

78followed by a colon-separated enumeration. The reader will use your \ 

79content as the answer to questions like "how many X?" / "total Y?" \ 

80/ "list of Z?". DO NOT generalize or theme — enumerate the actual \ 

81distinct items. When listing items added over time, the order is \ 

82chronological (earliest first). 

83 

84REQUIRED CONTENT SHAPE: 

85"User <verb>ed N distinct <category>s: <item1> (<context>), \ 

86<item2> (<context>), ..., <itemN> (<context>)." 

87 

88Examples of good observations: 

89{{"title": "Doctors visited", "content": "User visited 3 distinct \ 

90doctors: a primary care physician (Dr. Patel, May), an ENT specialist \ 

91(Dr. Lee, June), and a dermatologist (referral, July)."}} 

92 

93{{"title": "Model kits worked on", "content": "User worked on 5 model \ 

94kits: a Revell F-15 Eagle, a B-29 bomber, a 1/72 Spitfire, a Tiger I \ 

95tank, and a Sherman tank."}} 

96 

97{{"title": "Camping trips taken", "content": "User went on 4 camping \ 

98trips totaling 8 days: Yosemite (3 days, April), Lake Tahoe (2 days, \ 

99June), Olympic NP (2 days, August), Grand Canyon (1 day, October)."}} 

100 

101{{"title": "Korean restaurants visited", "content": "User visited 3 \ 

102distinct Korean restaurants: Yeon Su (Chelsea), Cote (Flatiron), and \ 

103Hanjip (LA, during travel)."}} 

104 

105ANTI-PATTERNS to avoid: 

106- "User is managing health" (no count, no enumeration) — WRONG 

107- "User explored various trips" (vague, no specifics) — WRONG 

108- "User had multiple doctor visits" (says "multiple" without counting) — WRONG 

109 

110When the cluster has only 1 distinct fact, still lead with "1": \ 

111"User has 1 ongoing project: <description>." 

112 

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

114 

115Sections in this cluster: 

116{sections} 

117""" 

118 

119 

120# ── Cluster helpers ───────────────────────────────────────────────── 

121 

122 

123def _slugify(s: str) -> str: 

124 """Convert a title to a URL-safe slug. e.g. 'Doctors visited' -> 'doctors-visited'.""" 

125 s = s.lower().strip() 

126 s = re.sub(r"[^a-z0-9\s-]", "", s) 

127 s = re.sub(r"\s+", "-", s) 

128 return s[:60] or "obs" 

129 

130 

131def _format_section_for_prompt(section: PageIndexSection) -> str: 

132 date = section.session_date.strftime("%Y-%m-%d") if section.session_date is not None else "no-date" 

133 summary = (section.summary or section.title or "").strip() 

134 return f"[line={section.line_num}, date={date}]\n{summary}" 

135 

136 

137async def _synthesize_observation( 

138 *, 

139 provider: LLMProvider, 

140 model: str | None, 

141 sections: list[PageIndexSection], 

142) -> tuple[str, str] | None: 

143 """LLM call — returns (title, content) or None on parse failure. 

144 

145 M12.6: sections are sorted by ``session_date`` (with ``line_num`` 

146 fallback) so the synth prompt sees them in chronological order. 

147 The Karpathy "latest state wins" guidance in the prompt requires 

148 this ordering to be meaningful. 

149 """ 

150 if not sections: 

151 return None 

152 ordered = sorted( 

153 sections, 

154 key=lambda s: ( 

155 s.session_date or datetime.min.replace(tzinfo=timezone.utc), 

156 s.line_num, 

157 ), 

158 ) 

159 rendered = "\n\n".join(_format_section_for_prompt(s) for s in ordered) 

160 msg = _OBSERVATION_PROMPT.format(sections=rendered) 

161 try: 

162 completion = await provider.complete( 

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

164 model=model, 

165 max_tokens=300, 

166 temperature=0.0, 

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

168 ) 

169 except Exception as exc: # noqa: BLE001 

170 _logger.warning( 

171 "section_compile.synthesize: LLM call failed (%s)", 

172 exc, 

173 ) 

174 return None 

175 try: 

176 data = tolerant_json_loads_or_raise(completion.text) 

177 except json.JSONDecodeError as exc: 

178 _logger.warning( 

179 "section_compile.synthesize: JSON parse failed (%s) text=%r", 

180 exc, 

181 completion.text[:200], 

182 ) 

183 return None 

184 title = str(data.get("title", "")).strip() 

185 content = str(data.get("content", "")).strip() 

186 if not title or not content: 

187 return None 

188 return title, content 

189 

190 

191# ── Revision pass (Karpathy incremental update, M12.6) ───────────── 

192 

193 

194_REVISION_PROMPT = """\ 

195You are revising a knowledge-base entry to reflect the LATEST stable \ 

196state across its source sections. 

197 

198Current entry (titled "{title}", revision {revision}): 

199{content} 

200 

201Source sections in CHRONOLOGICAL ORDER (oldest first): 

202{sections} 

203 

204Examine the sections in time order. If a LATER section supersedes, \ 

205contradicts, or extends an earlier claim, the entry should reflect \ 

206the latest state. If the entry already does, no revision is needed. 

207 

208Common revision triggers: 

209- Entry says "User visited 3 doctors" but a later section adds a 4th → bump count 

210- Entry says "User prefers brand X" but later sections say switched to Y → "User now prefers Y (previously X)" 

211- Entry uses past tense for an ongoing thing → use ongoing tense 

212- Entry omits items that appear in later sections → add them 

213- Entry includes superseded items as if current → mark as previous 

214 

215If revising: the new content follows the SAME shape as initial compile \ 

216(lead with explicit COUNT and colon-separated enumeration; cite dates / \ 

217entities when present). 

218 

219Respond as JSON only: 

220{{ 

221 "verdict": "OK" or "REVISE", 

222 "revised_title": "<5-10 words; only when REVISE>", 

223 "revised_content": "<1-3 sentences; only when REVISE>" 

224}} 

225""" 

226 

227 

228async def _revise_observation( 

229 *, 

230 provider: LLMProvider, 

231 model: str | None, 

232 page: WikiPage, 

233 sections: list[PageIndexSection], 

234) -> tuple[str, str] | None: 

235 """LLM call — returns (new_title, new_content) if revised, ``None`` 

236 if the page already reflects the latest state. 

237 

238 Sections must be sorted chronologically by the caller. Resilient 

239 to LLM / parse failures (returns ``None``). 

240 """ 

241 if not sections or not page.content.strip(): 

242 return None 

243 rendered = "\n\n".join(_format_section_for_prompt(s) for s in sections) 

244 msg = _REVISION_PROMPT.format( 

245 title=page.title, 

246 revision=page.revision, 

247 content=page.content, 

248 sections=rendered, 

249 ) 

250 try: 

251 completion = await provider.complete( 

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

253 model=model, 

254 max_tokens=400, 

255 temperature=0.0, 

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

257 ) 

258 except Exception as exc: # noqa: BLE001 

259 _logger.warning( 

260 "section_compile.revise: LLM call failed for page=%s (%s)", 

261 page.page_id, 

262 exc, 

263 ) 

264 return None 

265 try: 

266 data = tolerant_json_loads_or_raise(completion.text) 

267 except json.JSONDecodeError as exc: 

268 _logger.warning( 

269 "section_compile.revise: JSON parse failed for page=%s (%s) text=%r", 

270 page.page_id, 

271 exc, 

272 completion.text[:200], 

273 ) 

274 return None 

275 verdict = str(data.get("verdict", "")).strip().upper() 

276 if verdict != "REVISE": 

277 return None 

278 new_title = str(data.get("revised_title", "")).strip() 

279 new_content = str(data.get("revised_content", "")).strip() 

280 if not new_title or not new_content: 

281 return None 

282 if new_title == page.title and new_content == page.content: 

283 return None 

284 return new_title, new_content 

285 

286 

287async def revise_wikis_for_document( 

288 *, 

289 store: PageIndexStore, 

290 bank_id: str, 

291 document_id: str, 

292 provider: LLMProvider, 

293 model: str | None = None, 

294 embedding_model: str | None = None, 

295) -> int: 

296 """Karpathy-style revision pass — for each existing wiki of this 

297 document, re-check it against its provenance sections in 

298 chronological order and persist any revisions. 

299 

300 Returns: number of pages actually revised. 

301 

302 Idempotent: if no page needs revision, the call is a no-op (just 

303 one LLM call per page). Subsequent calls on already-revised pages 

304 should converge to no-op as the wiki stabilises. 

305 """ 

306 try: 

307 pages = await store.list_wiki_pages_for_doc(bank_id, document_id) 

308 except Exception as exc: # noqa: BLE001 

309 _logger.warning( 

310 "revise_wikis: list_wiki_pages_for_doc failed for doc=%s (%s)", 

311 document_id, 

312 exc, 

313 ) 

314 return 0 

315 if not pages: 

316 return 0 

317 

318 try: 

319 all_sections = await store.load_sections_with_embeddings(bank_id, document_id) 

320 except Exception as exc: # noqa: BLE001 

321 _logger.warning( 

322 "revise_wikis: load_sections failed for doc=%s (%s)", 

323 document_id, 

324 exc, 

325 ) 

326 return 0 

327 section_by_line = {s.line_num: s for s in all_sections} 

328 

329 revised_count = 0 

330 for page in pages: 

331 # Resolve provenance source_ids back to sections. 

332 provenance_lines: list[int] = [] 

333 for src in page.source_ids or []: 

334 # source_ids are formatted "docId:lineNum" 

335 if ":" not in src: 

336 continue 

337 _, _, line_str = src.rpartition(":") 

338 try: 

339 provenance_lines.append(int(line_str)) 

340 except ValueError: 

341 continue 

342 prov_sections = [section_by_line[ln] for ln in provenance_lines if ln in section_by_line] 

343 if not prov_sections: 

344 continue 

345 # Sort chronologically — session_date first, line_num fallback. 

346 prov_sections.sort( 

347 key=lambda s: ( 

348 s.session_date or datetime.min.replace(tzinfo=timezone.utc), 

349 s.line_num, 

350 ), 

351 ) 

352 result = await _revise_observation( 

353 provider=provider, 

354 model=model, 

355 page=page, 

356 sections=prov_sections, 

357 ) 

358 if result is None: 

359 continue 

360 new_title, new_content = result 

361 

362 # Save as a new revision. ``save_wiki_page`` upserts the page row 

363 # and inserts a new revision row keyed by (page_uuid, revision_number). 

364 revised_page = WikiPage( 

365 page_id=page.page_id, 

366 bank_id=page.bank_id, 

367 kind=page.kind, 

368 title=new_title, 

369 content=new_content, 

370 scope=page.scope, 

371 source_ids=list(page.source_ids or []), 

372 cross_links=list(page.cross_links or []), 

373 revision=page.revision + 1, 

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

375 tags=page.tags, 

376 metadata=page.metadata, 

377 ) 

378 try: 

379 new_embedding = ( 

380 await provider.embed( 

381 [f"{new_title}\n\n{new_content}"], 

382 model=embedding_model, 

383 ) 

384 )[0] 

385 except Exception as exc: # noqa: BLE001 

386 _logger.warning( 

387 "revise_wikis: embed failed for page=%s (%s)", 

388 page.page_id, 

389 exc, 

390 ) 

391 new_embedding = None 

392 

393 provenance_pairs = [(s.document_id, s.line_num) for s in prov_sections] 

394 try: 

395 await store.save_wiki_page( 

396 page=revised_page, 

397 embedding=new_embedding, 

398 provenance=provenance_pairs, 

399 ) 

400 revised_count += 1 

401 except Exception as exc: # noqa: BLE001 

402 _logger.warning( 

403 "revise_wikis.save failed for page=%s (%s)", 

404 page.page_id, 

405 exc, 

406 ) 

407 

408 if revised_count: 

409 _logger.info( 

410 "revise_wikis: doc=%s revised %d/%d pages", 

411 document_id, 

412 revised_count, 

413 len(pages), 

414 ) 

415 return revised_count 

416 

417 

418# ── Public entry point ───────────────────────────────────────────── 

419 

420 

421async def compile_sections_for_document( 

422 *, 

423 store: PageIndexStore, 

424 bank_id: str, 

425 document_id: str, 

426 provider: LLMProvider, 

427 model: str | None = None, 

428 embedding_model: str | None = None, 

429 eps: float = 0.55, 

430 min_samples: int = 2, 

431 max_pages_per_doc: int = 10, 

432) -> list[str]: 

433 """Cluster sections in this document, synthesize an observation per 

434 cluster, persist as :class:`WikiPage` rows. 

435 

436 Returns the list of newly-created ``page_id``s. When the document 

437 already has wiki pages (idempotency check), returns ``[]``. 

438 

439 ``eps`` / ``min_samples`` tune DBSCAN: defaults match LME-shaped 

440 chat conversations (sessions are heterogeneous; eps=0.30 forces 

441 fairly close cosine similarity). Lower ``min_samples`` than M8's 

442 default (2 vs 3) because section-grain clusters tend to be smaller 

443 than memory-unit clusters. 

444 

445 ``max_pages_per_doc`` caps cost: at 5-7 LLM calls per doc × 50 LME 

446 docs we'd burn ~$2-3 per gate. The cap keeps it bounded if a 

447 pathological doc clusters into many small groups. 

448 """ 

449 # Idempotency: skip if pages already exist for this doc. 

450 try: 

451 existing = await store.count_wiki_pages_for_doc(bank_id, document_id) 

452 except Exception as exc: # noqa: BLE001 

453 _logger.warning( 

454 "section_compile.count failed for doc=%s (%s)", 

455 document_id, 

456 exc, 

457 ) 

458 existing = 0 

459 if existing > 0: 

460 _logger.debug( 

461 "section_compile: doc=%s already has %d pages — skip initial compile, run revision pass only", 

462 document_id, 

463 existing, 

464 ) 

465 # M12.6: even when initial compile is skipped, run the Karpathy 

466 # revision pass against the existing pages. Lets a code change 

467 # (e.g. new prompt) take effect against cached banks without a 

468 # full re-extraction. 

469 try: 

470 await revise_wikis_for_document( 

471 store=store, 

472 bank_id=bank_id, 

473 document_id=document_id, 

474 provider=provider, 

475 model=model, 

476 embedding_model=embedding_model, 

477 ) 

478 except Exception as exc: # noqa: BLE001 

479 _logger.warning( 

480 "revise_wikis: cache-hit revision pass failed for doc=%s (%s)", 

481 document_id, 

482 exc, 

483 ) 

484 return [] 

485 

486 # Load sections with embeddings populated. 

487 sections = await store.load_sections_with_embeddings(bank_id, document_id) 

488 if not sections: 

489 return [] 

490 sections_with_emb = [s for s in sections if s.summary_embedding] 

491 if len(sections_with_emb) < min_samples: 

492 # Too few embedded sections to cluster meaningfully. 

493 return [] 

494 

495 # DBSCAN over (line_num_str, embedding) pairs. 

496 items = [(str(s.line_num), s.summary_embedding) for s in sections_with_emb] 

497 labels = _dbscan(items, eps=eps, min_samples=min_samples) 

498 by_cluster: dict[int, list[PageIndexSection]] = {} 

499 section_by_line = {s.line_num: s for s in sections_with_emb} 

500 for line_str, cluster_id in labels.items(): 

501 if cluster_id == -1: 

502 continue 

503 by_cluster.setdefault(cluster_id, []).append( 

504 section_by_line[int(line_str)], 

505 ) 

506 

507 if not by_cluster: 

508 _logger.debug( 

509 "section_compile: doc=%s yielded no clusters (eps=%s min=%s)", 

510 document_id, 

511 eps, 

512 min_samples, 

513 ) 

514 return [] 

515 

516 # Cap cluster count to control cost. Prefer larger clusters (more 

517 # signal per LLM call). 

518 clusters = sorted(by_cluster.values(), key=len, reverse=True)[:max_pages_per_doc] 

519 

520 created_page_ids: list[str] = [] 

521 now = datetime.now(tz=timezone.utc) 

522 pending_pages: list[WikiPage] = [] 

523 pending_provenance: list[tuple[str, list[tuple[str, int]]]] = [] 

524 pending_summaries: list[str] = [] # used for embedding pass 

525 

526 for cluster in clusters: 

527 synth = await _synthesize_observation( 

528 provider=provider, 

529 model=model, 

530 sections=cluster, 

531 ) 

532 if synth is None: 

533 continue 

534 title, content = synth 

535 page_id = f"obs:{document_id[:8]}:{_slugify(title)}" 

536 source_ids = [f"{s.document_id}:{s.line_num}" for s in cluster] 

537 page = WikiPage( 

538 page_id=page_id, 

539 bank_id=bank_id, 

540 kind="topic", 

541 title=title, 

542 content=content, 

543 scope=f"document:{document_id}", 

544 source_ids=source_ids, 

545 cross_links=[], 

546 revision=1, 

547 revised_at=now, 

548 tags=None, 

549 metadata=None, 

550 ) 

551 pending_pages.append(page) 

552 provenance_pairs = [(s.document_id, s.line_num) for s in cluster] 

553 pending_provenance.append((page_id, provenance_pairs)) 

554 # Embed the title + content together so the wiki search 

555 # strategy matches both the topic label and the aggregated 

556 # facts. Mirrors how Hindsight indexes observations. 

557 pending_summaries.append(f"{title}\n\n{content}") 

558 

559 if not pending_pages: 

560 return [] 

561 

562 # Single batched embedding call for all observations in this doc. 

563 try: 

564 embeddings = await provider.embed( 

565 pending_summaries, 

566 model=embedding_model, 

567 ) 

568 except Exception as exc: # noqa: BLE001 

569 _logger.warning( 

570 "section_compile: embedding batch failed for doc=%s (%s)", 

571 document_id, 

572 exc, 

573 ) 

574 embeddings = [None] * len(pending_pages) 

575 

576 for page, embedding, (page_id, provenance_pairs) in zip( 

577 pending_pages, 

578 embeddings, 

579 pending_provenance, 

580 ): 

581 try: 

582 await store.save_wiki_page( 

583 page=page, 

584 embedding=embedding, 

585 provenance=provenance_pairs, 

586 ) 

587 except Exception as exc: # noqa: BLE001 

588 _logger.warning( 

589 "section_compile.save_wiki_page failed page_id=%s (%s)", 

590 page_id, 

591 exc, 

592 ) 

593 continue 

594 created_page_ids.append(page_id) 

595 

596 _logger.info( 

597 "section_compile: doc=%s created %d pages from %d clusters", 

598 document_id, 

599 len(created_page_ids), 

600 len(clusters), 

601 ) 

602 

603 # M12.6: Karpathy-style revision pass over the freshly compiled 

604 # pages. Catches "latest state" patterns that single-shot synthesis 

605 # missed (e.g. counts that grow over time, providers that change). 

606 # Idempotent — pages already at latest state are unchanged. 

607 try: 

608 await revise_wikis_for_document( 

609 store=store, 

610 bank_id=bank_id, 

611 document_id=document_id, 

612 provider=provider, 

613 model=model, 

614 embedding_model=embedding_model, 

615 ) 

616 except Exception as exc: # noqa: BLE001 

617 _logger.warning( 

618 "revise_wikis: post-initial revision pass failed for doc=%s (%s)", 

619 document_id, 

620 exc, 

621 ) 

622 

623 return created_page_ids