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/ask.py ADDED
@@ -0,0 +1,631 @@
1
+ """Agentic multi-hop cited answer synthesis (`brain ask`, Plan 06).
2
+
3
+ Orchestrates a plan -> retrieve -> reflect -> synthesize loop over the existing
4
+ hybrid-search (and optional graph) retrieval primitives, then composes a single
5
+ cited answer. This module owns ONLY the loop + the LLM prompt strings; it has no
6
+ CLI or MCP knowledge. The three LLM steps go through an injected
7
+ :data:`ChatJson` callable (the public :func:`brain.chat.chat_json`) so the whole
8
+ loop is unit-testable with a fake chat and never needs a live Ollama.
9
+
10
+ Privacy: the prompts receive document ``title + snippet`` only (snippet capped at
11
+ :data:`_SYNTH_SNIPPET_CHARS`), never full bodies -- mirroring the enricher's
12
+ head-only truncation policy so no transcript/email body is sent to the LLM.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING, Any, Protocol
21
+
22
+ import psycopg
23
+
24
+ from .chat import coerce_bool
25
+ from .search import SearchResult, hybrid_search
26
+
27
+ if TYPE_CHECKING:
28
+ from .config import Config
29
+ from .graph_rag.backends.base import GraphBackend
30
+ from .graph_rag.schema import GraphContext
31
+ from .ingest import Embedder
32
+
33
+ _logger = logging.getLogger(__name__)
34
+
35
+ # Retrieval mode vocabulary accepted by `brain ask`. ``hybrid`` (the default) is
36
+ # vector/FTS only; the other three add a graph leg via ``graph_rag_search``.
37
+ HYBRID_MODE = "hybrid"
38
+ ASK_MODES: frozenset[str] = frozenset({"hybrid", "auto", "fuse", "local"})
39
+
40
+ # Fallback default when a caller omits ``max_iterations``; the CLI/MCP layers
41
+ # pass ``cfg.ask_max_iterations`` explicitly (this mirrors the plan signature
42
+ # default of 3 without importing Config at module load time).
43
+ _DEFAULT_MAX_ITERATIONS = 3
44
+
45
+ # Per-LLM-step completion-length budgets (tokens). Plan/reflect are tiny JSON
46
+ # objects; synthesize needs room for a few sentences of cited prose.
47
+ _PLAN_NUM_PREDICT = 128
48
+ _REFLECT_NUM_PREDICT = 128
49
+ _SYNTH_NUM_PREDICT = 512
50
+
51
+ # Sub-query count bounds returned by the plan step.
52
+ _MAX_SUB_QUERIES = 3
53
+ # Follow-up sub-query count bound returned by the reflect step.
54
+ _MAX_FOLLOW_UPS = 2
55
+
56
+ # Snippet truncation for the reflect step (titles + short snippets only).
57
+ _REFLECT_SNIPPET_CHARS = 120
58
+ # Snippet truncation for the synthesize step (privacy / context-window safety).
59
+ _SYNTH_SNIPPET_CHARS = 400
60
+
61
+ # Matches a ``[N]`` citation marker (1+ digits) in a synthesized answer.
62
+ _CITATION_RE = re.compile(r"\[(\d+)\]")
63
+
64
+ # Matches a ``[N]`` marker plus any leading whitespace, for sanitizing dangling
65
+ # (out-of-range) markers out of the final answer text — the marker AND the space
66
+ # that precedes it are removed so " foo [99] bar" collapses cleanly to "foo bar".
67
+ _MARKER_WITH_LEAD_RE = re.compile(r"\s*\[(\d+)\]")
68
+
69
+
70
+ class ChatJson(Protocol):
71
+ """Structural type of :func:`brain.chat.chat_json` (the injected LLM seam).
72
+
73
+ Production passes the real ``chat_json``; tests pass a closure with the same
74
+ signature. Keeping it a ``Protocol`` (not a hard import of the function) lets
75
+ the loop stay decoupled from the transport and trivially mockable.
76
+ """
77
+
78
+ def __call__(
79
+ self,
80
+ prompt: str,
81
+ *,
82
+ schema: dict[str, Any],
83
+ cfg: Config,
84
+ model: str | None = ...,
85
+ num_predict: int | None = ...,
86
+ timeout: float | None = ...,
87
+ ) -> dict[str, Any]: ...
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Citation:
92
+ """One cited source backing an answer, mapped from a ``[N]`` marker.
93
+
94
+ ``ref`` is the 1-indexed number that appears as ``[ref]`` in the answer text
95
+ (NOT renumbered, so the marker always resolves to a real source).
96
+ """
97
+
98
+ ref: int
99
+ document_id: str
100
+ title: str
101
+ source_kind: str | None
102
+ snippet: str
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ """JSON projection (CLI ``--json`` / MCP wire shape)."""
106
+ return {
107
+ "ref": self.ref,
108
+ "document_id": self.document_id,
109
+ "title": self.title,
110
+ "source_kind": self.source_kind,
111
+ "snippet": self.snippet,
112
+ }
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class AskResult:
117
+ """The synthesized answer plus its provenance.
118
+
119
+ ``citations`` are ordered by first appearance of their ``[ref]`` marker in
120
+ ``answer`` and contain ONLY sources the answer actually cites.
121
+ ``fallback_used`` is ``True`` when the agentic planning loop was skipped or
122
+ degraded: the ``no_loop`` fast path, or a plan step that yielded no usable
123
+ sub-queries (so the raw question was used as the single sub-query).
124
+ """
125
+
126
+ answer: str
127
+ citations: list[Citation]
128
+ iterations_used: int
129
+ sub_queries: list[str]
130
+ fallback_used: bool
131
+ session_id: str
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ """JSON projection shared by the CLI ``--json`` output and the MCP tool."""
135
+ return {
136
+ "answer": self.answer,
137
+ "citations": [c.to_dict() for c in self.citations],
138
+ "iterations_used": self.iterations_used,
139
+ "sub_queries": list(self.sub_queries),
140
+ "fallback_used": self.fallback_used,
141
+ "session_id": self.session_id,
142
+ }
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class _ReflectVerdict:
147
+ """Parsed result of the reflect step."""
148
+
149
+ sufficient: bool
150
+ follow_up_queries: list[str]
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # LLM step prompts + parsers (pure-logic; mockable via the injected ``chat``)
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ def _plan_prompt(question: str) -> str:
159
+ """Build the plan-step prompt: decompose ``question`` into sub-queries."""
160
+ return (
161
+ "You are a retrieval planner for a personal knowledge base. Decompose "
162
+ "the user's question into 1 to 3 focused search sub-queries that, "
163
+ "together, would retrieve the documents needed to answer it. Each "
164
+ "sub-query should be a short keyword phrase, not a full sentence. "
165
+ "Respond ONLY with a JSON object of the form "
166
+ '{"sub_queries": ["...", "..."]}.\n\n'
167
+ f"Question: {question}"
168
+ )
169
+
170
+
171
+ def _call_plan_step(chat: ChatJson, cfg: Config, question: str) -> list[str]:
172
+ """Run the plan step and return 1-3 sub-queries (clamped + de-duplicated).
173
+
174
+ Returns an empty list only if the model produced no usable string
175
+ sub-queries; the caller then falls back to ``[question]``.
176
+ """
177
+ body = chat(
178
+ _plan_prompt(question),
179
+ schema={"sub_queries": "list"},
180
+ cfg=cfg,
181
+ model=cfg.ask_model,
182
+ num_predict=_PLAN_NUM_PREDICT,
183
+ timeout=cfg.ask_timeout_seconds,
184
+ )
185
+ return _clean_query_list(body.get("sub_queries"), limit=_MAX_SUB_QUERIES)
186
+
187
+
188
+ def _reflect_prompt(question: str, docs: list[SearchResult]) -> str:
189
+ """Build the reflect-step prompt: is coverage sufficient for ``question``?"""
190
+ lines = [
191
+ "You are assessing whether the retrieved documents are sufficient to "
192
+ "answer the user's question. If they are sufficient, respond with "
193
+ '{"sufficient": true, "follow_up_queries": []}. If not, respond with '
194
+ '{"sufficient": false, "follow_up_queries": ["...", "..."]} providing '
195
+ "up to 2 NEW short search sub-queries that would fill the gaps. "
196
+ "Respond ONLY with that JSON object.\n",
197
+ f"Question: {question}\n",
198
+ "Retrieved documents:",
199
+ ]
200
+ if docs:
201
+ for i, doc in enumerate(docs, start=1):
202
+ snippet = _truncate(doc.snippet, _REFLECT_SNIPPET_CHARS)
203
+ lines.append(f"[{i}] {doc.title} — {snippet}")
204
+ else:
205
+ lines.append("(none retrieved)")
206
+ return "\n".join(lines)
207
+
208
+
209
+ def _call_reflect_step(
210
+ chat: ChatJson, cfg: Config, question: str, docs: list[SearchResult]
211
+ ) -> _ReflectVerdict:
212
+ """Run the reflect step and return the sufficiency verdict + follow-ups."""
213
+ body = chat(
214
+ _reflect_prompt(question, docs),
215
+ schema={"sufficient": "bool", "follow_up_queries": "list"},
216
+ cfg=cfg,
217
+ model=cfg.ask_model,
218
+ num_predict=_REFLECT_NUM_PREDICT,
219
+ timeout=cfg.ask_timeout_seconds,
220
+ )
221
+ # Coerce defensively: a local model that returns ``"sufficient": "false"``
222
+ # would be treated as True by a bare ``bool(...)`` (non-empty string is
223
+ # truthy), stopping the loop early. ``coerce_bool`` maps stringified/int
224
+ # booleans correctly and defaults unparseable verdicts to False (insufficient)
225
+ # so the loop keeps retrieving rather than truncating on garbage.
226
+ sufficient = coerce_bool(body.get("sufficient"))
227
+ follow_ups = _clean_query_list(
228
+ body.get("follow_up_queries"), limit=_MAX_FOLLOW_UPS
229
+ )
230
+ return _ReflectVerdict(sufficient=sufficient, follow_up_queries=follow_ups)
231
+
232
+
233
+ def _synthesize_prompt(
234
+ question: str, docs: list[SearchResult], graph_summary: str
235
+ ) -> str:
236
+ """Build the synthesize-step prompt over numbered title+snippet docs."""
237
+ lines = [
238
+ "You are answering the user's question using ONLY the numbered source "
239
+ "documents below. Write a direct, concise answer. After each claim, cite "
240
+ "the source it came from using its bracketed number exactly as shown "
241
+ "below — for example [1] or [2] (use the real digits, never a literal "
242
+ "letter). If the documents do not contain enough information to answer, "
243
+ "say so plainly and do not invent facts. Respond ONLY with a JSON object "
244
+ 'of the form {"answer": "your answer with [1]-style citations"}.\n',
245
+ f"Question: {question}\n",
246
+ ]
247
+ if graph_summary:
248
+ lines.append(f"Graph context: {graph_summary}\n")
249
+ lines.append("Sources:")
250
+ if docs:
251
+ for i, doc in enumerate(docs, start=1):
252
+ snippet = _truncate(doc.snippet, _SYNTH_SNIPPET_CHARS)
253
+ lines.append(f"[{i}] {doc.title} — {snippet}")
254
+ else:
255
+ lines.append("(no documents found)")
256
+ return "\n".join(lines)
257
+
258
+
259
+ def _call_synthesize_step(
260
+ chat: ChatJson,
261
+ cfg: Config,
262
+ question: str,
263
+ docs: list[SearchResult],
264
+ graph_summary: str,
265
+ ) -> str:
266
+ """Run the synthesize step and return the raw answer string."""
267
+ body = chat(
268
+ _synthesize_prompt(question, docs, graph_summary),
269
+ schema={"answer": "str"},
270
+ cfg=cfg,
271
+ model=cfg.ask_model,
272
+ num_predict=_SYNTH_NUM_PREDICT,
273
+ timeout=cfg.ask_timeout_seconds,
274
+ )
275
+ answer = body.get("answer")
276
+ return answer if isinstance(answer, str) else ""
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Citation tracking
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def _sanitize_answer(answer: str, doc_count: int) -> str:
285
+ """Strip out-of-range ``[N]`` markers from the answer text.
286
+
287
+ Citation integrity: a marker whose number has no backing source (``N`` >
288
+ ``doc_count`` or ``N`` < 1) is a dangling citation. Removing it — along with
289
+ its leading whitespace — keeps the displayed/returned answer free of
290
+ references that resolve to nothing, while valid in-range markers are kept
291
+ verbatim so :func:`_build_citations` still maps them.
292
+ """
293
+
294
+ def _replace(match: re.Match[str]) -> str:
295
+ ref = int(match.group(1))
296
+ if 1 <= ref <= doc_count:
297
+ return match.group(0) # keep the valid marker (and its lead space)
298
+ return ""
299
+
300
+ return _MARKER_WITH_LEAD_RE.sub(_replace, answer).strip()
301
+
302
+
303
+ def _build_citations(answer: str, docs: list[SearchResult]) -> list[Citation]:
304
+ """Map ``[N]`` markers in ``answer`` to the numbered ``docs`` list.
305
+
306
+ Keeps only in-range references (1..len(docs)), de-duplicated, ordered by
307
+ first appearance in the answer text. ``ref`` preserves the original marker
308
+ number so ``[ref]`` in the prose always resolves to a real source — and a
309
+ hallucinated marker (out of range) is silently dropped, never emitted as a
310
+ citation (citation integrity).
311
+ """
312
+ citations: list[Citation] = []
313
+ seen: set[int] = set()
314
+ for match in _CITATION_RE.finditer(answer):
315
+ ref = int(match.group(1))
316
+ if ref in seen:
317
+ continue
318
+ if ref < 1 or ref > len(docs):
319
+ continue
320
+ seen.add(ref)
321
+ doc = docs[ref - 1]
322
+ citations.append(
323
+ Citation(
324
+ ref=ref,
325
+ document_id=doc.document_id,
326
+ title=doc.title,
327
+ source_kind=doc.source_kind,
328
+ snippet=_truncate(doc.snippet, _SYNTH_SNIPPET_CHARS),
329
+ )
330
+ )
331
+ return citations
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Retrieval
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def _graph_summary(ctx: GraphContext) -> str:
340
+ """Build a one-line graph-context label list from a ``GraphContext``.
341
+
342
+ For ``themes`` mode uses each :class:`ThemeGroup`'s ``summary`` (when the
343
+ opt-in synthesis ran) else its top entity names; for ``global`` mode each
344
+ :class:`CommunityGroup`'s ``summary`` else its ``community_key``; for
345
+ ``local`` mode the reached entity names. Empty string when the graph leg
346
+ produced nothing — keeps the synthesize prompt clean.
347
+ """
348
+ labels: list[str] = []
349
+ for theme in ctx.themes:
350
+ if theme.summary:
351
+ labels.append(theme.summary)
352
+ else:
353
+ labels.extend(e.name for e in theme.entities[:3] if e.name)
354
+ for community in ctx.communities:
355
+ labels.append(community.summary or community.community_key)
356
+ for entity in ctx.entities:
357
+ if entity.name:
358
+ labels.append(entity.name)
359
+ # De-duplicate preserving order, cap to keep the prompt bounded.
360
+ deduped: list[str] = []
361
+ for label in labels:
362
+ if label and label not in deduped:
363
+ deduped.append(label)
364
+ return ", ".join(deduped[:10])
365
+
366
+
367
+ def _retrieve_hybrid(
368
+ conn: psycopg.Connection[Any],
369
+ cfg: Config,
370
+ *,
371
+ embedder: Embedder,
372
+ query: str,
373
+ limit: int,
374
+ ) -> list[SearchResult]:
375
+ """Single hybrid-search pass for one sub-query (config-driven knobs)."""
376
+ return hybrid_search(
377
+ conn,
378
+ embedder=embedder,
379
+ query=query,
380
+ limit=limit,
381
+ vector_sim_floor=cfg.vector_sim_floor,
382
+ recency_halflife_days=cfg.recency_halflife_days,
383
+ snippet_context_tokens=cfg.snippet_context_tokens,
384
+ )
385
+
386
+
387
+ def _retrieve(
388
+ conn: psycopg.Connection[Any],
389
+ cfg: Config,
390
+ *,
391
+ embedder: Embedder,
392
+ query: str,
393
+ limit: int,
394
+ mode: str,
395
+ backend: GraphBackend | None,
396
+ ) -> tuple[list[SearchResult], str]:
397
+ """Retrieve documents for one sub-query; returns (results, graph_summary).
398
+
399
+ The hybrid leg always runs. For ``mode != hybrid`` the graph leg runs too
400
+ (requires ``backend``) and its docs are appended after the hybrid docs; the
401
+ returned ``graph_summary`` carries the theme/community/entity labels for the
402
+ synthesize prompt. A graph leg that yields nothing degrades silently to the
403
+ hybrid docs (never-raise on empty, matching the graph layer's contract).
404
+ """
405
+ # Validate the graph precondition BEFORE the (DB-touching) hybrid leg so a
406
+ # missing backend fails fast as a clean caller error.
407
+ if mode != HYBRID_MODE and backend is None:
408
+ raise ValueError(
409
+ f"mode={mode!r} requires a graph backend; pass backend=AgeBackend()"
410
+ )
411
+
412
+ results = _retrieve_hybrid(
413
+ conn, cfg, embedder=embedder, query=query, limit=limit
414
+ )
415
+ if mode == HYBRID_MODE:
416
+ return results, ""
417
+
418
+ assert backend is not None # guarded above
419
+ from .graph_rag import graph_rag_search
420
+
421
+ ctx = graph_rag_search(
422
+ conn,
423
+ cfg,
424
+ query,
425
+ backend=backend,
426
+ mode=mode,
427
+ limit=limit,
428
+ embedder=embedder,
429
+ )
430
+ combined = list(results)
431
+ seen_ids = {r.document_id for r in combined}
432
+ for doc in ctx.docs:
433
+ if doc.document_id not in seen_ids:
434
+ seen_ids.add(doc.document_id)
435
+ combined.append(doc)
436
+ return combined, _graph_summary(ctx)
437
+
438
+
439
+ def _clean_query_list(raw: Any, *, limit: int) -> list[str]:
440
+ """Coerce a model's list field into 0..limit non-empty unique strings."""
441
+ if not isinstance(raw, list):
442
+ return []
443
+ cleaned: list[str] = []
444
+ for item in raw:
445
+ if not isinstance(item, str):
446
+ continue
447
+ stripped = item.strip()
448
+ if stripped and stripped not in cleaned:
449
+ cleaned.append(stripped)
450
+ if len(cleaned) >= limit:
451
+ break
452
+ return cleaned
453
+
454
+
455
+ def _truncate(text: str, limit: int) -> str:
456
+ """Trim ``text`` to ``limit`` chars (collapsing trailing whitespace)."""
457
+ if len(text) <= limit:
458
+ return text
459
+ return text[:limit].rstrip()
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # Public entry points
464
+ # ---------------------------------------------------------------------------
465
+
466
+
467
+ def ask_no_loop(
468
+ conn: psycopg.Connection[Any],
469
+ cfg: Config,
470
+ *,
471
+ embedder: Embedder,
472
+ chat: ChatJson,
473
+ question: str,
474
+ mode: str = HYBRID_MODE,
475
+ limit: int = 5,
476
+ backend: GraphBackend | None = None,
477
+ ) -> AskResult:
478
+ """Single retrieve + synthesize pass (the ``--no-loop`` fast path).
479
+
480
+ Skips the plan/reflect LLM steps: retrieves once for ``question`` and
481
+ synthesizes a cited answer. ``fallback_used`` is always ``True`` here (no
482
+ agentic planning was performed).
483
+ """
484
+ _validate_mode(mode)
485
+ session_id = uuid.uuid4().hex
486
+ docs, graph_summary = _retrieve(
487
+ conn,
488
+ cfg,
489
+ embedder=embedder,
490
+ query=question,
491
+ limit=limit,
492
+ mode=mode,
493
+ backend=backend,
494
+ )
495
+ raw_answer = _call_synthesize_step(chat, cfg, question, docs, graph_summary)
496
+ answer = _sanitize_answer(raw_answer, len(docs))
497
+ citations = _build_citations(answer, docs)
498
+ return AskResult(
499
+ answer=answer,
500
+ citations=citations,
501
+ iterations_used=1,
502
+ sub_queries=[question],
503
+ fallback_used=True,
504
+ session_id=session_id,
505
+ )
506
+
507
+
508
+ def ask(
509
+ conn: psycopg.Connection[Any],
510
+ cfg: Config,
511
+ *,
512
+ embedder: Embedder,
513
+ chat: ChatJson,
514
+ question: str,
515
+ mode: str = HYBRID_MODE,
516
+ no_loop: bool = False,
517
+ limit: int = 5,
518
+ max_iterations: int = _DEFAULT_MAX_ITERATIONS,
519
+ backend: GraphBackend | None = None,
520
+ ) -> AskResult:
521
+ """Agentic plan -> retrieve -> reflect -> synthesize loop over the corpus.
522
+
523
+ Plans 1-3 sub-queries, retrieves (dedup by document id across iterations),
524
+ reflects on coverage, optionally issues follow-up sub-queries, then
525
+ synthesizes a single cited answer. ``no_loop=True`` delegates to
526
+ :func:`ask_no_loop`. ``max_iterations`` hard-caps the loop.
527
+
528
+ The loop stops early when the reflect step reports coverage is sufficient,
529
+ when an iteration surfaces no new documents, or when no follow-up sub-queries
530
+ are produced. ``OllamaUnavailable`` / ``EnrichmentError`` from any LLM step
531
+ propagates to the caller (no silent degradation).
532
+ """
533
+ _validate_mode(mode)
534
+ if max_iterations < 1:
535
+ raise ValueError(
536
+ f"max_iterations must be >= 1 (got {max_iterations})"
537
+ )
538
+ if no_loop:
539
+ return ask_no_loop(
540
+ conn,
541
+ cfg,
542
+ embedder=embedder,
543
+ chat=chat,
544
+ question=question,
545
+ mode=mode,
546
+ limit=limit,
547
+ backend=backend,
548
+ )
549
+
550
+ session_id = uuid.uuid4().hex
551
+
552
+ # Step 1 — Plan. Fall back to the raw question when the planner yields none.
553
+ sub_queries = _call_plan_step(chat, cfg, question)
554
+ fallback_used = False
555
+ if not sub_queries:
556
+ sub_queries = [question]
557
+ fallback_used = True
558
+
559
+ all_sub_queries: list[str] = list(sub_queries)
560
+ all_docs: dict[str, SearchResult] = {}
561
+ graph_summaries: list[str] = []
562
+ iterations_used = 0
563
+
564
+ for iteration in range(1, max_iterations + 1):
565
+ iterations_used = iteration
566
+ # Step 2 — Retrieve (dedup across iterations).
567
+ new_doc_found = False
568
+ for sub_query in sub_queries:
569
+ results, graph_summary = _retrieve(
570
+ conn,
571
+ cfg,
572
+ embedder=embedder,
573
+ query=sub_query,
574
+ limit=limit,
575
+ mode=mode,
576
+ backend=backend,
577
+ )
578
+ if graph_summary:
579
+ graph_summaries.append(graph_summary)
580
+ for result in results:
581
+ if result.document_id not in all_docs:
582
+ all_docs[result.document_id] = result
583
+ new_doc_found = True
584
+
585
+ # Step 3 — Reflect (skip on the last iteration or when nothing new).
586
+ if iteration >= max_iterations or not new_doc_found:
587
+ break
588
+ verdict = _call_reflect_step(
589
+ chat, cfg, question, list(all_docs.values())
590
+ )
591
+ if verdict.sufficient or not verdict.follow_up_queries:
592
+ break
593
+ sub_queries = verdict.follow_up_queries
594
+ for follow_up in sub_queries:
595
+ if follow_up not in all_sub_queries:
596
+ all_sub_queries.append(follow_up)
597
+
598
+ # Step 4 — Synthesize over the accumulated, deduplicated documents.
599
+ docs = list(all_docs.values())
600
+ combined_graph_summary = _join_graph_summaries(graph_summaries)
601
+ raw_answer = _call_synthesize_step(
602
+ chat, cfg, question, docs, combined_graph_summary
603
+ )
604
+ answer = _sanitize_answer(raw_answer, len(docs))
605
+ citations = _build_citations(answer, docs)
606
+ return AskResult(
607
+ answer=answer,
608
+ citations=citations,
609
+ iterations_used=iterations_used,
610
+ sub_queries=all_sub_queries,
611
+ fallback_used=fallback_used,
612
+ session_id=session_id,
613
+ )
614
+
615
+
616
+ def _join_graph_summaries(summaries: list[str]) -> str:
617
+ """De-duplicate label tokens across iteration graph summaries into one line."""
618
+ labels: list[str] = []
619
+ for summary in summaries:
620
+ for label in summary.split(", "):
621
+ label = label.strip()
622
+ if label and label not in labels:
623
+ labels.append(label)
624
+ return ", ".join(labels[:10])
625
+
626
+
627
+ def _validate_mode(mode: str) -> None:
628
+ """Raise ``ValueError`` for an unrecognized ``--mode`` value."""
629
+ if mode not in ASK_MODES:
630
+ valid = ", ".join(sorted(ASK_MODES))
631
+ raise ValueError(f"unknown mode {mode!r}; valid modes: {valid}")