secondbrain-py 0.2.1__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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/enrichment.py ADDED
@@ -0,0 +1,808 @@
1
+ """Local-Ollama enrichment: per-document summary + tag proposals.
2
+
3
+ One module, two consumers: the post-ingest auto-summary hook in
4
+ ``brain.ingest.__init__`` and the interactive ``brain tag --auto`` flow in
5
+ ``brain.cli``. Both go through :class:`OllamaEnricher` so HTTP transport,
6
+ JSON-mode handling, timeouts, and retry policy live in one place.
7
+
8
+ Ollama is the only backend in Wave Q1-D — no Claude Haiku / OpenAI / Voyage
9
+ surface. Model is configurable via ``BRAIN_ENRICH_MODEL`` (default
10
+ ``llama3.1:8b``). The class reuses the ``httpx.Client`` transport pattern
11
+ from :class:`brain.embeddings._OllamaEmbedderBase` but does NOT subclass it:
12
+ the embedder hits ``/api/embed`` for vectors and the enricher hits
13
+ ``/api/chat`` for JSON completions, so a shared parent would carry no
14
+ shared logic.
15
+
16
+ Model upgrade semantics
17
+ -----------------------
18
+ ``documents.summary_model`` records the model fingerprint of every persisted
19
+ summary. The post-ingest hook and ``brain enrich --backfill`` both treat
20
+ "existing summary AND ``summary_model == cfg.enrich_model``" as up-to-date;
21
+ when the user upgrades ``BRAIN_ENRICH_MODEL`` (e.g. ``llama3.1:8b`` →
22
+ ``llama3.2:8b``), every doc whose stored summary was generated by the OLD
23
+ model is automatically re-enriched on next ingest / next backfill run. The
24
+ behavior is keyed on ``OllamaEnricher.model`` (the property), so callers
25
+ that pass a fake enricher in tests can control the comparison without
26
+ monkey-patching the production module.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+
35
+ import httpx
36
+ import tiktoken
37
+
38
+ from .chat import ChatMessage, chat_json_with_client
39
+ from .config import Config
40
+ from .errors import EnrichmentError
41
+ from .tags import normalize_tags
42
+
43
+ _logger = logging.getLogger(__name__)
44
+
45
+ # Module-level keep_alive fallback — matches embeddings.py convention. Tests
46
+ # that construct OllamaEnricher without an explicit ``keep_alive`` kwarg get
47
+ # this default; production always threads ``Config.ollama_keep_alive``.
48
+ _DEFAULT_KEEP_ALIVE = "30m"
49
+
50
+ # D20 — banned auto-tag formats. These are ``documents.content_type`` values
51
+ # (or close synonyms) that the LLM should never propose as semantic tags.
52
+ # Belt-and-braces with the prompt: a prompt-injection / model drift case that
53
+ # slipped one past the prompt is still filtered at the Python layer. Stored
54
+ # casefold-lower-hyphen-normalized so the post-``normalize_tags`` comparison
55
+ # is a direct membership check.
56
+ _BANNED_AUTO_TAGS = frozenset(
57
+ {
58
+ "pdf",
59
+ "transcript",
60
+ "email",
61
+ "email-thread",
62
+ "note",
63
+ "docx",
64
+ "markdown",
65
+ "text",
66
+ "krisp-action-items",
67
+ }
68
+ )
69
+
70
+
71
+ _SUMMARY_SYSTEM_PROMPT = (
72
+ "You write 2-3 sentence summaries of personal-knowledge-base documents.\n"
73
+ "\n"
74
+ "The user gives you a TITLE and a BODY. Return ONLY valid JSON:\n"
75
+ '{"summary": "..."}\n'
76
+ "\n"
77
+ "The summary must:\n"
78
+ "- be 2-3 sentences, factual, written in plain past tense\n"
79
+ "- name specific people / projects / dates if they appear in the body\n"
80
+ "- never invent facts, never include \"this document discusses\" filler\n"
81
+ "- never exceed 60 words"
82
+ )
83
+
84
+
85
+ _TAG_SYSTEM_PROMPT = (
86
+ "You propose tags for a personal-knowledge-base document.\n"
87
+ "\n"
88
+ "Inputs: TITLE, SUMMARY, EXISTING_VOCAB (tags already used in the corpus),\n"
89
+ "CURRENT_TAGS (tags already on this doc).\n"
90
+ "\n"
91
+ "Return ONLY valid JSON:\n"
92
+ '{"tags": ["tag1", "tag2", "tag3"]}\n'
93
+ "\n"
94
+ "Rules:\n"
95
+ "- propose 3 to 5 tags\n"
96
+ "- prefer tags from EXISTING_VOCAB; only propose new tags when no existing tag fits\n"
97
+ "- lowercase, hyphen-separated (e.g. \"interview-prep\", \"company-acme\")\n"
98
+ "- never propose tags already in CURRENT_TAGS\n"
99
+ "- never propose more than 5 tags\n"
100
+ "- never propose tags about file format (pdf, docx, transcript) — those are tracked elsewhere\n"
101
+ "- never propose tags about timing (recent, old, last-week)"
102
+ )
103
+
104
+
105
+ # Wave G2-b — concept entity extraction (spec §3 D3 / §8). The extractor
106
+ # *backend* lives here (transport, JSON-mode, retry) so it shares one place with
107
+ # ``summarize`` / ``propose_tags``; the concept-extraction *logic* (chunking,
108
+ # canonicalization, position-finding, per-doc cap, never-raise) lives in
109
+ # :mod:`brain.graph_rag.extract`. ``type`` is constrained to the four concept
110
+ # entity types — people are derived for free from the participants pipeline and
111
+ # are deliberately NOT extracted here (spec §17b decision 2: "people excluded").
112
+ _EXTRACT_SYSTEM_PROMPT = (
113
+ "You extract the salient non-person CONCEPT entities from a "
114
+ "personal-knowledge-base document.\n"
115
+ "\n"
116
+ "Return ONLY valid JSON:\n"
117
+ '{"entities": [{"name": "...", "type": "topic"}]}\n'
118
+ "\n"
119
+ "type MUST be exactly one of: org, project, tool, topic.\n"
120
+ "Use the entity's surface form exactly as it appears in the text for name.\n"
121
+ "\n"
122
+ "Type rules:\n"
123
+ "- org: a company or provider whose hosted service or platform you consume "
124
+ "or integrate with — e.g. a payments, identity, data-warehouse, or cloud "
125
+ "provider. Use the company name.\n"
126
+ "- tool: software you run, deploy, operate, import, or build with — a "
127
+ "framework, library, language, database, monitoring agent, or server.\n"
128
+ "- project: a named initiative or effort. Keep the FULL name including a "
129
+ 'leading word like "Project" (return the whole "Project <name>" form, never '
130
+ "just the bare name).\n"
131
+ "- topic: a central, durable theme or subject area the document is about, "
132
+ "usually one or two words.\n"
133
+ "\n"
134
+ "Topic guidance:\n"
135
+ "- Capture the few themes the text frames as central. Look for "
136
+ "importance-signaling language — examples (not an exhaustive list) include "
137
+ '"the main topic", "the recurring theme", "the headline", "dominated the '
138
+ 'discussion", "a major focus was", "central to the conversation", "we kept '
139
+ 'coming back to", or "came up repeatedly" — and any similar phrasing that '
140
+ "marks something as important, as well as the core subject the document "
141
+ "centers on.\n"
142
+ "- Also capture the core subject word that a named effort or platform is "
143
+ 'about — the domain word inside phrases like a "<X> effort", "<X> '
144
+ 'initiative", "<X> platform", or "<X> stack" (extract <X> as the topic).\n'
145
+ "- Return roughly 1-3 topics, and prefer the short theme word over a long "
146
+ "phrase (the one- or two-word theme, not a verbose feature description).\n"
147
+ "\n"
148
+ "Never extract:\n"
149
+ "- people or person names (they are tracked separately)\n"
150
+ "- dates, times, or file formats\n"
151
+ "- document-structure references (PDF, chapter, section, page, table, "
152
+ "figure, appendix, heading)\n"
153
+ "- generic activities or events (meetings, reviews, sprints, workshops)\n"
154
+ "- incidental UI or implementation nouns, or one-off mentions that are not a "
155
+ "central theme\n"
156
+ "- vague container words (platform, service, system, dashboard) on their own\n"
157
+ "\n"
158
+ "CRITICAL: Extract ONLY entities that appear LITERALLY in THIS text. Copy the "
159
+ "name exactly as written. Never invent a name, never add a name that is not "
160
+ "in the text, and never copy a name from these instructions or from a "
161
+ "previous document.\n"
162
+ "\n"
163
+ "The bracketed words in the FORMAT example below are PLACEHOLDERS showing the "
164
+ "JSON shape only. They are NOT real entities — never output them.\n"
165
+ "FORMAT (schematic — replace each placeholder with a real name from the "
166
+ "text, or omit it):\n"
167
+ 'JSON: {"entities": [{"name": "<the hosted provider named in the text>", '
168
+ '"type": "org"}, {"name": "Project <the effort named in the text>", '
169
+ '"type": "project"}, {"name": "<the software tool named in the text>", '
170
+ '"type": "tool"}, {"name": "<the central theme of the text>", '
171
+ '"type": "topic"}]}\n'
172
+ "\n"
173
+ "When THIS text contains no org, project, tool, or topic entities, return an "
174
+ "empty list. Do NOT fill it with examples or guesses:\n"
175
+ 'JSON: {"entities": []}'
176
+ )
177
+
178
+
179
+ # Wave G2-f — on-demand theme synthesis (spec §6b step 4 / §17b decision 7).
180
+ # Opt-in (``--synthesize``); best-effort and never required for retrieval.
181
+
182
+ # Wave elicit — articulate tacit rules the user follows but has never written
183
+ # down. The enricher holds the LLM transport; the elicit drafter owns the
184
+ # orchestration logic.
185
+ _ELICIT_SYSTEM_PROMPT = (
186
+ "You are articulating a tacit rule the user follows in their work but has "
187
+ "never written down. You are given a subject and evidence excerpts drawn from "
188
+ "their meetings, messages, and notes. State the single most likely underlying "
189
+ "rule as a confident, first-person assertion the user can quickly confirm or "
190
+ "correct. Be specific and concrete; do not hedge. Respond with JSON: "
191
+ '{"title": "<short imperative title>", "rule": "<1-3 sentence rule in first person>"}.'
192
+ )
193
+
194
+ _GROUP_SUMMARY_SYSTEM_PROMPT = (
195
+ "You write a one-to-two sentence synthesis of a cluster of related topics "
196
+ "from a personal knowledge base.\n"
197
+ "\n"
198
+ "Inputs: PERSON/SCOPE (the person the themes are about, if any), TOPICS "
199
+ "(the cluster's key entities), and REPRESENTATIVE DOCUMENTS (their titles).\n"
200
+ "\n"
201
+ "Return ONLY valid JSON:\n"
202
+ '{"summary": "..."}\n'
203
+ "\n"
204
+ "Rules:\n"
205
+ "- 1-2 sentences, factual, plain past tense\n"
206
+ "- name the specific topics that tie the cluster together\n"
207
+ "- never invent facts, never exceed 50 words"
208
+ )
209
+
210
+ # Plan 05 — `brain timeline --synthesize`. Per-time-bucket narrative synthesis
211
+ # GROUNDED in the period's document SUMMARIES (not just titles): write a concrete
212
+ # "what happened this period" line naming the actual decisions / deliverables /
213
+ # problems. When a PREVIOUS period is supplied the model states what CHANGED.
214
+ # Best-effort + never-raises, same discipline as :data:`_GROUP_SUMMARY_SYSTEM_PROMPT`.
215
+ _BUCKET_SUMMARY_SYSTEM_PROMPT = (
216
+ "You write a 1-3 sentence synthesis of what concretely happened in one time "
217
+ "period for a recurring theme in a personal knowledge base. You are graded on "
218
+ "SPECIFICITY: a reader must learn something they could not guess from the "
219
+ "topic name alone.\n"
220
+ "\n"
221
+ "Inputs: PERIOD (the time-bucket label), ENTITY (the theme tracked over "
222
+ "time), CO-TOPICS (entities that co-occurred this period), DOCUMENT SUMMARIES "
223
+ "(the period's source-document summaries — your PRIMARY evidence), DOCUMENT "
224
+ "TITLES, and optionally PREVIOUS PERIOD (the prior bucket's label, co-topics, "
225
+ "and synthesis) for contrast.\n"
226
+ "\n"
227
+ "Return ONLY valid JSON:\n"
228
+ '{"summary": "..."}\n'
229
+ "\n"
230
+ "Rules:\n"
231
+ "- Ground EVERY claim in the DOCUMENT SUMMARIES. Name the concrete specifics "
232
+ "they contain: decisions made, deliverables or artifacts produced, problems "
233
+ "or blockers hit, and named systems / tools / documents.\n"
234
+ "- If a PREVIOUS PERIOD is given, state what CHANGED versus it (a new focus, a "
235
+ "decision reversed, a problem resolved) — but ONLY when the summaries support "
236
+ "it; never fabricate a change.\n"
237
+ "- FORBIDDEN — these phrasings are an automatic failure: 'discussions about', "
238
+ "'discussions on', 'conversations around', 'connecting to', 'topics related "
239
+ "to', 'focused on various', 'a range of', 'integration, reports, and "
240
+ "planning', or any vacuous paraphrase of the topic / co-topic names.\n"
241
+ "- 1-3 sentences, factual, plain past tense. Never invent facts. Never exceed "
242
+ "60 words. If the summaries are too thin to say anything concrete, write a "
243
+ "single short factual sentence rather than padding with generic filler."
244
+ )
245
+
246
+ # Wave elicit Wave 4 — contradiction detection between document summaries.
247
+ # The model is given a subject entity and several summary excerpts from
248
+ # different documents and must decide whether they express CONTRADICTORY
249
+ # positions or decisions about that entity (a reversed decision, opposing
250
+ # stance, or conflicting fact).
251
+ _CONTRADICTION_SYSTEM_PROMPT = (
252
+ "You are given an entity subject and several summary excerpts drawn from "
253
+ "different documents in a personal knowledge base. Your task is to decide "
254
+ "whether the excerpts express CONTRADICTORY positions, decisions, or facts "
255
+ "about that entity — for example a reversed decision, an opposing stance, "
256
+ "or a directly conflicting statement of fact. Minor differences in emphasis "
257
+ "or level of detail are NOT contradictions. Return ONLY valid JSON:\n"
258
+ '{"contradicts": <true|false>, "rationale": "<one sentence>"}\n'
259
+ "\n"
260
+ "Rules:\n"
261
+ '- "contradicts" MUST be a JSON boolean (true or false, no quotes)\n'
262
+ "- \"rationale\" must be a single sentence explaining the contradiction (or lack thereof)\n"
263
+ "- never invent facts not present in the excerpts\n"
264
+ "- when in doubt, return false"
265
+ )
266
+
267
+ # Token budget for the concept-extraction completion. Larger than the
268
+ # summary/tag budget (256) because a document can yield a long entity list.
269
+ _EXTRACT_NUM_PREDICT = 1024
270
+
271
+ # Recognized string forms for a boolean ``contradicts`` verdict. The model is
272
+ # instructed to return a JSON boolean, but small local models occasionally
273
+ # stringify it; we tolerate ONLY these exact (stripped, lowercased) forms and
274
+ # raise on anything else rather than silently coercing arbitrary truthiness.
275
+ _CONTRADICTS_TRUE_STRINGS = frozenset({"true", "yes", "1"})
276
+ _CONTRADICTS_FALSE_STRINGS = frozenset({"false", "no", "0"})
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class SummaryResult:
281
+ """One enrichment summary. Stored byte-for-byte on ``documents.summary``.
282
+
283
+ ``model`` is the model fingerprint (e.g. ``"llama3.1:8b"``) so the
284
+ post-ingest hook can persist it onto ``documents.summary_model`` and
285
+ ``brain enrich --backfill --remodel`` can detect drift after a model
286
+ swap (without ``--remodel``, backfill is NULL-only and ignores
287
+ fingerprint mismatches).
288
+ """
289
+
290
+ summary: str
291
+ model: str
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class RuleDraft:
296
+ """A drafted tacit rule for the user to confirm or correct.
297
+
298
+ ``title`` is a short imperative phrase; ``rule_text`` is the 1-3 sentence
299
+ first-person assertion. ``model`` is the Ollama model fingerprint so
300
+ callers can persist provenance alongside the draft.
301
+ """
302
+
303
+ title: str
304
+ rule_text: str
305
+ model: str
306
+
307
+
308
+ @dataclass(frozen=True)
309
+ class ContradictionVerdict:
310
+ """Assessment of whether document summaries express conflicting positions.
311
+
312
+ ``contradicts`` is True when the LLM detected a genuine contradiction
313
+ (reversed decision, opposing stance, conflicting fact) in the evidence
314
+ summaries for a given subject entity. ``rationale`` is a one-sentence
315
+ explanation suitable for display in ``brain elicit``.
316
+ """
317
+
318
+ contradicts: bool
319
+ rationale: str
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class TagProposal:
324
+ """The LLM's proposed tag set for one document, partitioned by novelty.
325
+
326
+ ``existing`` are tags already used somewhere in the corpus; ``new`` are
327
+ tags the LLM proposed that aren't in the vocabulary yet (already
328
+ rate-limited to ≤``max_new`` entries — default 1 — by
329
+ :meth:`OllamaEnricher.propose_tags`).
330
+ """
331
+
332
+ existing: list[str]
333
+ new: list[str]
334
+
335
+
336
+ class OllamaEnricher:
337
+ """Thin wrapper over Ollama's ``/api/chat`` endpoint with JSON mode.
338
+
339
+ One instance, one model. Callers (post-ingest hook, ``brain enrich``,
340
+ ``brain tag --auto``) instantiate via :func:`make_enricher` and reuse
341
+ the same instance for the lifetime of a CLI invocation.
342
+
343
+ Public surface:
344
+ - ``summarize(title, content)`` → :class:`SummaryResult`
345
+ - ``propose_tags(title, summary, existing_vocab, current_tags, max_new=1)``
346
+ → :class:`TagProposal`
347
+ - ``extract_entities(text)`` → ``list[Any]`` (raw concept-entity
348
+ candidates; the concept-extraction *logic* lives in
349
+ :mod:`brain.graph_rag.extract`)
350
+ - ``summarize_group(person, entity_names, doc_titles)`` → ``str | None``
351
+ (best-effort theme synthesis; never raises)
352
+ - ``summarize_bucket(bucket_label, entity_name, doc_titles, cotopics)``
353
+ → ``str | None`` (best-effort timeline-bucket synthesis; never raises)
354
+ - ``count_tokens(text)`` → int (tiktoken ``cl100k_base``)
355
+ - ``model`` (read-only attribute) → the model fingerprint
356
+ """
357
+
358
+ def __init__(
359
+ self,
360
+ *,
361
+ host: str,
362
+ model: str,
363
+ client: httpx.Client | None = None,
364
+ timeout: float = 60.0,
365
+ max_input_tokens: int = 4000,
366
+ keep_alive: str = _DEFAULT_KEEP_ALIVE,
367
+ ) -> None:
368
+ self._host = host
369
+ self._model = model
370
+ self._max_input_tokens = max_input_tokens
371
+ self._keep_alive = keep_alive
372
+ self._tokenizer = tiktoken.get_encoding("cl100k_base")
373
+ if client is not None:
374
+ self._client = client
375
+ else:
376
+ self._client = httpx.Client(
377
+ base_url=host, timeout=httpx.Timeout(timeout)
378
+ )
379
+
380
+ @property
381
+ def model(self) -> str:
382
+ return self._model
383
+
384
+ def count_tokens(self, text: str) -> int:
385
+ """Return the number of tokens in ``text`` per ``cl100k_base``."""
386
+ return len(self._tokenizer.encode(text))
387
+
388
+ def truncate_to_tokens(self, text: str, max_tokens: int) -> str:
389
+ """Return the head of ``text`` truncated to its first ``max_tokens`` tokens.
390
+
391
+ Head-only truncation using the SAME ``cl100k_base`` tokenizer as
392
+ :meth:`count_tokens`, so a caller's truncation boundary is consistent with
393
+ the chunk budgeting that also goes through ``count_tokens`` — the concept
394
+ extractor's input head cap (perf Fix C, 2026-05-24) reuses this so its cap
395
+ and its per-chunk token sizing agree. The full text is returned unchanged
396
+ when it already fits. ``max_tokens`` must be positive. Cheap: one encode +
397
+ one decode.
398
+ """
399
+ if max_tokens < 1:
400
+ raise ValueError(
401
+ f"max_tokens must be a positive integer (got {max_tokens})"
402
+ )
403
+ ids = self._tokenizer.encode(text)
404
+ if len(ids) <= max_tokens:
405
+ return text
406
+ return self._tokenizer.decode(ids[:max_tokens])
407
+
408
+ def _truncate_to_budget(self, text: str) -> str:
409
+ """Truncate ``text`` to this enricher's summary input budget (head-only).
410
+
411
+ Delegates to :meth:`truncate_to_tokens` with ``self._max_input_tokens``
412
+ (the per-instance summary cap, default 4000): the opening of a doc is the
413
+ highest-signal portion for transcripts, emails, and notes alike.
414
+ """
415
+ return self.truncate_to_tokens(text, self._max_input_tokens)
416
+
417
+ def summarize(self, title: str, content: str) -> SummaryResult:
418
+ """Return a 2-3 sentence summary of ``content``.
419
+
420
+ Calls ``/api/chat`` with ``format='json'`` and a system prompt that
421
+ constrains the model to emit ``{"summary": "..."}``. On JSON parse
422
+ failure or missing key, retries ONCE with the same prompt. Second
423
+ failure raises :class:`EnrichmentError`. Connection / 5xx errors
424
+ raise :class:`OllamaUnavailable` immediately (no retry — Ollama-down
425
+ is the common case and we want to fail fast so the post-ingest hook
426
+ can skip).
427
+ """
428
+ truncated = self._truncate_to_budget(content)
429
+ user_message = f"TITLE: {title}\n\nBODY:\n{truncated}"
430
+ body = self._chat_with_retry(
431
+ system=_SUMMARY_SYSTEM_PROMPT,
432
+ user=user_message,
433
+ schema_keys=("summary",),
434
+ )
435
+ summary = body["summary"]
436
+ if not isinstance(summary, str) or not summary.strip():
437
+ raise EnrichmentError(
438
+ f"summarize: model returned non-string / empty summary: {body!r}"
439
+ )
440
+ return SummaryResult(summary=summary.strip(), model=self._model)
441
+
442
+ def propose_tags(
443
+ self,
444
+ *,
445
+ title: str,
446
+ summary: str,
447
+ existing_vocab: list[str],
448
+ current_tags: list[str],
449
+ max_new: int = 1,
450
+ ) -> TagProposal:
451
+ """Return 3-5 proposed tags partitioned into existing-vocab + new.
452
+
453
+ Returned tags are normalized via :func:`brain.tags.normalize_tags`,
454
+ de-duplicated against ``current_tags`` (we never re-propose tags
455
+ already on the doc), banned-format tags are dropped at the Python
456
+ layer (D20 belt-and-braces), and the ``new`` partition is truncated
457
+ to the first ``max_new`` entries (default 1) per the R2 mitigation
458
+ in the wave plan.
459
+ """
460
+ if max_new < 0:
461
+ raise ValueError(f"max_new must be non-negative (got {max_new})")
462
+ # Normalize the vocab + current-tags so the LLM sees canonical forms
463
+ # and the post-call partitioning compares normalized to normalized.
464
+ vocab_normalized = normalize_tags(existing_vocab)
465
+ current_normalized = set(normalize_tags(current_tags))
466
+ user_message = (
467
+ f"TITLE: {title}\n\n"
468
+ f"SUMMARY: {summary}\n\n"
469
+ f"EXISTING_VOCAB: {json.dumps(vocab_normalized)}\n"
470
+ f"CURRENT_TAGS: {json.dumps(sorted(current_normalized))}"
471
+ )
472
+ body = self._chat_with_retry(
473
+ system=_TAG_SYSTEM_PROMPT,
474
+ user=user_message,
475
+ schema_keys=("tags",),
476
+ )
477
+ raw_tags = body["tags"]
478
+ if not isinstance(raw_tags, list) or not all(
479
+ isinstance(t, str) for t in raw_tags
480
+ ):
481
+ raise EnrichmentError(
482
+ f"propose_tags: model returned non-list-of-str tags: {body!r}"
483
+ )
484
+ normalized = normalize_tags(raw_tags)
485
+ # Drop banned formats (D20) BEFORE partitioning so they can't sneak
486
+ # into either bucket.
487
+ normalized = [t for t in normalized if t not in _BANNED_AUTO_TAGS]
488
+ # Drop anything already on the doc — no point proposing what's there.
489
+ normalized = [t for t in normalized if t not in current_normalized]
490
+ vocab_set = set(vocab_normalized)
491
+ existing: list[str] = []
492
+ new: list[str] = []
493
+ for tag in normalized:
494
+ if tag in vocab_set:
495
+ existing.append(tag)
496
+ else:
497
+ new.append(tag)
498
+ return TagProposal(existing=existing, new=new[:max_new])
499
+
500
+ def extract_entities(self, text: str) -> list[Any]:
501
+ """Return the model's raw concept-entity candidates for one text block.
502
+
503
+ One ``/api/chat`` JSON-mode round-trip with the concept-extraction
504
+ system prompt. Returns the model's ``entities`` array verbatim — a list
505
+ whose individual elements the caller (:class:`brain.graph_rag.extract.
506
+ OllamaExtractor`) validates, canonicalizes, positions, dedups, and caps.
507
+ This method does NOT chunk: the extractor sizes each ``text`` block to
508
+ the model context and calls once per chunk.
509
+
510
+ Raises :class:`OllamaUnavailable` / :class:`EnrichmentError` on transport
511
+ / malformed-response failures exactly like :meth:`summarize`. The
512
+ never-raise boundary is the :class:`~brain.graph_rag.extract.
513
+ OllamaExtractor`, which catches these so a gated ingest never breaks
514
+ (spec §17b decision 7 / §7 never-raise discipline).
515
+ """
516
+ body = self._chat_with_retry(
517
+ system=_EXTRACT_SYSTEM_PROMPT,
518
+ user=f"TEXT:\n{text}",
519
+ schema_keys=("entities",),
520
+ num_predict=_EXTRACT_NUM_PREDICT,
521
+ )
522
+ entities = body["entities"]
523
+ if not isinstance(entities, list):
524
+ raise EnrichmentError(
525
+ f"extract_entities: model returned non-list entities: {body!r}"
526
+ )
527
+ return entities
528
+
529
+ def summarize_group(
530
+ self,
531
+ *,
532
+ person: str | None,
533
+ entity_names: list[str],
534
+ doc_titles: list[str],
535
+ ) -> str | None:
536
+ """Best-effort one-to-two sentence synthesis of a theme group.
537
+
538
+ Used by the G2-f ``--synthesize`` path (spec §6b step 4 / §17b decision
539
+ 7): opt-in, default-off, and **never required for retrieval**. Returns
540
+ the synthesized text, or ``None`` (logging a WARN) when Ollama is
541
+ unavailable / times out / returns invalid JSON / yields an empty
542
+ summary. NEVER raises — mirroring the GraphSyncer + enrich never-raise
543
+ discipline so a retrieval path never becomes a hard live-Ollama
544
+ dependency.
545
+ """
546
+ scope = f" {person}" if person else " (none)"
547
+ user_message = (
548
+ f"PERSON/SCOPE:{scope}\n"
549
+ f"TOPICS: {json.dumps(entity_names)}\n"
550
+ f"REPRESENTATIVE DOCUMENTS: {json.dumps(doc_titles)}"
551
+ )
552
+ try:
553
+ body = self._chat_with_retry(
554
+ system=_GROUP_SUMMARY_SYSTEM_PROMPT,
555
+ user=user_message,
556
+ schema_keys=("summary",),
557
+ )
558
+ except EnrichmentError as exc:
559
+ # OllamaUnavailable is an EnrichmentError subclass, so this single
560
+ # clause covers transport failure AND malformed-JSON-twice.
561
+ _logger.warning(
562
+ "summarize_group: synthesis failed (%s); returning summary=None",
563
+ exc,
564
+ )
565
+ return None
566
+ summary = body["summary"]
567
+ if not isinstance(summary, str) or not summary.strip():
568
+ _logger.warning(
569
+ "summarize_group: model returned empty / non-string summary; "
570
+ "returning summary=None"
571
+ )
572
+ return None
573
+ return summary.strip()
574
+
575
+ def summarize_bucket(
576
+ self,
577
+ *,
578
+ bucket_label: str,
579
+ entity_name: str,
580
+ doc_titles: list[str],
581
+ cotopics: list[str],
582
+ doc_summaries: list[str] | None = None,
583
+ prev_bucket_label: str | None = None,
584
+ prev_cotopics: list[str] | None = None,
585
+ prev_synthesis: str | None = None,
586
+ ) -> str | None:
587
+ """Best-effort 1-3 sentence, content-grounded synthesis of one timeline bucket.
588
+
589
+ Used by the Plan 05 ``brain timeline --synthesize`` path: opt-in,
590
+ default-off, and **never required** for the timeline to render.
591
+
592
+ ``doc_summaries`` is the period's source-document summaries (the caller
593
+ token-budgets the bundle and falls back to titles where a summary is
594
+ NULL). They are the model's PRIMARY evidence — passing them is what makes
595
+ the synthesis concrete instead of a paraphrase of the titles. The
596
+ ``prev_*`` parameters carry the chronologically PREVIOUS bucket's label /
597
+ co-topics / already-generated synthesis so the model can state what
598
+ CHANGED (the caller synthesizes oldest→newest so a prior synthesis is
599
+ available). All four new params are optional and default-absent so older
600
+ callers keep working.
601
+
602
+ Returns the synthesized text, or ``None`` (logging a WARN) when Ollama is
603
+ unavailable / times out / returns invalid JSON / yields an empty summary.
604
+ NEVER raises — mirroring :meth:`summarize_group`'s never-raise discipline
605
+ so the display command never becomes a hard live-Ollama dependency.
606
+ """
607
+ parts = [
608
+ f"PERIOD: {bucket_label}",
609
+ f"ENTITY: {entity_name}",
610
+ f"CO-TOPICS: {json.dumps(cotopics)}",
611
+ f"DOCUMENT SUMMARIES: {json.dumps(doc_summaries or [])}",
612
+ f"DOCUMENT TITLES: {json.dumps(doc_titles)}",
613
+ ]
614
+ if prev_bucket_label:
615
+ prev_lines = [f"PREVIOUS PERIOD: {prev_bucket_label}"]
616
+ prev_lines.append(f"PREVIOUS CO-TOPICS: {json.dumps(prev_cotopics or [])}")
617
+ if prev_synthesis:
618
+ prev_lines.append(f"PREVIOUS SYNTHESIS: {prev_synthesis}")
619
+ parts.append("\n".join(prev_lines))
620
+ user_message = "\n".join(parts)
621
+ try:
622
+ body = self._chat_with_retry(
623
+ system=_BUCKET_SUMMARY_SYSTEM_PROMPT,
624
+ user=user_message,
625
+ schema_keys=("summary",),
626
+ )
627
+ except EnrichmentError as exc:
628
+ # OllamaUnavailable is an EnrichmentError subclass, so this single
629
+ # clause covers transport failure AND malformed-JSON-twice.
630
+ _logger.warning(
631
+ "summarize_bucket: synthesis failed (%s); returning summary=None",
632
+ exc,
633
+ )
634
+ return None
635
+ summary = body["summary"]
636
+ if not isinstance(summary, str) or not summary.strip():
637
+ _logger.warning(
638
+ "summarize_bucket: model returned empty / non-string summary; "
639
+ "returning summary=None"
640
+ )
641
+ return None
642
+ return summary.strip()
643
+
644
+ def draft_rule(self, *, subject: str, evidence_texts: list[str]) -> RuleDraft:
645
+ """Articulate the tacit rule implied by ``subject`` and ``evidence_texts``.
646
+
647
+ Calls ``/api/chat`` with :data:`_ELICIT_SYSTEM_PROMPT` and a numbered
648
+ excerpt prompt built by :meth:`_build_elicit_user_prompt`. The caller
649
+ (``GapDrafter``) is responsible for pre-truncating ``evidence_texts``
650
+ to a reasonable token budget; this method joins them as-is.
651
+
652
+ Raises :class:`OllamaUnavailable` on transport failure and
653
+ :class:`EnrichmentError` on two consecutive bad responses — the same
654
+ contract as :meth:`summarize`.
655
+ """
656
+ user = self._build_elicit_user_prompt(subject, evidence_texts)
657
+ data = self._chat_with_retry(
658
+ system=_ELICIT_SYSTEM_PROMPT,
659
+ user=user,
660
+ schema_keys=("title", "rule"),
661
+ num_predict=256,
662
+ )
663
+ title = data["title"]
664
+ if not isinstance(title, str) or not title.strip():
665
+ raise EnrichmentError(
666
+ f"draft_rule: model returned non-string / empty title: {data!r}"
667
+ )
668
+ rule_text = data["rule"]
669
+ if not isinstance(rule_text, str) or not rule_text.strip():
670
+ raise EnrichmentError(
671
+ f"draft_rule: model returned non-string / empty rule: {data!r}"
672
+ )
673
+ return RuleDraft(
674
+ title=title.strip(),
675
+ rule_text=rule_text.strip(),
676
+ model=self._model,
677
+ )
678
+
679
+ def _build_elicit_user_prompt(
680
+ self, subject: str, evidence_texts: list[str]
681
+ ) -> str:
682
+ """Compose the user-turn prompt for :meth:`draft_rule`.
683
+
684
+ Joins ``subject`` and numbered evidence excerpts into a clear prompt.
685
+ Evidence texts are assumed pre-truncated by the caller; this method
686
+ only formats them.
687
+ """
688
+ parts = [f"SUBJECT: {subject}", ""]
689
+ for i, text in enumerate(evidence_texts, 1):
690
+ parts.append(f"EXCERPT {i}:\n{text}")
691
+ return "\n".join(parts)
692
+
693
+ def assess_contradiction(
694
+ self, *, subject: str, summaries: list[str]
695
+ ) -> ContradictionVerdict:
696
+ """Decide whether ``summaries`` express contradictory positions about ``subject``.
697
+
698
+ Calls ``/api/chat`` with :data:`_CONTRADICTION_SYSTEM_PROMPT` and a
699
+ numbered-excerpt prompt. Returns a :class:`ContradictionVerdict` with
700
+ ``contradicts=True`` when a genuine conflict is detected.
701
+
702
+ Raises :class:`OllamaUnavailable` on transport failure and
703
+ :class:`EnrichmentError` on two consecutive bad responses — the same
704
+ contract as :meth:`draft_rule`.
705
+ """
706
+ user = self._build_contradiction_user_prompt(subject, summaries)
707
+ data = self._chat_with_retry(
708
+ system=_CONTRADICTION_SYSTEM_PROMPT,
709
+ user=user,
710
+ schema_keys=("contradicts", "rationale"),
711
+ num_predict=200,
712
+ )
713
+ contradicts = self._coerce_contradicts(data)
714
+ rationale = data["rationale"]
715
+ if not isinstance(rationale, str) or not rationale.strip():
716
+ raise EnrichmentError(
717
+ "assess_contradiction: model returned non-string / empty "
718
+ f"rationale: {data!r}"
719
+ )
720
+ return ContradictionVerdict(
721
+ contradicts=contradicts, rationale=rationale.strip()
722
+ )
723
+
724
+ @staticmethod
725
+ def _coerce_contradicts(data: dict[str, Any]) -> bool:
726
+ """Validate + coerce the ``contradicts`` field into a real bool.
727
+
728
+ Accepts a JSON boolean directly. Tolerates the common stringified
729
+ forms ("true"/"false"/"yes"/"no"/"1"/"0", case-insensitive) some local
730
+ models emit. Anything else — a number, a dict, an unrecognized string —
731
+ raises :class:`EnrichmentError` (parity with :meth:`draft_rule`, which
732
+ refuses to coerce malformed output) rather than silently treating
733
+ arbitrary truthiness as a verdict.
734
+ """
735
+ value = data["contradicts"]
736
+ if isinstance(value, bool):
737
+ return value
738
+ if isinstance(value, str):
739
+ token = value.strip().lower()
740
+ if token in _CONTRADICTS_TRUE_STRINGS:
741
+ return True
742
+ if token in _CONTRADICTS_FALSE_STRINGS:
743
+ return False
744
+ raise EnrichmentError(
745
+ f"assess_contradiction: non-boolean contradicts verdict: {data!r}"
746
+ )
747
+
748
+ def _build_contradiction_user_prompt(
749
+ self, subject: str, summaries: list[str]
750
+ ) -> str:
751
+ """Compose the user-turn prompt for :meth:`assess_contradiction`.
752
+
753
+ Joins ``subject`` and numbered summary excerpts into a clear prompt.
754
+ """
755
+ parts = [f"SUBJECT: {subject}", ""]
756
+ for i, summary in enumerate(summaries, 1):
757
+ parts.append(f"SUMMARY {i}:\n{summary}")
758
+ return "\n".join(parts)
759
+
760
+ def _chat_with_retry(
761
+ self,
762
+ *,
763
+ system: str,
764
+ user: str,
765
+ schema_keys: tuple[str, ...],
766
+ num_predict: int = 256,
767
+ ) -> dict[str, Any]:
768
+ """Issue an ``/api/chat`` JSON-mode call with one retry on parse failure.
769
+
770
+ Thin adapter over the shared transport + retry core in
771
+ :func:`brain.chat.chat_json_with_client`. Preserves the enricher's
772
+ exact two-message (system + user) wire shape and its persistent
773
+ injected ``httpx.Client`` (the retry / truncation-bump / transport
774
+ error policy lives once, in ``chat``). Returns the parsed JSON object;
775
+ raises :class:`~brain.errors.OllamaUnavailable` on transport failure
776
+ (no retry) and :class:`EnrichmentError` on two consecutive bad
777
+ responses — the unchanged contract every caller depends on.
778
+ """
779
+ messages: list[ChatMessage] = [
780
+ {"role": "system", "content": system},
781
+ {"role": "user", "content": user},
782
+ ]
783
+ return chat_json_with_client(
784
+ self._client,
785
+ model=self._model,
786
+ messages=messages,
787
+ required_keys=schema_keys,
788
+ keep_alive=self._keep_alive,
789
+ num_predict=num_predict,
790
+ )
791
+
792
+
793
+ def make_enricher(cfg: Config) -> OllamaEnricher:
794
+ """Factory — same shape as :func:`brain.embeddings.make_embedder`.
795
+
796
+ Reads ``cfg.enrich_model`` / ``cfg.ollama_host`` /
797
+ ``cfg.enrich_max_input_tokens`` / ``cfg.enrich_timeout_seconds`` and
798
+ always returns an :class:`OllamaEnricher` (single-backend wave). Tests
799
+ pass a fake via direct construction or by substituting at the call site;
800
+ production code goes through this factory.
801
+ """
802
+ return OllamaEnricher(
803
+ host=cfg.ollama_host,
804
+ model=cfg.enrich_model,
805
+ timeout=cfg.enrich_timeout_seconds,
806
+ max_input_tokens=cfg.enrich_max_input_tokens,
807
+ keep_alive=cfg.ollama_keep_alive,
808
+ )