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/review/scans.py ADDED
@@ -0,0 +1,355 @@
1
+ """Contradiction + staleness scan orchestrators (Plan 03 — ``brain review scan``).
2
+
3
+ One reason to change: the scan algorithm. The SQL lives in
4
+ :mod:`brain.review.queries`; this module owns the pure-Python pipeline —
5
+ graph prefilter -> embedding prefilter -> LLM adjudication (conflicts only) ->
6
+ upsert. Logs entity names and doc ids at INFO level only; never summaries or
7
+ document bodies (privacy, CLAUDE.md security standards).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import math
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Any, Protocol, runtime_checkable
16
+
17
+ import psycopg
18
+
19
+ from ..config import Config
20
+ from ..enrichment import ContradictionVerdict
21
+ from ..errors import OllamaUnavailable, ReviewError
22
+ from ..ingest import Embedder
23
+ from . import queries
24
+
25
+ _logger = logging.getLogger(__name__)
26
+
27
+
28
+ @runtime_checkable
29
+ class ContradictionAssessor(Protocol):
30
+ """The single enricher method the conflict scan depends on (DIP).
31
+
32
+ :class:`brain.enrichment.OllamaEnricher` satisfies this; the MCP layer wraps
33
+ it with a call-counter, and tests pass a fake — all without importing the
34
+ concrete enricher here.
35
+ """
36
+
37
+ def assess_contradiction(
38
+ self, *, subject: str, summaries: list[str]
39
+ ) -> ContradictionVerdict: ...
40
+
41
+ # Embedding prefilter cap: at most this many of an entity's documents are
42
+ # pairwise-compared (Step 3). Bounds the pair count to C(10, 2) = 45 before the
43
+ # per-entity ``pairs_per_entity`` cap trims it further.
44
+ _MAX_DOCS_PER_ENTITY = 10
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ReviewFinding:
49
+ """One contradiction or staleness finding produced by a scan.
50
+
51
+ ``kind`` is the ``elicitation_gaps.signal_kind`` value
52
+ (``'contradiction'`` | ``'stale'``). ``target_type`` is the entity type for
53
+ conflicts and ``'doc'`` for staleness. ``target_id`` is the entity
54
+ ``canonical_key`` (conflicts) or the stale document id (staleness).
55
+ ``score`` is ``1.0`` for a confirmed conflict and the cosine similarity for
56
+ a stale finding. ``evidence_ids`` are the conflicting / superseded document
57
+ ids. Frozen — a finding is an immutable value object.
58
+ """
59
+
60
+ kind: str
61
+ target_type: str
62
+ target_id: str
63
+ score: float
64
+ rationale: str
65
+ evidence_ids: list[str]
66
+
67
+
68
+ def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
69
+ """Cosine similarity of two vectors in pure Python (no numpy).
70
+
71
+ Returns ``0.0`` when either vector is all-zero (degenerate, no direction) so
72
+ the caller never divides by zero. Identical vectors -> ``1.0``; orthogonal
73
+ -> ``0.0``.
74
+ """
75
+ # strict=False: vectors are same-dim in practice; tolerate drift over the
76
+ # overlap rather than raising mid-scan.
77
+ dot = sum(x * y for x, y in zip(a, b, strict=False))
78
+ norm_a = math.sqrt(sum(x * x for x in a))
79
+ norm_b = math.sqrt(sum(y * y for y in b))
80
+ if norm_a == 0.0 or norm_b == 0.0:
81
+ return 0.0
82
+ return dot / (norm_a * norm_b)
83
+
84
+
85
+ def _top_pairs(
86
+ doc_ids: Sequence[str],
87
+ embeddings: Mapping[str, Sequence[float]],
88
+ *,
89
+ sim_floor: float,
90
+ max_pairs: int,
91
+ ) -> list[tuple[str, str, float]]:
92
+ """Top ``max_pairs`` document pairs by cosine, keeping only pairs >= floor.
93
+
94
+ Only documents present in ``embeddings`` are paired (a missing embedding
95
+ can't be compared). Pairs below ``sim_floor`` are discarded — too topically
96
+ distant to possibly contradict. Returns ``(doc_a, doc_b, cosine)`` sorted by
97
+ cosine DESC. Pure logic; unit-tested without a DB.
98
+ """
99
+ usable = [d for d in doc_ids if d in embeddings]
100
+ pairs: list[tuple[str, str, float]] = []
101
+ for i in range(len(usable)):
102
+ for j in range(i + 1, len(usable)):
103
+ doc_a, doc_b = usable[i], usable[j]
104
+ sim = _cosine(embeddings[doc_a], embeddings[doc_b])
105
+ if sim >= sim_floor:
106
+ pairs.append((doc_a, doc_b, sim))
107
+ pairs.sort(key=lambda p: p[2], reverse=True)
108
+ return pairs[:max_pairs]
109
+
110
+
111
+ def run_conflict_scan(
112
+ conn: psycopg.Connection[Any],
113
+ enricher: ContradictionAssessor,
114
+ embedder: Embedder,
115
+ cfg: Config,
116
+ *,
117
+ tenant_id: str,
118
+ dry_run: bool = False,
119
+ ) -> list[ReviewFinding]:
120
+ """Detect entities whose document summaries express contradictory positions.
121
+
122
+ Pipeline (spec §3): graph prefilter (entities with >= min_docs summarized
123
+ docs) -> idempotency skip (never re-adjudicate or overwrite an already
124
+ surfaced / snoozed / dismissed finding) -> embedding prefilter (top pairs
125
+ above the cosine floor) -> LLM adjudication via
126
+ :meth:`OllamaEnricher.assess_contradiction` -> upsert. At most one finding
127
+ per entity (the ``canonical_key`` is the unique target). When ``dry_run`` is
128
+ true the findings are computed and returned but never written.
129
+
130
+ ``embedder`` is part of the documented scan signature for symmetry with the
131
+ ingest / search layer; the scan compares pre-stored lead-chunk embeddings,
132
+ so it never calls the embedder to vectorize new text.
133
+
134
+ Raises :class:`ReviewError` (carrying the findings written so far and the
135
+ processed / total counts) when Ollama becomes unreachable mid-scan — earlier
136
+ findings are committed before the error propagates.
137
+ """
138
+ del embedder # documented-but-unused; see docstring.
139
+ candidates = queries.iter_entities_for_conflict_scan(
140
+ conn,
141
+ tenant_id=tenant_id,
142
+ min_docs=cfg.elicit_contradiction_min_docs,
143
+ limit=cfg.review_conflict_limit,
144
+ )
145
+ existing = queries.existing_finding_statuses(
146
+ conn, tenant_id=tenant_id, signal_kind="contradiction"
147
+ )
148
+ findings: list[ReviewFinding] = []
149
+ total = len(candidates)
150
+ processed = 0
151
+ for cand in candidates:
152
+ # Idempotency: any non-resolved row (surfaced / snoozed / dismissed)
153
+ # means we never re-adjudicate or overwrite. Only resolved / absent
154
+ # targets are rescanned.
155
+ if cand.canonical_key in existing:
156
+ continue
157
+ docs = cand.doc_ids[:_MAX_DOCS_PER_ENTITY]
158
+ embeddings = queries.fetch_best_chunk_embeddings(conn, document_ids=docs)
159
+ pairs = _top_pairs(
160
+ docs,
161
+ embeddings,
162
+ sim_floor=cfg.review_embed_sim_floor,
163
+ max_pairs=cfg.review_conflict_pairs_per_entity,
164
+ )
165
+ if pairs:
166
+ summaries = queries.fetch_doc_summaries(conn, document_ids=docs)
167
+ _logger.info(
168
+ "conflict scan: adjudicating entity %r (%d candidate pair(s))",
169
+ cand.name,
170
+ len(pairs),
171
+ )
172
+ finding = _adjudicate_entity(
173
+ conn,
174
+ enricher,
175
+ cand,
176
+ pairs,
177
+ summaries,
178
+ tenant_id=tenant_id,
179
+ dry_run=dry_run,
180
+ findings=findings,
181
+ processed=processed,
182
+ total=total,
183
+ )
184
+ if finding is not None:
185
+ findings.append(finding)
186
+ processed += 1
187
+ return findings
188
+
189
+
190
+ def _adjudicate_entity(
191
+ conn: psycopg.Connection[Any],
192
+ enricher: ContradictionAssessor,
193
+ cand: queries.EntityCandidate,
194
+ pairs: Sequence[tuple[str, str, float]],
195
+ summaries: Mapping[str, str],
196
+ *,
197
+ tenant_id: str,
198
+ dry_run: bool,
199
+ findings: list[ReviewFinding],
200
+ processed: int,
201
+ total: int,
202
+ ) -> ReviewFinding | None:
203
+ """Run the LLM on each surviving pair; return the first confirmed conflict.
204
+
205
+ Returns ``None`` when no pair is judged contradictory. On
206
+ :class:`OllamaUnavailable` commits the findings written so far (unless
207
+ ``dry_run``) and raises :class:`ReviewError` with the partial result.
208
+ """
209
+ for doc_a, doc_b, _sim in pairs:
210
+ summary_a = summaries.get(doc_a)
211
+ summary_b = summaries.get(doc_b)
212
+ if not summary_a or not summary_b:
213
+ continue
214
+ try:
215
+ verdict = enricher.assess_contradiction(
216
+ subject=cand.name, summaries=[summary_a, summary_b]
217
+ )
218
+ except OllamaUnavailable as exc:
219
+ if not dry_run:
220
+ conn.commit()
221
+ raise ReviewError(
222
+ f"Ollama unavailable mid-scan: {exc}",
223
+ findings=findings,
224
+ processed=processed,
225
+ total=total,
226
+ ) from exc
227
+ if verdict.contradicts:
228
+ if not dry_run:
229
+ queries.upsert_review_finding(
230
+ conn,
231
+ tenant_id=tenant_id,
232
+ signal_kind="contradiction",
233
+ target_type=cand.entity_type,
234
+ target_id=cand.canonical_key,
235
+ score=1.0,
236
+ evidence_ids=[doc_a, doc_b],
237
+ rationale=verdict.rationale,
238
+ )
239
+ return ReviewFinding(
240
+ kind="contradiction",
241
+ target_type=cand.entity_type,
242
+ target_id=cand.canonical_key,
243
+ score=1.0,
244
+ rationale=verdict.rationale,
245
+ evidence_ids=[doc_a, doc_b],
246
+ )
247
+ return None
248
+
249
+
250
+ def run_staleness_scan(
251
+ conn: psycopg.Connection[Any],
252
+ embedder: Embedder,
253
+ cfg: Config,
254
+ *,
255
+ tenant_id: str,
256
+ dry_run: bool = False,
257
+ ) -> list[ReviewFinding]:
258
+ """Flag aged docs superseded by a newer doc sharing an entity (no LLM calls).
259
+
260
+ Pipeline (spec §3): age candidates (older than ``stale_age_days``,
261
+ summarized, non-transcript, non-draft) -> newer docs sharing an entity
262
+ within ``stale_supersede_window_days`` -> embedding similarity filter (keep
263
+ the best superseding doc with cosine >= ``stale_sim_floor``) -> idempotency
264
+ skip -> upsert. The score is the cosine similarity. When ``dry_run`` is true
265
+ the findings are computed and returned but never written.
266
+
267
+ ``embedder`` is part of the documented scan signature for symmetry; the scan
268
+ compares pre-stored lead-chunk embeddings, so it never vectorizes new text.
269
+ """
270
+ del embedder # documented-but-unused; see docstring.
271
+ candidates = queries.iter_docs_for_staleness_scan(
272
+ conn,
273
+ tenant_id=tenant_id,
274
+ stale_age_days=cfg.review_stale_age_days,
275
+ limit=cfg.review_stale_limit,
276
+ )
277
+ existing = queries.existing_finding_statuses(
278
+ conn, tenant_id=tenant_id, signal_kind="stale"
279
+ )
280
+ findings: list[ReviewFinding] = []
281
+ for cand in candidates:
282
+ if cand.doc_id in existing:
283
+ continue
284
+ newer = queries.fetch_superseding_docs(
285
+ conn,
286
+ tenant_id=tenant_id,
287
+ doc_id=cand.doc_id,
288
+ window_days=cfg.review_stale_supersede_window_days,
289
+ )
290
+ if not newer:
291
+ continue
292
+ best = _best_superseding(conn, cand, newer, sim_floor=cfg.review_stale_sim_floor)
293
+ if best is None:
294
+ continue
295
+ superseding, sim = best
296
+ rationale = (
297
+ f"Age: {cand.age_days} days. "
298
+ f"Superseded by: '{superseding.title}' (similarity {sim:.2f})"
299
+ )
300
+ _logger.info(
301
+ "stale scan: doc %s superseded by %s (cosine %.2f)",
302
+ cand.doc_id,
303
+ superseding.doc_id,
304
+ sim,
305
+ )
306
+ if not dry_run:
307
+ queries.upsert_review_finding(
308
+ conn,
309
+ tenant_id=tenant_id,
310
+ signal_kind="stale",
311
+ target_type="doc",
312
+ target_id=cand.doc_id,
313
+ score=sim,
314
+ evidence_ids=[cand.doc_id, superseding.doc_id],
315
+ rationale=rationale,
316
+ )
317
+ findings.append(
318
+ ReviewFinding(
319
+ kind="stale",
320
+ target_type="doc",
321
+ target_id=cand.doc_id,
322
+ score=sim,
323
+ rationale=rationale,
324
+ evidence_ids=[cand.doc_id, superseding.doc_id],
325
+ )
326
+ )
327
+ return findings
328
+
329
+
330
+ def _best_superseding(
331
+ conn: psycopg.Connection[Any],
332
+ cand: queries.StaleCandidate,
333
+ newer: Sequence[queries.SupersedingDoc],
334
+ *,
335
+ sim_floor: float,
336
+ ) -> tuple[queries.SupersedingDoc, float] | None:
337
+ """Most-similar superseding doc with cosine >= ``sim_floor`` (or ``None``).
338
+
339
+ Fetches the stale doc's lead-chunk embedding alongside every newer doc's in
340
+ one batched query, then ranks by cosine.
341
+ """
342
+ ids = [cand.doc_id, *(n.doc_id for n in newer)]
343
+ embeddings = queries.fetch_best_chunk_embeddings(conn, document_ids=ids)
344
+ old_emb = embeddings.get(cand.doc_id)
345
+ if old_emb is None:
346
+ return None
347
+ best: tuple[queries.SupersedingDoc, float] | None = None
348
+ for superseding in newer:
349
+ new_emb = embeddings.get(superseding.doc_id)
350
+ if new_emb is None:
351
+ continue
352
+ sim = _cosine(old_emb, new_emb)
353
+ if sim >= sim_floor and (best is None or sim > best[1]):
354
+ best = (superseding, sim)
355
+ return best