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
@@ -0,0 +1,885 @@
1
+ """Gated, validated, versioned concept entity extractor (wave G2-b, GraphRAG).
2
+
3
+ The concept aspect of the entity graph (spec §3 D3, §5, §8). People entities are
4
+ derived *for free* from the participants pipeline (see
5
+ :mod:`brain.graph_rag.reconcile`); **concepts** — topics, projects,
6
+ organizations, tools — are extracted from raw document text by a small local
7
+ Ollama model, then validated/canonicalized/positioned/capped here.
8
+
9
+ **Single responsibility split (SOLID).** The Ollama *transport* (httpx, JSON
10
+ mode, retry, timeout, error mapping) lives in :class:`brain.enrichment.
11
+ OllamaEnricher` — the same place ``summarize`` / ``propose_tags`` live, so this
12
+ module never duplicates a chat client (spec §8: "extract.py — OllamaExtractor
13
+ (validated/versioned/chunked) + canonicalization"). This module owns the
14
+ extraction *logic*:
15
+
16
+ * **Chunked** — long documents are split with the existing paragraph-aware
17
+ :func:`brain.ingest.chunker.chunk_text` (token-budgeted via the enricher's
18
+ ``count_tokens``) and the model is called once per chunk, so a document larger
19
+ than the model context is still fully covered.
20
+ * **Validated** — every model entry is checked deterministically: it must be a
21
+ ``{"name": str, "type": str}`` object whose ``type`` is one of the four
22
+ concept types (:data:`CONCEPT_ENTITY_TYPES`). People and unknown types are
23
+ dropped (spec §17b decision 2: "people excluded"). Malformed entries are
24
+ skipped, never fatal.
25
+ * **Presence-validated (v3)** — a small model on an entity-*sparse* document
26
+ tends to copy the prompt's illustrative names or emit its own reasoning text
27
+ as "entities". So every candidate whose name does not actually appear in the
28
+ source document (separator-normalized substring match) is dropped, regardless
29
+ of prompt. This is the robust kill for few-shot prompt-example leakage; a
30
+ reasoning-text reject (sentence-length / meta-commentary patterns) and a
31
+ generic structural-noise filter (``PDF`` / ``Chapter 24`` / ``Section 3`` /
32
+ page + format words) back it up.
33
+ * **One type per name (v3)** — when the model emits the *same* canonical name
34
+ under multiple types (a standards body as both ``org`` and ``topic``; a
35
+ numbered standard duplicated across ``topic`` / ``org`` / ``tool``), the
36
+ duplicates are collapsed to a single node keeping the highest-precedence type
37
+ (:data:`_TYPE_PRECEDENCE`: person > org > project > tool > topic), so the
38
+ graph (and the communities derived from it) is not fragmented.
39
+ * **Canonicalized** — ``canonical_key`` is ``name`` lower-cased with ALL
40
+ separators stripped (strip-all, Bug B), so separator variants of one concept
41
+ (``AcmePlatform`` / ``acme-platform`` / ``Acme Platform``) share one key and
42
+ stop fragmenting the catalog. Entities dedup on
43
+ ``(entity_type, canonical_key)`` — the same key the eval gate scores
44
+ (spec §17b decision 2) and the catalog uniqueness key
45
+ ``UNIQUE(tenant_id, entity_type, canonical_key)`` (spec §5).
46
+ * **Positioned** — each entity's raw-text *word-index* positions are located in
47
+ the full document (chunker-independent, per spec §4 D4: ``pos`` is "a
48
+ token/word index for raw-text concepts"). These feed
49
+ :func:`brain.graph_rag.cooccur.cooccurrence_counts` in G2-c.
50
+ * **Capped** — the per-document distinct-entity count is bounded by
51
+ ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC`` (spec §10), kept by mention frequency
52
+ (ties broken by ``canonical_key``), mirroring
53
+ :func:`brain.graph_rag.cooccur._apply_entity_cap`.
54
+ * **Never-raise** — Ollama down / timeout / invalid JSON yields an **empty
55
+ list + a WARN**, never an exception (spec §7 / §17b decision 7 discipline), so
56
+ the gated G2-c ingest hook can never be broken by a flaky extractor.
57
+
58
+ **Versioning.** :data:`EXTRACTOR_VERSION` is the extraction-algorithm version.
59
+ :attr:`OllamaExtractor.version` folds it together with the model fingerprint
60
+ (``"<model>@<ver>"``), so a model swap *or* an algorithm bump flips the
61
+ ``graph_index_state.extractor_ver`` watermark and forces G2-c to re-extract
62
+ (spec §7 step 1). The ``graph_entity_mentions.source`` provenance string
63
+ (spec §5a) is ``"extractor:<model>@<ver>"`` — i.e. ``f"extractor:{version}"`` —
64
+ constructed by the G2-c reconcile from this same property.
65
+
66
+ **Gating.** Concepts are default-OFF (``BRAIN_GRAPH_CONCEPTS=false``); this
67
+ module is dormant until G2-c wires it behind that flag. G2-b builds and
68
+ unit-tests the extractor in isolation only.
69
+ """
70
+ from __future__ import annotations
71
+
72
+ import logging
73
+ import re
74
+ from dataclasses import dataclass
75
+ from typing import Any, Protocol, runtime_checkable
76
+
77
+ from ..config import Config
78
+ from ..enrichment import OllamaEnricher
79
+ from ..errors import EnrichmentError, OllamaUnavailable
80
+ from .cooccur import DEFAULT_MAX_ENTITIES_PER_DOC
81
+
82
+ _logger = logging.getLogger(__name__)
83
+
84
+ __all__ = [
85
+ "CONCEPT_ENTITY_TYPES",
86
+ "EXTRACTOR_VERSION",
87
+ "EntityExtractor",
88
+ "ExtractedEntity",
89
+ "OllamaExtractor",
90
+ "make_extractor",
91
+ ]
92
+
93
+ # Extraction-algorithm version. Folded into :attr:`OllamaExtractor.version`,
94
+ # which feeds ``graph_index_state.extractor_ver`` (spec §7). Bump when the
95
+ # prompt / validation / canonicalization semantics change so reconcile
96
+ # re-extracts affected documents.
97
+ #
98
+ # concepts-v4 (2026-05-24, perf Fix C): the extractor now applies a configurable
99
+ # input head cap (``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS``, default 8000) BEFORE
100
+ # chunking, so a long document is no longer extracted in full. This changes the
101
+ # extraction output for docs past the cap, so the watermark MUST change to force a
102
+ # ``--backfill`` re-extraction.
103
+ #
104
+ # concepts-v5 (2026-05-25, Phase B): (1) ``_STRUCTURAL_ENUM_RE`` extended with
105
+ # number-words (zero…ten) so "section one" / "version two" are dropped by the
106
+ # existing ``_is_noise_key`` filter without a new broad regex; (2)
107
+ # ``OllamaExtractor`` gains an ``extra_stopwords`` parameter (operator-curated
108
+ # canonical keys dropped in ``_finalize`` after the generic noise filter). Real
109
+ # stopwords are employer-specific (rule 15) and default to the empty set.
110
+ #
111
+ # concepts-v6 (2026-06-02, Bug B): ``_canonical_key`` is now STRIP-ALL —
112
+ # lower-cased with every separator removed (``re.sub(r"[\W_]+", "", …)``) instead
113
+ # of whitespace-collapse only. Separator variants of one concept (``AcmePlatform``
114
+ # / ``acme-platform`` / ``Acme Platform`` / ``acmeplatform``) collapse to a single
115
+ # key and stop fragmenting the catalog. The identity key changes shape, so the
116
+ # watermark MUST change to force a ``--backfill`` re-extraction that re-keys and
117
+ # merges existing separator-variant rows. Dependents kept consistent:
118
+ # ``_normalize_for_presence`` also strips all separators (presence stays
119
+ # consistent with the new key, both directions); ``_STRUCTURAL_ENUM_RE`` matches
120
+ # ``\s*`` so glued forms (``chapter24`` / ``sectionone``) are still filtered; and
121
+ # ``_dedupe_project_substrings`` + curated ``extra_stopwords`` operate on
122
+ # display-name word tokens / normalized keys rather than the now-wordless key.
123
+ EXTRACTOR_VERSION = "concepts-v6"
124
+
125
+ # The concept entity types the extractor emits (spec §5 ``entity_type CHECK``
126
+ # minus ``person``). People are derived from the participants pipeline and are
127
+ # explicitly NOT extracted here (spec §17b decision 2: "people excluded"); any
128
+ # model entry typed ``person`` (or anything off this list) is dropped.
129
+ CONCEPT_ENTITY_TYPES = frozenset({"topic", "project", "org", "tool"})
130
+
131
+ # Near-synonym TYPE labels a small model commonly emits for the four canonical
132
+ # concept types. Normalized to the canonical type BEFORE the allowlist check in
133
+ # :func:`_validate_entry`, so a valid extraction tagged ``"organization"`` /
134
+ # ``"software"`` / ``"initiative"`` / ``"theme"`` is recovered rather than
135
+ # silently dropped (a real recall loss the gate exposed). This is keyed strictly
136
+ # on the TYPE label — never on the entity NAME (no vendor/name allowlist, which
137
+ # would overfit the synthetic gate fixture).
138
+ #
139
+ # Deliberately conservative: ONLY unambiguous type-label synonyms are mapped.
140
+ # Ambiguous words (``platform`` / ``technology`` / ``domain`` / ``area`` /
141
+ # ``language`` / ``infrastructure`` / ``app``) are intentionally EXCLUDED — a
142
+ # real-corpus entity mislabeled with one of those is better dropped than
143
+ # silently mis-typed (Codex review).
144
+ _TYPE_LABEL_SYNONYMS: dict[str, str] = {
145
+ "organization": "org",
146
+ "organisation": "org",
147
+ "company": "org",
148
+ "companies": "org",
149
+ "vendor": "org",
150
+ "institution": "org",
151
+ "corporation": "org",
152
+ "software": "tool",
153
+ "framework": "tool",
154
+ "library": "tool",
155
+ "application": "tool",
156
+ "initiative": "project",
157
+ "effort": "project",
158
+ "program": "project",
159
+ "programme": "project",
160
+ "projects": "project",
161
+ "theme": "topic",
162
+ "subject": "topic",
163
+ "topics": "topic",
164
+ }
165
+
166
+ # Minimum canonical-key length (characters). A floor of 2 drops single-character
167
+ # noise while KEEPING short acronyms / tool names (a 2-char language, ``ml``,
168
+ # ``ci``…). Deliberately not 3 — a 3-char floor silently kills valid 2-char
169
+ # acronyms in the real corpus (Codex review, recall-protective).
170
+ _MIN_CANONICAL_KEY_LEN = 2
171
+
172
+ # Generic structural / meeting nouns that are never a useful standalone concept.
173
+ # EXACT canonical-key match only, and intentionally GENERIC (NOT this fixture's
174
+ # specific distractor terms) so it never blocks a legitimate domain concept in
175
+ # the real 1195-doc corpus (Codex review: a fixture-derived blocklist overfits
176
+ # the precision side). Keep this list small and obviously-generic.
177
+ _GENERIC_STOP_KEYS: frozenset[str] = frozenset({
178
+ "platform",
179
+ "service",
180
+ "services",
181
+ "system",
182
+ "systems",
183
+ "stack",
184
+ "team",
185
+ "teams",
186
+ "group",
187
+ "groups",
188
+ "meeting",
189
+ "meetings",
190
+ "call",
191
+ "calls",
192
+ "sync",
193
+ "standup",
194
+ "review",
195
+ "reviews",
196
+ "update",
197
+ "updates",
198
+ "discussion",
199
+ "workshop",
200
+ "sprint",
201
+ "document",
202
+ "note",
203
+ "notes",
204
+ "report",
205
+ "dashboard",
206
+ "dashboards",
207
+ })
208
+
209
+ # Document-structure / file-format noise that is never a standalone concept
210
+ # (audit B.5: ``PDF`` / ``Chapter 24`` / ``<Body> Standard`` extracted as
211
+ # topics). EXACT canonical-key match, extends :data:`_GENERIC_STOP_KEYS`, and
212
+ # kept deliberately GENERIC — only format + document-structure words, never a
213
+ # real product / standards-body / domain name (a corpus-derived blocklist would
214
+ # overfit precision and risk dropping legitimate concepts). "standard" /
215
+ # "standards" sit here because the audit observed a numbered-standards family
216
+ # fragmenting the graph; a real domain concept is virtually never the bare word.
217
+ _STRUCTURAL_STOP_KEYS: frozenset[str] = frozenset({
218
+ "pdf",
219
+ "docx",
220
+ "doc",
221
+ "html",
222
+ "csv",
223
+ "json",
224
+ "xml",
225
+ "page",
226
+ "pages",
227
+ "chapter",
228
+ "chapters",
229
+ "section",
230
+ "sections",
231
+ "appendix",
232
+ "appendices",
233
+ "figure",
234
+ "figures",
235
+ "table",
236
+ "tables",
237
+ "exhibit",
238
+ "exhibits",
239
+ "paragraph",
240
+ "paragraphs",
241
+ "footnote",
242
+ "footnotes",
243
+ "heading",
244
+ "headings",
245
+ "introduction",
246
+ "conclusion",
247
+ "abstract",
248
+ "preface",
249
+ "glossary",
250
+ "index",
251
+ "contents",
252
+ "attachment",
253
+ "attachments",
254
+ "screenshot",
255
+ "screenshots",
256
+ "standard",
257
+ "standards",
258
+ })
259
+
260
+ # A structural reference of the form "<structural noun><enumerator>" — e.g.
261
+ # "chapter 24", "section 3", "page 12", "figure 2", "appendix a", "standard 72".
262
+ # Matches a document-structure noun followed by a number / roman numeral / single
263
+ # letter; generic by construction, never a real entity name. Applied to the
264
+ # already-lower-cased canonical key — which is now STRIP-ALL (Bug B), so the
265
+ # separator between noun and enumerator is gone (``chapter24`` / ``sectionone``).
266
+ # The whitespace matcher is therefore ``\s*`` (zero-or-more), keeping BOTH the
267
+ # historical spaced form and the post-strip-all glued form filtered.
268
+ # DO NOT revert ``\s*`` to ``\s+``: strip-all keys never contain whitespace, so
269
+ # ``\s+`` would make this filter dead (nothing would ever match). The trade-off is
270
+ # that a short name colliding with a noun-prefix + single letter (e.g. "vera" =>
271
+ # ver+a) is filtered as collateral — accepted, since the filter is generic by
272
+ # construction and such collisions are rare in the real corpus.
273
+ _STRUCTURAL_ENUM_RE = re.compile(
274
+ r"^(?:chapter|section|page|figure|fig|table|appendix|exhibit|paragraph|"
275
+ r"para|footnote|clause|article|item|part|volume|vol|step|standard|"
276
+ r"version|ver|revision|rev)\s*"
277
+ r"(?:\d+|[ivxlcdm]+|[a-z]|"
278
+ r"zero|one|two|three|four|five|six|seven|eight|nine|ten)$"
279
+ )
280
+
281
+ # Maximum word count for an entity NAME. Concept names are short (the gate's
282
+ # longest gold entity is two words); a longer "name" is almost always the
283
+ # model's reasoning text / a sentence rather than an entity (audit B.2). A
284
+ # generous cap of 6 protects recall on legitimate multi-word concepts.
285
+ _MAX_ENTITY_WORDS = 6
286
+
287
+ # Meta-commentary / reasoning-text markers a small model emits AS an entity name
288
+ # on entity-sparse documents (audit B.2: e.g. "<X> is not present in the text.
289
+ # However, …"). Matched against the candidate name; a hit means it is the
290
+ # model's prose, not a concept, so it is dropped. Generic phrasing only.
291
+ _REASONING_PATTERNS = re.compile(
292
+ r"(?i)("
293
+ r"\bis not present\b|\bare not present\b|\bnot present in\b|"
294
+ r"\bdoes not appear\b|\bdo not appear\b|\bnot mentioned\b|"
295
+ r"\bno (?:clear |concept )*entit(?:y|ies)\b|"
296
+ r"\bthere (?:is|are) no\b|"
297
+ r"\bcannot (?:find|identify|extract)\b|\bunable to\b|"
298
+ r"\bthe (?:text|document|passage) (?:does|contains|has|mentions|states|"
299
+ r"describes|discusses)\b|"
300
+ r"\bn/a\b|\bnone\b|\bhowever\b"
301
+ r")"
302
+ )
303
+
304
+ # Type precedence for cross-type collapse (audit B.3/B.4): when the SAME
305
+ # canonical name is emitted under multiple types, keep ONE node with the
306
+ # highest-precedence type and drop the rest. ``person`` ranks first for
307
+ # completeness (a name confusable with a person should win person), though
308
+ # people never reach :func:`_finalize` — they are dropped in
309
+ # :func:`_validate_entry`. Among concept types: a hosted provider is an ``org``
310
+ # before a ``topic``; a named effort is a ``project`` before a ``tool``.
311
+ _TYPE_PRECEDENCE: tuple[str, ...] = ("person", "org", "project", "tool", "topic")
312
+ _TYPE_RANK: dict[str, int] = {t: rank for rank, t in enumerate(_TYPE_PRECEDENCE)}
313
+
314
+ # Separator pattern shared by the canonical key and the presence check: every run
315
+ # of non-alphanumeric characters (whitespace, hyphens, dots, underscores, special
316
+ # characters). Both callers REMOVE these (strip-all, Bug B), so "back-pressure" /
317
+ # "back_pressure" / "Back Pressure" / "BackPressure" all normalize to
318
+ # "backpressure" — a hyphenated concept present in the text is not spuriously
319
+ # dropped, and separator variants of one concept share a single canonical key.
320
+ _SEPARATOR_RE = re.compile(r"[\W_]+", re.UNICODE)
321
+
322
+ # Per-chunk token budget for the LLM extraction calls. A document longer than
323
+ # this is split into multiple chunks (one model call each). Independent of the
324
+ # enrich-summary budget (no enrich<->graph config coupling); not env-tunable in
325
+ # G2 (YAGNI — promote to a knob only if the eval gate needs it).
326
+ _DEFAULT_CHUNK_TARGET_TOKENS = 1500
327
+
328
+ # Overlap between consecutive extraction chunks. A small overlap reduces the
329
+ # chance an entity straddling a chunk boundary is missed; cross-chunk repeats
330
+ # are harmless (dedup collapses them, positions are computed over the full doc).
331
+ _DEFAULT_CHUNK_OVERLAP_TOKENS = 100
332
+
333
+ # Raw-text word tokenizer for positions + canonicalization. ``\w+`` over the
334
+ # lower-cased text yields chunker-independent word ordinals (spec §4 D4).
335
+ _WORD_RE = re.compile(r"\w+", re.UNICODE)
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class ExtractedEntity:
340
+ """One validated, canonicalized concept entity extracted from a document.
341
+
342
+ The pre-upsert input to the G2-c concept reconcile (no ``tenant_id`` /
343
+ ``id`` — those are assigned when the catalog row is upserted, exactly as a
344
+ :class:`brain.graph_rag.reconcile.ResolvedPerson` carries no id).
345
+
346
+ ``canonical_key`` is the dedup identity (``name`` lower-cased with all
347
+ separators stripped, Bug B) — unique per ``(entity_type, canonical_key)`` and
348
+ the key the eval gate scores. ``display_name`` is the surface form for the
349
+ catalog ``name`` / AGE vertex label. ``positions`` are the entity's
350
+ raw-text word-index occurrences over the *whole* document (empty when the
351
+ model named a concept that does not appear literally in the text); they feed
352
+ :func:`brain.graph_rag.cooccur.cooccurrence_counts` in G2-c.
353
+ ``mention_count`` (``>= 1``) backs the ``graph_entity_mentions`` row.
354
+ """
355
+
356
+ entity_type: str
357
+ canonical_key: str
358
+ display_name: str
359
+ positions: tuple[int, ...] = ()
360
+ mention_count: int = 1
361
+
362
+
363
+ @runtime_checkable
364
+ class EntityExtractor(Protocol):
365
+ """Resolve one document's concept entities (dependency-inversion seam).
366
+
367
+ Narrow Protocol mirroring the embedder/enricher pattern: a ``version``
368
+ property (threaded into the ``graph_index_state.extractor_ver`` watermark)
369
+ and an ``extract`` method. The default implementation
370
+ (:class:`OllamaExtractor`) wraps a local-Ollama model; G2-c depends only on
371
+ this Protocol and tests inject fakes.
372
+ """
373
+
374
+ @property
375
+ def version(self) -> str:
376
+ """Watermark version string for ``graph_index_state.extractor_ver``."""
377
+ ...
378
+
379
+ def extract(self, text: str) -> list[ExtractedEntity]:
380
+ """Return the document's deduped, capped concept entities (never raises)."""
381
+ ...
382
+
383
+
384
+ class OllamaExtractor:
385
+ """Local-Ollama concept extractor — validated, versioned, chunked.
386
+
387
+ Composes a :class:`brain.enrichment.OllamaEnricher` for the chat transport
388
+ (``extract_entities`` round-trip + ``count_tokens`` for chunking + the model
389
+ fingerprint) and adds the extraction logic on top. Construct via
390
+ :func:`make_extractor`; tests inject an enricher backed by an
391
+ ``httpx.MockTransport`` (no monkey-patching).
392
+ """
393
+
394
+ def __init__(
395
+ self,
396
+ *,
397
+ enricher: OllamaEnricher,
398
+ max_entities: int | None = DEFAULT_MAX_ENTITIES_PER_DOC,
399
+ chunk_target_tokens: int = _DEFAULT_CHUNK_TARGET_TOKENS,
400
+ chunk_overlap_tokens: int = _DEFAULT_CHUNK_OVERLAP_TOKENS,
401
+ max_input_tokens: int | None = None,
402
+ extra_stopwords: frozenset[str] = frozenset(),
403
+ ) -> None:
404
+ if max_entities is not None and max_entities < 1:
405
+ raise ValueError(
406
+ f"max_entities must be a positive integer or None (got {max_entities})"
407
+ )
408
+ if chunk_target_tokens < 1:
409
+ raise ValueError(
410
+ f"chunk_target_tokens must be a positive integer (got {chunk_target_tokens})"
411
+ )
412
+ if chunk_overlap_tokens < 0:
413
+ raise ValueError(
414
+ "chunk_overlap_tokens must be a non-negative integer "
415
+ f"(got {chunk_overlap_tokens})"
416
+ )
417
+ if max_input_tokens is not None and max_input_tokens < 1:
418
+ raise ValueError(
419
+ "max_input_tokens must be a positive integer or None "
420
+ f"(got {max_input_tokens})"
421
+ )
422
+ self._enricher = enricher
423
+ self._max_entities = max_entities
424
+ self._chunk_target_tokens = chunk_target_tokens
425
+ self._chunk_overlap_tokens = chunk_overlap_tokens
426
+ # Perf Fix C: head cap (in cl100k_base tokens) applied to the document
427
+ # body before chunking. ``None`` == no cap (the whole document is
428
+ # extracted — the historical behavior and the test default). Production
429
+ # threads ``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS`` via ``make_extractor``.
430
+ self._max_input_tokens = max_input_tokens
431
+ # Phase B: operator-curated canonical keys dropped in ``_finalize`` after
432
+ # the generic noise filter. Default empty (real terms are employer-specific,
433
+ # rule 15). Threaded from ``cfg.graph_extract_stopwords`` by
434
+ # :func:`make_extractor`. Bug B: normalize each curated stopword through the
435
+ # strip-all :func:`_canonical_key` so an operator-friendly spaced/hyphenated
436
+ # form ("bar baz") matches the strip-all candidate key ("barbaz"); empties
437
+ # (no alphanumeric content) are dropped.
438
+ self._extra_stopwords = frozenset(
439
+ key for word in extra_stopwords if (key := _canonical_key(word))
440
+ )
441
+
442
+ @property
443
+ def version(self) -> str:
444
+ """``"<model>@<ver>"`` — the watermark version (spec §7).
445
+
446
+ Folds the model fingerprint together with :data:`EXTRACTOR_VERSION` so a
447
+ model swap *or* an algorithm bump re-extracts. G2-c uses this both as the
448
+ ``graph_index_state.extractor_ver`` value and (as ``f"extractor:{version}"``)
449
+ the ``graph_entity_mentions.source`` provenance (spec §5a).
450
+ """
451
+ return f"{self._enricher.model}@{EXTRACTOR_VERSION}"
452
+
453
+ def extract(self, text: str) -> list[ExtractedEntity]:
454
+ """Extract one document's concept entities. Never raises.
455
+
456
+ Optionally head-caps ``text`` to the first ``max_input_tokens`` tokens
457
+ (perf Fix C; ``None`` = no cap), then chunks it, calls the model once per
458
+ chunk, validates + collects every well-formed candidate, then dedups on
459
+ ``(entity_type, canonical_key)``, locates raw-text positions over the
460
+ (capped) document, and applies the per-doc cap. On Ollama unavailability
461
+ the whole extraction returns ``[]`` (+ WARN); a single chunk that returns
462
+ malformed JSON is skipped (+ WARN) and the remaining chunks still
463
+ contribute.
464
+ """
465
+ text = text.strip()
466
+ if not text:
467
+ return []
468
+
469
+ # Perf Fix C: head-cap the input BEFORE chunking. The model is called once
470
+ # per chunk, so a long document drives a heavy tail of LLM calls; capping
471
+ # to the first ``self._max_input_tokens`` tokens bounds that tail. The cap
472
+ # uses the enricher's tokenizer (the same ``count_tokens`` path the chunker
473
+ # below budgets with), so the truncation boundary and the chunk sizing
474
+ # agree. ``None`` disables the cap (whole document extracted). KISS: a
475
+ # plain HEAD cap — head+tail sampling (to also catch concepts that appear
476
+ # only deep in a long transcript) is a deliberate FUTURE tuning option, not
477
+ # built here.
478
+ if self._max_input_tokens is not None:
479
+ text = self._enricher.truncate_to_tokens(text, self._max_input_tokens)
480
+
481
+ # Late import keeps :mod:`brain.graph_rag` import-cheap and avoids pulling
482
+ # the heavy :mod:`brain.ingest` package in at module load (mirrors
483
+ # ``reconcile.py``'s late import of ``brain.wiki.build_people``).
484
+ from ..ingest.chunker import chunk_text
485
+
486
+ chunks = chunk_text(
487
+ text,
488
+ target_tokens=self._chunk_target_tokens,
489
+ overlap_tokens=self._chunk_overlap_tokens,
490
+ count_tokens=self._enricher.count_tokens,
491
+ )
492
+ candidates: list[tuple[str, str]] = []
493
+ for chunk in chunks:
494
+ try:
495
+ raw_entities = self._enricher.extract_entities(chunk.content)
496
+ except OllamaUnavailable as exc:
497
+ # Server down — no chunk can succeed; abort the whole extraction.
498
+ _logger.warning(
499
+ "concept extraction aborted: Ollama unavailable (%s); "
500
+ "returning no entities",
501
+ exc,
502
+ )
503
+ return []
504
+ except EnrichmentError as exc:
505
+ # Malformed JSON after retry on THIS chunk; skip it, keep going.
506
+ _logger.warning(
507
+ "concept extraction: chunk %d failed (%s); skipping chunk",
508
+ chunk.index,
509
+ exc,
510
+ )
511
+ continue
512
+ for entry in raw_entities:
513
+ validated = _validate_entry(entry)
514
+ if validated is not None:
515
+ candidates.append(validated)
516
+
517
+ return self._finalize(candidates, text)
518
+
519
+ def _finalize(
520
+ self, candidates: list[tuple[str, str]], text: str
521
+ ) -> list[ExtractedEntity]:
522
+ """Repair, filter, dedup, position, cap, and order the candidates."""
523
+ if not candidates:
524
+ return []
525
+ doc_words = _tokenize_words(text)
526
+ normalized_text = _normalize_for_presence(text)
527
+ # Dedup on (entity_type, canonical_key); keep the first-seen surface form
528
+ # as the display name. Iteration order over the candidate list is
529
+ # deterministic (chunk order, then in-chunk order), so the kept surface
530
+ # form is stable. Per candidate, in order: restore a stripped "Project"
531
+ # prefix when the document names the project that way (fixes an exact-key
532
+ # miss); drop the model's reasoning text / meta-commentary (B.2); drop
533
+ # too-short, generic, or structural-noise keys (B.5 precision); and drop
534
+ # any name that does not actually appear in the source text (B.1 presence
535
+ # validation — the robust kill for few-shot prompt-example leakage).
536
+ seen: dict[tuple[str, str], str] = {}
537
+ for entity_type, raw_display_name in candidates:
538
+ display_name = _repair_project_prefix(entity_type, raw_display_name, text)
539
+ if _is_reasoning_text(display_name):
540
+ continue
541
+ canonical_key = _canonical_key(display_name)
542
+ if _is_noise_key(canonical_key):
543
+ continue
544
+ if canonical_key in self._extra_stopwords:
545
+ continue
546
+ if not _name_present_in_text(canonical_key, normalized_text):
547
+ continue
548
+ seen.setdefault((entity_type, canonical_key), display_name)
549
+
550
+ entities: list[ExtractedEntity] = []
551
+ for (entity_type, canonical_key), display_name in seen.items():
552
+ positions = _locate_positions(display_name, doc_words)
553
+ entities.append(
554
+ ExtractedEntity(
555
+ entity_type=entity_type,
556
+ canonical_key=canonical_key,
557
+ display_name=display_name,
558
+ positions=positions,
559
+ mention_count=max(1, len(positions)),
560
+ )
561
+ )
562
+ entities = _dedupe_project_substrings(entities)
563
+ entities = _dedupe_cross_type(entities)
564
+ entities = self._apply_cap(entities)
565
+ entities.sort(key=lambda entity: (entity.entity_type, entity.canonical_key))
566
+ return entities
567
+
568
+ def _apply_cap(self, entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
569
+ """Bound the per-document distinct-entity count (spec §10).
570
+
571
+ Keeps the most-mentioned entities (ties broken by ``canonical_key``
572
+ ascending for determinism), mirroring
573
+ :func:`brain.graph_rag.cooccur._apply_entity_cap`. ``None`` disables.
574
+ """
575
+ if self._max_entities is None or len(entities) <= self._max_entities:
576
+ return entities
577
+ ranked = sorted(
578
+ entities,
579
+ key=lambda entity: (
580
+ -entity.mention_count,
581
+ entity.entity_type,
582
+ entity.canonical_key,
583
+ ),
584
+ )
585
+ return ranked[: self._max_entities]
586
+
587
+
588
+ def make_extractor(cfg: Config) -> OllamaExtractor:
589
+ """Factory — same shape as :func:`brain.enrichment.make_enricher`.
590
+
591
+ Builds the :class:`OllamaExtractor` from ``cfg.graph_extract_model`` /
592
+ ``cfg.ollama_host`` and the per-doc cap ``cfg.graph_max_entities``
593
+ (``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``). The model is the dedicated
594
+ ``BRAIN_GRAPH_EXTRACT_MODEL`` so the concept extractor and the summary
595
+ enricher stay independently overridable (no enrich<->graph coupling).
596
+
597
+ The HTTP timeout is threaded from ``cfg.enrich_timeout_seconds``
598
+ (``BRAIN_ENRICH_TIMEOUT_SECONDS``) — NOT the 60s ``OllamaEnricher`` default.
599
+ A large document can take more than 60s to extract on a slow model; the old
600
+ hardcoded default timed those out and silently returned zero entities.
601
+ Operators raise ``BRAIN_ENRICH_TIMEOUT_SECONDS`` for slow models.
602
+
603
+ The input head cap is threaded from ``cfg.graph_extract_max_input_tokens``
604
+ (``BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS``, default 8000; ``None`` disables)
605
+ so a long document's body is truncated to its first N tokens before chunking
606
+ — bounding the per-document LLM call count (perf Fix C).
607
+ """
608
+ enricher = OllamaEnricher(
609
+ host=cfg.ollama_host,
610
+ model=cfg.graph_extract_model,
611
+ timeout=cfg.enrich_timeout_seconds,
612
+ keep_alive=cfg.ollama_keep_alive,
613
+ )
614
+ return OllamaExtractor(
615
+ enricher=enricher,
616
+ max_entities=cfg.graph_max_entities,
617
+ max_input_tokens=cfg.graph_extract_max_input_tokens,
618
+ extra_stopwords=cfg.graph_extract_stopwords,
619
+ )
620
+
621
+
622
+ # --------------------------------------------------------------------------- #
623
+ # Pure helpers (validation / canonicalization / positions)
624
+ # --------------------------------------------------------------------------- #
625
+ def _validate_entry(entry: Any) -> tuple[str, str] | None:
626
+ """Validate one raw model entry → ``(entity_type, display_name)`` or ``None``.
627
+
628
+ Deterministic, defensive validation (spec §16 "strict validation"): an entry
629
+ is accepted only when it is a ``{"name": str, "type": str}`` object with a
630
+ non-empty ``name`` and a ``type`` (case-insensitively) in
631
+ :data:`CONCEPT_ENTITY_TYPES`. Everything else — non-dicts, missing/extra
632
+ keys, wrong value types, empty names, people, unknown types — returns
633
+ ``None`` and is skipped by the caller.
634
+ """
635
+ if not isinstance(entry, dict):
636
+ return None
637
+ raw_name = entry.get("name")
638
+ raw_type = entry.get("type")
639
+ if not isinstance(raw_name, str) or not isinstance(raw_type, str):
640
+ return None
641
+ display_name = raw_name.strip()
642
+ entity_type = raw_type.strip().lower()
643
+ # Normalize near-synonym type labels (organization/software/initiative/theme/
644
+ # …) to the canonical type BEFORE the allowlist check, so a valid extraction
645
+ # is not dropped purely over label wording (a real recall loss). Keyed on the
646
+ # TYPE label only — never on the entity NAME.
647
+ entity_type = _TYPE_LABEL_SYNONYMS.get(entity_type, entity_type)
648
+ if not display_name:
649
+ return None
650
+ if entity_type not in CONCEPT_ENTITY_TYPES:
651
+ return None
652
+ return (entity_type, display_name)
653
+
654
+
655
+ def _canonical_key(display_name: str) -> str:
656
+ """Canonical dedup key: lower-cased with ALL separators stripped (Bug B).
657
+
658
+ Lower-cases ``display_name`` and removes every non-alphanumeric run
659
+ (whitespace, hyphens, dots, underscores, special characters) via
660
+ :data:`_SEPARATOR_RE`, so the glued camelCase, hyphenated, spaced, and
661
+ concatenated surface forms of one concept (``AcmePlatform`` / ``acme-platform``
662
+ / ``Acme Platform`` / ``acmeplatform``) all collapse to the SAME key
663
+ (``acmeplatform``) and merge into one ``graph_entities`` row instead of
664
+ fragmenting. This is *strip-all*, not collapse-to-space — the weaker
665
+ whitespace-only normalization left separator variants as distinct keys.
666
+
667
+ Backs the catalog's ``UNIQUE(tenant_id, entity_type, canonical_key)`` (spec
668
+ §5). Returns an empty string only for a name with no alphanumeric content
669
+ (caller skips it). The presence check (:func:`_normalize_for_presence`) strips
670
+ separators the same way, so the two stay consistent.
671
+ """
672
+ return _SEPARATOR_RE.sub("", display_name.lower())
673
+
674
+
675
+ def _tokenize_words(text: str) -> list[str]:
676
+ r"""Lower-cased ``\w+`` word tokens — the raw-text position unit (spec §4 D4)."""
677
+ return _WORD_RE.findall(text.lower())
678
+
679
+
680
+ def _locate_positions(name: str, doc_words: list[str]) -> tuple[int, ...]:
681
+ """Word-index start positions of ``name`` in ``doc_words`` (case-insensitive).
682
+
683
+ Finds every occurrence of the (possibly multi-word) entity name as a
684
+ contiguous sub-sequence of the document's word stream and returns each
685
+ match's starting word index. Empty when the name's words never appear
686
+ contiguously (e.g. the model paraphrased a concept not present verbatim).
687
+ """
688
+ name_words = _tokenize_words(name)
689
+ if not name_words:
690
+ return ()
691
+ span = len(name_words)
692
+ last_start = len(doc_words) - span
693
+ return tuple(
694
+ i for i in range(last_start + 1) if doc_words[i : i + span] == name_words
695
+ )
696
+
697
+
698
+ def _is_noise_key(canonical_key: str) -> bool:
699
+ """True when a canonical key is too short, a generic stop word, or structural.
700
+
701
+ Drops single-character noise (``len < _MIN_CANONICAL_KEY_LEN``), an exact
702
+ match against the generic :data:`_GENERIC_STOP_KEYS` / file-format +
703
+ document-structure :data:`_STRUCTURAL_STOP_KEYS` lists, and the
704
+ "<structural noun> <enumerator>" pattern (``chapter 24``, ``section 3``,
705
+ ``standard 72`` — :data:`_STRUCTURAL_ENUM_RE`). All checks are deliberately
706
+ narrow + generic to protect recall — short acronyms (>= 2 chars) and every
707
+ real domain concept pass through.
708
+ """
709
+ return (
710
+ len(canonical_key) < _MIN_CANONICAL_KEY_LEN
711
+ or canonical_key in _GENERIC_STOP_KEYS
712
+ or canonical_key in _STRUCTURAL_STOP_KEYS
713
+ or _STRUCTURAL_ENUM_RE.match(canonical_key) is not None
714
+ )
715
+
716
+
717
+ def _normalize_for_presence(text: str) -> str:
718
+ """Lower-case ``text`` and strip every non-alphanumeric run (Bug B).
719
+
720
+ The shared normalization for the presence check: separators (whitespace,
721
+ hyphens, dots, underscores, special characters) are REMOVED entirely — the
722
+ same strip-all rule as :func:`_canonical_key`. Stripping on both sides keeps
723
+ presence matching consistent with the strip-all key regardless of which side
724
+ carries the separators: a glued canonical key (``acmeplatform``) matches a
725
+ separated source form (``Acme Platform``), and a separated surface form
726
+ matches a glued source form (``AcmePlatform``). Without this, the strip-all
727
+ key would never substring-match a strip-to-space document and every
728
+ separator-bearing entity would be falsely dropped.
729
+
730
+ Trade-off: removing separators makes the substring match deliberately MORE
731
+ permissive across word boundaries (e.g. ``"acme"`` matches ``"...acme..."``
732
+ even mid-word), so very short entity names (<= 4 chars) carry a higher
733
+ false-positive risk. Accepted: the presence check is intentionally lenient
734
+ (recall-safe) — its only job is to drop names that do not occur at all, and
735
+ the strip-all key consistency matters more than tightening this edge.
736
+ """
737
+ return _SEPARATOR_RE.sub("", text.lower())
738
+
739
+
740
+ def _name_present_in_text(canonical_key: str, normalized_text: str) -> bool:
741
+ """True when the entity name actually appears in the source text (B.1).
742
+
743
+ Strip-all substring match: the canonical key is re-normalized the same way as
744
+ ``normalized_text`` (:func:`_normalize_for_presence` — all separators removed)
745
+ and checked for containment. Because the key is already strip-all, the needle
746
+ equals the key; matching against strip-all text means a glued key
747
+ (``acmeplatform``) is found in a separated source (``Acme Platform`` →
748
+ ``acmeplatform``) and vice versa. Deliberately a substring (not a whole-word)
749
+ match so a bare ``"helios"`` still counts as present when the document writes
750
+ ``"Helioscope"`` (lenient = recall-safe); the goal is only to drop names that
751
+ do **not** occur at all — hallucinated few-shot example names and paraphrases
752
+ the model invented but never wrote.
753
+ """
754
+ needle = _normalize_for_presence(canonical_key)
755
+ if not needle:
756
+ return False
757
+ return needle in normalized_text
758
+
759
+
760
+ def _is_reasoning_text(display_name: str) -> bool:
761
+ """True when a candidate "name" is the model's reasoning text, not an entity.
762
+
763
+ Drops sentence-length names (more than :data:`_MAX_ENTITY_WORDS` words) and
764
+ names containing meta-commentary markers (:data:`_REASONING_PATTERNS`, e.g.
765
+ "is not present", "however", "the document discusses") that a small model
766
+ emits as an "entity" on entity-sparse documents (audit B.2). Generic phrasing
767
+ only — never keyed on a specific entity name.
768
+ """
769
+ stripped = display_name.strip()
770
+ if not stripped:
771
+ return True
772
+ if len(stripped.split()) > _MAX_ENTITY_WORDS:
773
+ return True
774
+ return _REASONING_PATTERNS.search(stripped) is not None
775
+
776
+
777
+ def _dedupe_cross_type(entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
778
+ """Collapse one canonical name emitted under multiple types to a single node.
779
+
780
+ Audit B.3/B.4: the model frequently emits the same real entity under more
781
+ than one type (a standards body as both ``org`` and ``topic``; each numbered
782
+ standard duplicated across ``topic`` / ``org`` / ``tool``), inflating and
783
+ fragmenting the graph and the Louvain communities built from it. For each
784
+ canonical key, keep the single highest-precedence type
785
+ (:data:`_TYPE_PRECEDENCE`) and drop the lower-precedence duplicates. Order is
786
+ preserved. Positions are a pure function of the name + document text, so the
787
+ surviving node already carries the correct ``mention_count`` — no re-summing.
788
+ """
789
+ unknown_rank = len(_TYPE_PRECEDENCE)
790
+ best_rank: dict[str, int] = {}
791
+ for entity in entities:
792
+ rank = _TYPE_RANK.get(entity.entity_type, unknown_rank)
793
+ current = best_rank.get(entity.canonical_key)
794
+ if current is None or rank < current:
795
+ best_rank[entity.canonical_key] = rank
796
+ return [
797
+ entity
798
+ for entity in entities
799
+ if _TYPE_RANK.get(entity.entity_type, unknown_rank)
800
+ == best_rank[entity.canonical_key]
801
+ ]
802
+
803
+
804
+ def _repair_project_prefix(entity_type: str, display_name: str, text: str) -> str:
805
+ """Restore a stripped ``Project`` prefix when the document names it that way.
806
+
807
+ Small models often return the bare project name (``"Helios"``) for a concept
808
+ the document writes as ``"Project Helios"``. When the entity is a ``project``
809
+ and the ``"Project <name>"`` form appears verbatim in the text, prefer that
810
+ fuller surface form so the canonical key matches how the project is actually
811
+ named. Generic (any project name) — never keyed on a specific entity.
812
+
813
+ The match is a contiguous WORD/token sequence, not a substring: a bare
814
+ ``"Helios"`` is NOT promoted when the document only says ``"Project
815
+ Helioscope"`` (which contains ``"project helios"`` as a substring).
816
+ """
817
+ if entity_type != "project":
818
+ return display_name
819
+ stripped = display_name.strip()
820
+ if not stripped or stripped.lower().startswith("project "):
821
+ return display_name
822
+ name_words = _tokenize_words(stripped)
823
+ if not name_words:
824
+ return display_name
825
+ target = ["project", *name_words]
826
+ doc_words = _tokenize_words(text)
827
+ span = len(target)
828
+ if any(
829
+ doc_words[i : i + span] == target
830
+ for i in range(len(doc_words) - span + 1)
831
+ ):
832
+ return f"Project {stripped}"
833
+ return display_name
834
+
835
+
836
+ def _is_word_subsequence(short_words: list[str], long_words: list[str]) -> bool:
837
+ """True when ``short_words`` appears as a contiguous run inside ``long_words``."""
838
+ span = len(short_words)
839
+ if not span or span >= len(long_words):
840
+ return False
841
+ return any(
842
+ long_words[i : i + span] == short_words
843
+ for i in range(len(long_words) - span + 1)
844
+ )
845
+
846
+
847
+ def _dedupe_project_substrings(
848
+ entities: list[ExtractedEntity],
849
+ ) -> list[ExtractedEntity]:
850
+ """Drop a bare ``project`` entity subsumed by a fuller ``"Project X"`` sibling.
851
+
852
+ Conservative and PROJECT-SCOPED: when two ``project`` entities exist and one's
853
+ name is a contiguous word-subsequence of the other (e.g. ``Helios`` vs
854
+ ``Project Helios``), keep only the longer named form. Deliberately NOT applied
855
+ to org/tool/topic — for those a shorter key is often the correct concept
856
+ (``billing`` vs ``billing platform``), so a blanket keep-longest would hurt
857
+ recall.
858
+
859
+ The word-subsequence test runs on tokens of the ``display_name`` (via
860
+ :func:`_tokenize_words`), NOT on ``canonical_key`` — the strip-all key (Bug B)
861
+ has every separator removed, so ``canonical_key.split()`` no longer yields the
862
+ individual words needed for a subsequence check. The drop set is still keyed
863
+ by ``canonical_key`` (the entity identity).
864
+ """
865
+ projects = [e for e in entities if e.entity_type == "project"]
866
+ if len(projects) < 2:
867
+ return entities
868
+ drop_keys: set[str] = set()
869
+ for candidate in projects:
870
+ candidate_words = _tokenize_words(candidate.display_name)
871
+ for other in projects:
872
+ if other.canonical_key == candidate.canonical_key:
873
+ continue
874
+ if _is_word_subsequence(
875
+ candidate_words, _tokenize_words(other.display_name)
876
+ ):
877
+ drop_keys.add(candidate.canonical_key)
878
+ break
879
+ if not drop_keys:
880
+ return entities
881
+ return [
882
+ e
883
+ for e in entities
884
+ if not (e.entity_type == "project" and e.canonical_key in drop_keys)
885
+ ]