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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/config.py
ADDED
|
@@ -0,0 +1,1949 @@
|
|
|
1
|
+
"""Configuration loading from environment / .env."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from dotenv import dotenv_values, find_dotenv
|
|
9
|
+
|
|
10
|
+
DEFAULT_OLLAMA_HOST = "http://localhost:11434"
|
|
11
|
+
DEFAULT_QWEN3_MODEL = "qwen3-embedding:8b"
|
|
12
|
+
DEFAULT_EMBEDDER = "arctic"
|
|
13
|
+
# ``none`` is the FTS-only backend (see :class:`brain.embeddings.NullEmbedder`):
|
|
14
|
+
# a user with no Ollama gets a working brain (ingest + lexical search + passing
|
|
15
|
+
# doctor) instead of crashes. It produces no vectors, so hybrid search degrades
|
|
16
|
+
# to the FTS leg.
|
|
17
|
+
_VALID_EMBEDDERS = {"arctic", "voyage", "qwen3", "none"}
|
|
18
|
+
|
|
19
|
+
# How long Ollama keeps a model loaded in VRAM between requests. Passed as
|
|
20
|
+
# ``keep_alive`` in every outgoing Ollama HTTP payload (``/api/embed``,
|
|
21
|
+
# ``/api/chat``, ``/api/generate``). "30m" keeps the model hot across bursts of
|
|
22
|
+
# ingest/search; raise to "1h" if your workflow has longer idle gaps, or set
|
|
23
|
+
# "-1" to keep the model loaded indefinitely (never unloaded). Set via
|
|
24
|
+
# ``BRAIN_OLLAMA_KEEP_ALIVE``; accepts any format Ollama understands: a positive
|
|
25
|
+
# integer duration string ("30m", "1h", "60s"), a bare positive integer seconds
|
|
26
|
+
# string ("60"), "-1" (keep loaded forever), or "0" (unload immediately after
|
|
27
|
+
# each call). The shipped default stays "30m" so a machine shared with other GPU
|
|
28
|
+
# workloads frees VRAM on idle; on a dedicated box, "-1" eliminates the measured
|
|
29
|
+
# 5,028ms cold-embed latency cliff that hits the first embed once the model has
|
|
30
|
+
# gone idle past the keep_alive window. Other malformed values are rejected.
|
|
31
|
+
DEFAULT_OLLAMA_KEEP_ALIVE = "30m"
|
|
32
|
+
|
|
33
|
+
# Accepts "30m", "1h", "60s", "60" etc. — any POSITIVE integer optionally
|
|
34
|
+
# followed by m/h/s — plus the two Ollama sentinels "-1" (keep loaded
|
|
35
|
+
# indefinitely) and "0" (unload immediately). Empty / whitespace-only strings
|
|
36
|
+
# fall back to the default; every other malformed value ("-2", "-1m", "0m",
|
|
37
|
+
# "abc", "1.5m", …) is rejected at load time.
|
|
38
|
+
_KEEP_ALIVE_RE = re.compile(r"^(-1|0|[1-9]\d*(?:m|h|s)?)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def keep_alive_wire_value(keep_alive: str) -> str | int:
|
|
42
|
+
"""Coerce the numeric keep_alive sentinels to JSON numbers for Ollama.
|
|
43
|
+
|
|
44
|
+
Ollama's ``/api/embed`` and ``/api/chat`` accept ``keep_alive`` as either
|
|
45
|
+
a duration STRING with a unit (``"30m"``, ``"1h"``) or a bare JSON NUMBER
|
|
46
|
+
of seconds (``-1`` = keep the model loaded indefinitely, ``0`` = unload
|
|
47
|
+
immediately). They reject the unit-less strings ``"-1"`` / ``"0"`` with
|
|
48
|
+
HTTP 400 (``time: missing unit in duration "-1"``). Config validation
|
|
49
|
+
accepts ``"-1"`` / ``"0"`` as the documented sentinels, so EVERY Ollama
|
|
50
|
+
payload site must translate through this one function; unit-bearing
|
|
51
|
+
strings pass through unchanged.
|
|
52
|
+
"""
|
|
53
|
+
if keep_alive in ("-1", "0"):
|
|
54
|
+
return int(keep_alive)
|
|
55
|
+
return keep_alive
|
|
56
|
+
|
|
57
|
+
# Cosine-similarity floor for the vector leg of hybrid search. Tuned
|
|
58
|
+
# empirically against the live corpus on 2026-05-06 (see Phase D of
|
|
59
|
+
# `docs/plans/2026-05-06-search-ranking-fix.md` and
|
|
60
|
+
# `tests/test_search_floor_default_excludes_known_bad.py`).
|
|
61
|
+
#
|
|
62
|
+
# Measurement: real Arctic embedding of the query ``person-x``
|
|
63
|
+
# vs. stored chunk embeddings on the live corpus.
|
|
64
|
+
#
|
|
65
|
+
# * Known-bad docs (interview prep / cheatsheet false positives):
|
|
66
|
+
# max cosine <= 0.20.
|
|
67
|
+
# * True positives kept by acceptance criterion #1: Krisp meeting
|
|
68
|
+
# ``3508c63e`` max 0.36; Gmail thread ``bc9f06c9`` max 0.27.
|
|
69
|
+
#
|
|
70
|
+
# The clean gap is (0.20, 0.27). A floor of 0.25 sits ``max_bad +
|
|
71
|
+
# 0.05`` (per plan revision #3) and stays comfortably below the
|
|
72
|
+
# 0.27 true-positive ceiling so neither acceptance-criterion doc is
|
|
73
|
+
# dropped from the vector leg. Override via ``BRAIN_VECTOR_SIM_FLOOR``.
|
|
74
|
+
DEFAULT_VECTOR_SIM_FLOOR = 0.25
|
|
75
|
+
|
|
76
|
+
# Default exponential-decay half-life for the recency boost applied after RRF.
|
|
77
|
+
# At 180 days a document is a year old (365 days) => boost ~0.25x. Tuned as
|
|
78
|
+
# a reasonable default for a personal corpus that spans years; override via
|
|
79
|
+
# ``BRAIN_RECENCY_HALFLIFE_DAYS``. Set to a very large value (e.g. 999999)
|
|
80
|
+
# to effectively disable the boost.
|
|
81
|
+
DEFAULT_RECENCY_HALFLIFE_DAYS = 180.0
|
|
82
|
+
|
|
83
|
+
# Default token budget for snippet-context expansion. After the best-matching
|
|
84
|
+
# chunk is selected, this many tokens of neighboring-chunk context are
|
|
85
|
+
# stitched around it to give Claude / the user richer reading context. Set to
|
|
86
|
+
# 0 to disable expansion. Override via ``BRAIN_SNIPPET_CONTEXT_TOKENS``.
|
|
87
|
+
DEFAULT_SNIPPET_CONTEXT_TOKENS = 200
|
|
88
|
+
|
|
89
|
+
# Default vault location -- clean, no implicit cloud sync. Users who want iCloud
|
|
90
|
+
# can either symlink ``~/brain-vault`` to an iCloud Drive folder or set
|
|
91
|
+
# ``BRAIN_VAULT_PATH`` to an iCloud path.
|
|
92
|
+
DEFAULT_VAULT_PATH = Path.home() / "brain-vault"
|
|
93
|
+
|
|
94
|
+
# Default doc-count threshold for the People Hub (``brain people``,
|
|
95
|
+
# ``<vault>/people/``). Persons with fewer than this many documents whose
|
|
96
|
+
# names are NOT pinned in ``<vault>/_people.yml`` are filtered out. The
|
|
97
|
+
# default of 3 keeps the long-tail of one-off cc'd recipients from each
|
|
98
|
+
# getting their own page; curated ``_people.yml`` entries always render
|
|
99
|
+
# regardless. Override via ``BRAIN_PEOPLE_HUB_MIN_DOCS``.
|
|
100
|
+
DEFAULT_PEOPLE_HUB_MIN_DOCS = 3
|
|
101
|
+
|
|
102
|
+
# Plan 09 -- quick-capture inbox (`brain capture`) defaults.
|
|
103
|
+
#
|
|
104
|
+
# Number of leading words from the body used to build the auto-title slug when
|
|
105
|
+
# ``--title`` is omitted. Override via ``BRAIN_CAPTURE_TITLE_WORDS``.
|
|
106
|
+
DEFAULT_CAPTURE_TITLE_WORDS = 6
|
|
107
|
+
# Inbox size strictly above which `brain doctor` warns the capture queue is
|
|
108
|
+
# backing up (count > threshold; consumed in Plan 09 Phase 3). Override via
|
|
109
|
+
# ``BRAIN_CAPTURE_INBOX_WARN_THRESHOLD``.
|
|
110
|
+
DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD = 20
|
|
111
|
+
|
|
112
|
+
# Plan 10 -- `brain review weekly` periodic-synthesis defaults. All three are
|
|
113
|
+
# positive-integer display caps validated the same way as the capture knobs.
|
|
114
|
+
#
|
|
115
|
+
# Max activity / ingested documents listed per weekly report section.
|
|
116
|
+
DEFAULT_REVIEW_ACTIVITY_LIMIT = 20
|
|
117
|
+
# Max theme clusters (graph communities or tag clusters) per weekly report.
|
|
118
|
+
DEFAULT_REVIEW_THEME_LIMIT = 5
|
|
119
|
+
# Max open-loop (action item) rows listed per weekly report.
|
|
120
|
+
DEFAULT_REVIEW_OPEN_LOOP_LIMIT = 20
|
|
121
|
+
|
|
122
|
+
# Plan 01 -- `brain brief` proactive daily-digest defaults. All four are
|
|
123
|
+
# positive-integer knobs validated the same way as the capture knobs.
|
|
124
|
+
#
|
|
125
|
+
# Default capture window (hours) for the brief's "recent captures" section.
|
|
126
|
+
DEFAULT_BRIEF_SINCE_HOURS = 24
|
|
127
|
+
# Default open-todo window (days) for the brief's "open action items" section.
|
|
128
|
+
DEFAULT_BRIEF_TODO_SINCE_DAYS = 7
|
|
129
|
+
# Max recent-capture rows listed in the brief.
|
|
130
|
+
DEFAULT_BRIEF_CAPTURE_LIMIT = 20
|
|
131
|
+
# Max pinned-doc rows listed in the brief.
|
|
132
|
+
DEFAULT_BRIEF_PIN_LIMIT = 10
|
|
133
|
+
|
|
134
|
+
# Wave Q1-D -- enrichment (auto-summary + auto-tag) defaults.
|
|
135
|
+
#
|
|
136
|
+
# ``DEFAULT_ENRICH_MODEL`` is the Ollama model name passed to ``/api/chat``.
|
|
137
|
+
# llama3.1:8b is the only model authorized by the roadmap intake brief for
|
|
138
|
+
# Q1-D. Users override via ``BRAIN_ENRICH_MODEL`` -- anything pullable via
|
|
139
|
+
# ``ollama pull <name>`` works as long as it supports JSON-mode output.
|
|
140
|
+
DEFAULT_ENRICH_MODEL = "llama3.1:8b"
|
|
141
|
+
|
|
142
|
+
# Min content tokens (tiktoken ``cl100k_base``) below which the post-ingest
|
|
143
|
+
# enrichment hook silently skips. A 50-token doc is roughly two sentences;
|
|
144
|
+
# the title alone is already a fine summary. Conservative default mirrors
|
|
145
|
+
# the planner's recommendation (D3 in the wave plan).
|
|
146
|
+
DEFAULT_ENRICH_MIN_TOKENS = 50
|
|
147
|
+
|
|
148
|
+
# Max content tokens fed to the model. llama3.1:8b has a 128K context window
|
|
149
|
+
# but we never need more than the opening of a doc to summarize it. Capping
|
|
150
|
+
# at 1200 tokens (~900 words, the doc head) keeps the LLM round-trip under
|
|
151
|
+
# ~2.5s on M-series silicon (was ~7s at 4000 tokens) and bounds the prompt
|
|
152
|
+
# cost while still covering the content that matters most for a 2-3 sentence
|
|
153
|
+
# summary. Override via ``BRAIN_ENRICH_MAX_INPUT_TOKENS`` when higher fidelity
|
|
154
|
+
# on very long docs is more important than throughput.
|
|
155
|
+
DEFAULT_ENRICH_MAX_INPUT_TOKENS = 1200
|
|
156
|
+
|
|
157
|
+
# Ollama HTTP timeout for ``/api/chat`` calls. 60 s gives headroom for a
|
|
158
|
+
# cold-model swap-in on first call without spiraling. Override via
|
|
159
|
+
# ``BRAIN_ENRICH_TIMEOUT_SECONDS``.
|
|
160
|
+
DEFAULT_ENRICH_TIMEOUT_SECONDS = 60.0
|
|
161
|
+
|
|
162
|
+
# Plan 02 -- spaced-repetition resurfacing (`brain resurface`) defaults.
|
|
163
|
+
#
|
|
164
|
+
# ``DEFAULT_RESURFACE_LIMIT`` is the number of docs surfaced per run when
|
|
165
|
+
# ``--limit`` is omitted. Override via ``BRAIN_RESURFACE_LIMIT`` (int >= 1).
|
|
166
|
+
DEFAULT_RESURFACE_LIMIT = 7
|
|
167
|
+
# Documents younger than this many days are excluded from the queue before
|
|
168
|
+
# scoring -- brand-new notes don't need resurfacing. Override via
|
|
169
|
+
# ``BRAIN_RESURFACE_MIN_AGE_DAYS`` (int >= 0).
|
|
170
|
+
DEFAULT_RESURFACE_MIN_AGE_DAYS = 14
|
|
171
|
+
# Exponential half-life (days) for the age factor: at this age the age factor
|
|
172
|
+
# is 0.5, saturating toward 1.0 for much older docs. Reuses the ``0.5^(t/hl)``
|
|
173
|
+
# decay shape from search.py, inverted so older = higher priority. Override via
|
|
174
|
+
# ``BRAIN_RESURFACE_AGE_HALFLIFE_DAYS`` (float > 0).
|
|
175
|
+
DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS = 180.0
|
|
176
|
+
# Exponential half-life (days) for the access-staleness factor: a doc last
|
|
177
|
+
# opened this many days ago contributes 0.5. Shorter than the age half-life so
|
|
178
|
+
# a recent open visibly deprioritizes a doc. Override via
|
|
179
|
+
# ``BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS`` (float > 0).
|
|
180
|
+
DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS = 90.0
|
|
181
|
+
|
|
182
|
+
# Wave G1-c -- GraphRAG incremental sync (people aspect) settings.
|
|
183
|
+
#
|
|
184
|
+
# Graph sync is OPT-IN by origin; ``BRAIN_GRAPH_ENABLED`` now defaults to True so
|
|
185
|
+
# new deployments get graph retrieval out of the box. Deployments on stock pgvector
|
|
186
|
+
# (no AGE) are safe: GraphSyncer.reconcile is best-effort + never-raises. When
|
|
187
|
+
# the AGE image is present, a post-write/post-delete hook keeps the graph in sync with
|
|
188
|
+
# ``documents`` (see :mod:`brain.graph_rag.sync`). The remaining knobs map
|
|
189
|
+
# 1:1 onto :class:`brain.graph_rag.reconcile.ReconcileConfig`; defaults
|
|
190
|
+
# mirror the canonical constants in :mod:`brain.graph_rag.cooccur` /
|
|
191
|
+
# :mod:`brain.graph_rag.weighting` -- kept as literals here (not imported) so
|
|
192
|
+
# ``config`` stays import-cheap and free of any cycle with the graph package.
|
|
193
|
+
DEFAULT_GRAPH_ENABLED = True
|
|
194
|
+
DEFAULT_GRAPH_TENANT_ID = "default"
|
|
195
|
+
DEFAULT_GRAPH_COOCCUR_WINDOW = 3 # == brain.graph_rag.cooccur.DEFAULT_COOCCUR_WINDOW
|
|
196
|
+
DEFAULT_GRAPH_MAX_ENTITIES = 40 # == cooccur.DEFAULT_MAX_ENTITIES_PER_DOC
|
|
197
|
+
DEFAULT_GRAPH_GENERIC_DF_RATIO = 0.30 # == weighting.DEFAULT_GENERIC_DF
|
|
198
|
+
|
|
199
|
+
# Accepted spellings for the boolean ``BRAIN_GRAPH_ENABLED`` /
|
|
200
|
+
# ``BRAIN_GRAPH_CONCEPTS`` flags (compared case-insensitively after ``.strip()``).
|
|
201
|
+
_GRAPH_ENABLED_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
202
|
+
_GRAPH_ENABLED_FALSY = frozenset({"0", "false", "no", "off"})
|
|
203
|
+
|
|
204
|
+
# Wave G2 -- GraphRAG concept extraction + bounded retrieval (spec §10). Parsed
|
|
205
|
+
# here in G2-a; the concept aspect (G2-b/c) and the local/themes retrieval
|
|
206
|
+
# surfaces (G2-d..i) consume them. Defaults follow spec §10 + Codex ruling Q4
|
|
207
|
+
# (``BRAIN_GRAPH_MAX_DEGREE`` = 50, ``BRAIN_GRAPH_MIN_EDGE_WEIGHT`` = 0.20).
|
|
208
|
+
DEFAULT_GRAPH_CONCEPTS = True
|
|
209
|
+
# Ollama model for the gated concept entity extractor (spec §3 D3: the default
|
|
210
|
+
# ``OllamaExtractor`` wraps ``enrichment.extract_entities()``). Mirrors the
|
|
211
|
+
# enrich-model default convention but kept a separate literal so the concept
|
|
212
|
+
# extractor and the summary enricher stay independently overridable -- ``config``
|
|
213
|
+
# carries no enrich<->graph coupling.
|
|
214
|
+
DEFAULT_GRAPH_EXTRACT_MODEL = "llama3.1:8b"
|
|
215
|
+
# Head-cap (tiktoken ``cl100k_base`` tokens) applied to a document body BEFORE the
|
|
216
|
+
# concept extractor chunks it (perf Fix C, 2026-05-24). The extractor calls the
|
|
217
|
+
# LLM once per ~1500-token chunk, so a long document generates a heavy tail of
|
|
218
|
+
# calls (the perf investigation measured 7.7% of docs driving ~33% of all calls).
|
|
219
|
+
# Capping the input to its first ~5-6 chunks bounds that tail while preserving
|
|
220
|
+
# recall on the bulk; mirrors the summary enricher's input head cap. ``0`` /
|
|
221
|
+
# ``none`` / ``unlimited`` disables the cap (whole document extracted) -- the
|
|
222
|
+
# escape hatch. A generous default of 8000 only trims the extreme tail.
|
|
223
|
+
DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS = 8000
|
|
224
|
+
DEFAULT_GRAPH_DEPTH = 2 # spec §6 bounded variable-length traversal radius
|
|
225
|
+
DEFAULT_GRAPH_FRONTIER_CAP = 200 # spec §6 LIMIT on entities reached per seed
|
|
226
|
+
DEFAULT_GRAPH_MAX_DEGREE = 50 # Codex ruling Q4 -- per-node expansion fan-out cap
|
|
227
|
+
DEFAULT_GRAPH_MIN_EDGE_WEIGHT = 0.20 # Codex ruling Q4 -- normalized-lift floor
|
|
228
|
+
DEFAULT_GRAPH_THEME_LIMIT = 5 # spec §6b ranked ThemeGroup count
|
|
229
|
+
|
|
230
|
+
# Wave G3 -- global community detection (networkx Louvain) knobs (spec §17c).
|
|
231
|
+
# Parsed here in G3-a; the detection core (G3-b), the lazy/eager summaries
|
|
232
|
+
# (G3-c), and the global retrieval path (G3-d) consume them. §17c pins the
|
|
233
|
+
# migration-013 schema (Q1) and the perf budgets (Q8) but does NOT pin these
|
|
234
|
+
# tuning defaults, so the values below are CHOSEN to be consistent with the
|
|
235
|
+
# existing graph knobs and are documented as such in ``.env.example``.
|
|
236
|
+
DEFAULT_GRAPH_COMMUNITY_RESOLUTION = 1.0 # networkx louvain_communities() default
|
|
237
|
+
DEFAULT_GRAPH_COMMUNITY_SEED = 1234 # deterministic Louvain RNG seed (chosen)
|
|
238
|
+
DEFAULT_GRAPH_COMMUNITY_MIN_SIZE = 3 # min members to materialize a community (chosen)
|
|
239
|
+
DEFAULT_GRAPH_COMMUNITY_JACCARD = 0.5 # stable-identity match threshold (§17c Q3/Q7; chosen)
|
|
240
|
+
DEFAULT_GRAPH_COMMUNITY_LIMIT = 5 # global retrieval community count (== theme limit)
|
|
241
|
+
# Perf/ops safety valve: max communities materialized per tenant per build
|
|
242
|
+
# (bounds the summary + embedding cost behind the §17c Q8 budgets). ``None`` ==
|
|
243
|
+
# unlimited so the default is behavior-neutral; mirrors the int|none idiom of
|
|
244
|
+
# ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``.
|
|
245
|
+
DEFAULT_GRAPH_COMMUNITY_MAX: int | None = None
|
|
246
|
+
|
|
247
|
+
# Tacit-knowledge elicitation knobs (feat/tacit-knowledge-elicitation).
|
|
248
|
+
#
|
|
249
|
+
# ``DEFAULT_ELICIT_MIN_EVIDENCE_DOCS`` — minimum number of evidence documents
|
|
250
|
+
# required before a gap is eligible for surfacing. A gap with fewer supporting
|
|
251
|
+
# docs is too thin to act on; conservative default of 3 mirrors the People Hub
|
|
252
|
+
# threshold.
|
|
253
|
+
DEFAULT_ELICIT_MIN_EVIDENCE_DOCS = 3
|
|
254
|
+
|
|
255
|
+
# Minimum gap score (in [0.0, 1.0]) for a gap to enter the queue. Below this
|
|
256
|
+
# threshold the gap is computed but silently discarded. Default 0.3 keeps
|
|
257
|
+
# low-confidence noise out while still surfacing moderate signals.
|
|
258
|
+
DEFAULT_ELICIT_MIN_GAP_SCORE = 0.3
|
|
259
|
+
|
|
260
|
+
# Maximum number of open gaps returned by ``brain elicit list`` in a single
|
|
261
|
+
# query. Keeps the CLI output manageable; override via
|
|
262
|
+
# ``BRAIN_ELICIT_QUEUE_LIMIT``.
|
|
263
|
+
DEFAULT_ELICIT_QUEUE_LIMIT = 20
|
|
264
|
+
|
|
265
|
+
# Gates the contradiction detector. Off by default because contradiction
|
|
266
|
+
# detection requires at least ``elicit_contradiction_min_docs`` documents for
|
|
267
|
+
# a target and can produce false positives on sparse corpora.
|
|
268
|
+
DEFAULT_ELICIT_CONTRADICTION_ENABLED = False
|
|
269
|
+
|
|
270
|
+
# Minimum documents for a target before contradiction detection runs on it.
|
|
271
|
+
# Ignored when ``elicit_contradiction_enabled`` is False.
|
|
272
|
+
DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS = 5
|
|
273
|
+
|
|
274
|
+
# Plan 05 -- `brain timeline` (temporal evolution) knobs.
|
|
275
|
+
#
|
|
276
|
+
# ``DEFAULT_TIMELINE_GRANULARITY`` is the default time-bucket width; one of
|
|
277
|
+
# ``auto`` / ``month`` / ``quarter`` / ``year``. ``auto`` (the default) picks the
|
|
278
|
+
# coarsest of {year, quarter, month} that yields >=3 non-empty buckets for the
|
|
279
|
+
# matched docs' date span — a fixed ``quarter`` collapsed a young (few-month)
|
|
280
|
+
# corpus into a single bucket, hiding all evolution. An explicit value forces
|
|
281
|
+
# that granularity exactly. Override via ``BRAIN_TIMELINE_GRANULARITY``.
|
|
282
|
+
DEFAULT_TIMELINE_GRANULARITY = "auto"
|
|
283
|
+
_VALID_TIMELINE_GRANULARITIES = frozenset({"auto", "month", "quarter", "year"})
|
|
284
|
+
|
|
285
|
+
# Max buckets returned by ``brain timeline`` before the sparse tail is trimmed.
|
|
286
|
+
# Positive int. Override via ``BRAIN_TIMELINE_LIMIT``.
|
|
287
|
+
DEFAULT_TIMELINE_LIMIT = 20
|
|
288
|
+
|
|
289
|
+
# Max number of densest buckets that get an Ollama ``--synthesize`` summary.
|
|
290
|
+
# Non-negative int; ``0`` disables synthesis even when ``--synthesize`` is
|
|
291
|
+
# passed (keeps the LLM round-trips bounded). Override via
|
|
292
|
+
# ``BRAIN_TIMELINE_SYNTH_LIMIT``.
|
|
293
|
+
DEFAULT_TIMELINE_SYNTH_LIMIT = 5
|
|
294
|
+
|
|
295
|
+
# Which end of the timeline is trimmed when ``--limit`` is hit: ``oldest``
|
|
296
|
+
# (drop the earliest buckets, keep the most recent) or ``sparsest`` (drop the
|
|
297
|
+
# buckets with the fewest docs). Override via ``BRAIN_TIMELINE_TRIM``.
|
|
298
|
+
DEFAULT_TIMELINE_TRIM = "oldest"
|
|
299
|
+
_VALID_TIMELINE_TRIMS = frozenset({"oldest", "sparsest"})
|
|
300
|
+
|
|
301
|
+
# Plan 07 -- `brain connect` proactive auto-link suggestion knobs.
|
|
302
|
+
#
|
|
303
|
+
# ``DEFAULT_CONNECT_MIN_SCORE`` is the RRF-blend confidence floor: candidate
|
|
304
|
+
# pairs scoring below this are silently discarded (no DB write). Tuned against
|
|
305
|
+
# a live ~1.3k-doc corpus (2026-06-10): the original 0.30 floor admitted
|
|
306
|
+
# essentially every candidate (6.2k pending — the per-doc cap, not the floor,
|
|
307
|
+
# was binding); 0.60 keeps ~1.5 high-signal suggestions per doc. Lower via
|
|
308
|
+
# ``BRAIN_CONNECT_MIN_SCORE`` for more recall; must be a float in (0.0, 1.0].
|
|
309
|
+
DEFAULT_CONNECT_MIN_SCORE = 0.60
|
|
310
|
+
# Per-source-doc cap on candidate targets pulled from EACH leg (graph +
|
|
311
|
+
# embedding) before the RRF blend. Bounds the per-doc cost. Override via
|
|
312
|
+
# ``BRAIN_CONNECT_CANDIDATE_LIMIT``; must be an integer >= 1.
|
|
313
|
+
DEFAULT_CONNECT_CANDIDATE_LIMIT = 50
|
|
314
|
+
# Max suggestions persisted per source doc after gating + ranking. Keeps the
|
|
315
|
+
# review queue focused. Override via ``BRAIN_CONNECT_MAX_PER_DOC``; integer >= 1.
|
|
316
|
+
DEFAULT_CONNECT_MAX_PER_DOC = 5
|
|
317
|
+
# Plan 03 -- `brain review scan` contradiction + staleness knobs.
|
|
318
|
+
# Conflict scan caps: max entity candidates per run, max doc pairs adjudicated
|
|
319
|
+
# per surviving entity, and the embedding cosine floor below which a pair is too
|
|
320
|
+
# topically distant to possibly contradict. The product of the first two bounds
|
|
321
|
+
# the worst-case LLM call budget (default 30 x 3 = 90).
|
|
322
|
+
DEFAULT_REVIEW_CONFLICT_LIMIT = 30
|
|
323
|
+
DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY = 3
|
|
324
|
+
DEFAULT_REVIEW_EMBED_SIM_FLOOR = 0.40
|
|
325
|
+
# Staleness scan: a doc older than ``stale_age_days`` is a candidate; a newer
|
|
326
|
+
# doc ingested within ``stale_supersede_window_days`` sharing an entity and with
|
|
327
|
+
# cosine >= ``stale_sim_floor`` supersedes it. ``stale_limit`` caps candidates.
|
|
328
|
+
DEFAULT_REVIEW_STALE_AGE_DAYS = 365
|
|
329
|
+
DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS = 90
|
|
330
|
+
DEFAULT_REVIEW_STALE_SIM_FLOOR = 0.60
|
|
331
|
+
DEFAULT_REVIEW_STALE_LIMIT = 200
|
|
332
|
+
# Plan 06 -- `brain ask` agentic-synthesis knobs.
|
|
333
|
+
#
|
|
334
|
+
# ``DEFAULT_ASK_MAX_ITERATIONS`` is the hard cap on plan/retrieve/reflect loop
|
|
335
|
+
# iterations when ``--no-loop`` is not given. 3 covers the initial plan plus two
|
|
336
|
+
# reflect-driven follow-up rounds -- enough for multi-hop coverage without
|
|
337
|
+
# unbounded Ollama latency. Override via ``BRAIN_ASK_MAX_ITERATIONS`` (int >= 1).
|
|
338
|
+
DEFAULT_ASK_MAX_ITERATIONS = 3
|
|
339
|
+
|
|
340
|
+
# Max documents retrieved per sub-query per iteration. Mirrors the search
|
|
341
|
+
# ``--limit`` default. Override via ``BRAIN_ASK_DOCS_PER_ITER`` (int >= 1).
|
|
342
|
+
DEFAULT_ASK_DOCS_PER_ITER = 5
|
|
343
|
+
|
|
344
|
+
# Ollama HTTP timeout (seconds) for each ask plan/reflect/synthesize chat call.
|
|
345
|
+
# 90 s gives headroom for a cold-model swap-in on the first (plan) call plus the
|
|
346
|
+
# longer synthesize completion without spiraling. Override via
|
|
347
|
+
# ``BRAIN_ASK_TIMEOUT_SECONDS`` (float > 0).
|
|
348
|
+
DEFAULT_ASK_TIMEOUT_SECONDS = 90.0
|
|
349
|
+
# Plan 04 -- `brain audio` (NotebookLM-style two-host overview) knobs.
|
|
350
|
+
#
|
|
351
|
+
# ``DEFAULT_AUDIO_SCRIPT_MODEL`` is the Ollama model name passed to the shared
|
|
352
|
+
# ``chat_json`` helper for dialogue generation. Kept a separate literal from the
|
|
353
|
+
# enrich / extract models so the audio script model stays independently
|
|
354
|
+
# overridable via ``BRAIN_AUDIO_SCRIPT_MODEL``.
|
|
355
|
+
DEFAULT_AUDIO_SCRIPT_MODEL = "llama3.1:8b"
|
|
356
|
+
# Max speaker turns in the generated dialogue. Must be a POSITIVE EVEN integer
|
|
357
|
+
# (pairs of Host/Guest). The generator truncates any surplus turns the model
|
|
358
|
+
# emits beyond this cap. Override via ``BRAIN_AUDIO_MAX_TURNS``.
|
|
359
|
+
DEFAULT_AUDIO_MAX_TURNS = 12
|
|
360
|
+
# Max prompt tokens (tiktoken ``cl100k_base``) fed to the script generator. The
|
|
361
|
+
# bundle (entity names + theme summaries + doc titles/summaries — never raw
|
|
362
|
+
# bodies) is trimmed group-by-group to fit this budget before the LLM call,
|
|
363
|
+
# bounding the round-trip cost. Override via ``BRAIN_AUDIO_MAX_INPUT_TOKENS``.
|
|
364
|
+
DEFAULT_AUDIO_MAX_INPUT_TOKENS = 3000
|
|
365
|
+
# Max theme / community groups pulled from the graph context into the source
|
|
366
|
+
# bundle. Positive int. Override via ``BRAIN_AUDIO_THEME_LIMIT``.
|
|
367
|
+
DEFAULT_AUDIO_THEME_LIMIT = 4
|
|
368
|
+
|
|
369
|
+
# Plan 08 -- `brain gaps` search-failure-driven knowledge-gap detection knobs.
|
|
370
|
+
# ``DEFAULT_GAPS_LOOKBACK_DAYS`` is the mining window for the ``search_queries``
|
|
371
|
+
# log; ``DEFAULT_GAPS_MIN_CLUSTER_SIZE`` is the minimum query-occurrence count
|
|
372
|
+
# before a failed-query cluster surfaces as a gap. Both are positive integers
|
|
373
|
+
# validated like the other int knobs. Override via ``BRAIN_GAPS_LOOKBACK_DAYS``
|
|
374
|
+
# / ``BRAIN_GAPS_MIN_CLUSTER_SIZE``.
|
|
375
|
+
DEFAULT_GAPS_LOOKBACK_DAYS = 30
|
|
376
|
+
DEFAULT_GAPS_MIN_CLUSTER_SIZE = 2
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Boilerplate regex patterns stripped from email bodies during Gmail ingest.
|
|
380
|
+
# Compiled with ``re.MULTILINE | re.IGNORECASE`` in
|
|
381
|
+
# :func:`brain.ingest.gmail.strip_boilerplate`. Default-deny for ``re.DOTALL``;
|
|
382
|
+
# patterns that genuinely need cross-line matching opt in with the inline
|
|
383
|
+
# ``(?s)`` flag and document why. Cross-line matches MUST be bounded by a
|
|
384
|
+
# non-greedy lookahead (``.*?(?=\n\n|\Z)``) so a single notice block can be
|
|
385
|
+
# absorbed without devouring the rest of the message.
|
|
386
|
+
#
|
|
387
|
+
# Single-source-of-truth: tweak the list here, no code change required.
|
|
388
|
+
BOILERPLATE_PATTERNS: tuple[str, ...] = (
|
|
389
|
+
# Common mobile-app footers -- single line, terminated by EOL.
|
|
390
|
+
r"^Sent from my (iPhone|iPad|Android|BlackBerry|Windows Phone)\.?$",
|
|
391
|
+
r"^Get Outlook for (iOS|Android)\s*<https?://[^>]+>\s*$",
|
|
392
|
+
# Confidentiality / corporate-disclaimer footers run multiple lines.
|
|
393
|
+
# ``(?s)`` enables DOTALL on this pattern only so ``.*?`` can cross
|
|
394
|
+
# newlines; the ``(?=\n\n|\Z)`` lookahead caps the match at the next
|
|
395
|
+
# blank line (or EOF) so we don't eat into legitimate downstream content.
|
|
396
|
+
r"(?s)^CONFIDENTIALITY NOTICE:.*?(?=\n\n|\Z)",
|
|
397
|
+
r"(?s)^This email and any attachments are confidential.*?(?=\n\n|\Z)",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _project_dotenv() -> Path:
|
|
402
|
+
"""Path to the .env file at the repo root, relative to this module.
|
|
403
|
+
|
|
404
|
+
config.py lives at <repo>/src/brain/config.py, so the repo root is two
|
|
405
|
+
parents up from this file's directory.
|
|
406
|
+
"""
|
|
407
|
+
return Path(__file__).resolve().parent.parent.parent / ".env"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _brain_home_root(_config_file: Path | None = None) -> Path:
|
|
411
|
+
"""Resolve the $BRAIN_HOME directory per T1.1 priority order.
|
|
412
|
+
|
|
413
|
+
Priority:
|
|
414
|
+
1. $BRAIN_HOME env var (expanduser applied).
|
|
415
|
+
2. Repo-root walk-up: if <three-levels-up>/pyproject.toml exists, that's
|
|
416
|
+
a dev checkout -- use the repo root (dev backcompat).
|
|
417
|
+
3. ~/.brain -- NOT created here; ``brain setup`` creates it lazily.
|
|
418
|
+
|
|
419
|
+
The optional ``_config_file`` parameter is a test-only seam: production
|
|
420
|
+
code never passes it. Tests exercise the dev-checkout branch by passing a
|
|
421
|
+
synthetic path whose three-levels-up ancestor contains ``pyproject.toml``.
|
|
422
|
+
"""
|
|
423
|
+
env = os.environ.get("BRAIN_HOME")
|
|
424
|
+
if env:
|
|
425
|
+
return Path(env).expanduser()
|
|
426
|
+
config_file = _config_file or Path(__file__).resolve()
|
|
427
|
+
repo_root = config_file.parent.parent.parent
|
|
428
|
+
if (repo_root / "pyproject.toml").is_file():
|
|
429
|
+
return repo_root
|
|
430
|
+
return Path.home() / ".brain"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _brain_home_dotenv() -> Path:
|
|
434
|
+
"""Path to $BRAIN_HOME/.env (resolved via _brain_home_root)."""
|
|
435
|
+
return _brain_home_root() / ".env"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class ConfigError(RuntimeError):
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_positive_int_env(env_var: str, default: int) -> int:
|
|
443
|
+
"""Parse ``env_var`` as an integer ``>= 1``, falling back to ``default``.
|
|
444
|
+
|
|
445
|
+
Unset / blank → ``default``; non-parseable or ``< 1`` → :class:`ConfigError`
|
|
446
|
+
at load time so a typo surfaces at startup, not mid-command. Mirrors the
|
|
447
|
+
eager-validation idiom used by every other int knob in this module, factored
|
|
448
|
+
out for the Plan 01 / Plan 10 positive-int families.
|
|
449
|
+
"""
|
|
450
|
+
raw = os.environ.get(env_var)
|
|
451
|
+
if raw is None or raw.strip() == "":
|
|
452
|
+
return default
|
|
453
|
+
try:
|
|
454
|
+
value = int(raw)
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
raise ConfigError(
|
|
457
|
+
f"{env_var} must be an integer >= 1 (got {raw!r})"
|
|
458
|
+
) from exc
|
|
459
|
+
if value < 1:
|
|
460
|
+
raise ConfigError(f"{env_var} must be an integer >= 1 (got {raw!r})")
|
|
461
|
+
return value
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _parse_unit_interval_env(env_var: str, default: float) -> float:
|
|
465
|
+
"""Parse ``env_var`` as a float in ``[0.0, 1.0]``, falling back to ``default``.
|
|
466
|
+
|
|
467
|
+
Unset / blank → ``default``; non-parseable or out of ``[0.0, 1.0]`` →
|
|
468
|
+
:class:`ConfigError` at load time so a typo surfaces at startup, not
|
|
469
|
+
mid-command. Mirrors the inline ``BRAIN_VECTOR_SIM_FLOOR`` /
|
|
470
|
+
``BRAIN_ELICIT_MIN_GAP_SCORE`` validation, factored out for the Plan 03
|
|
471
|
+
cosine-floor knobs.
|
|
472
|
+
"""
|
|
473
|
+
raw = os.environ.get(env_var)
|
|
474
|
+
if raw is None or raw.strip() == "":
|
|
475
|
+
return default
|
|
476
|
+
try:
|
|
477
|
+
value = float(raw)
|
|
478
|
+
except ValueError as exc:
|
|
479
|
+
raise ConfigError(
|
|
480
|
+
f"{env_var} must be a float in [0.0, 1.0] (got {raw!r})"
|
|
481
|
+
) from exc
|
|
482
|
+
if not (0.0 <= value <= 1.0):
|
|
483
|
+
raise ConfigError(
|
|
484
|
+
f"{env_var} must be a float in [0.0, 1.0] (got {raw!r})"
|
|
485
|
+
)
|
|
486
|
+
return value
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _default_vault_path() -> Path:
|
|
490
|
+
"""Resolve the vault root from ``BRAIN_VAULT_PATH``, else ``DEFAULT_VAULT_PATH``.
|
|
491
|
+
|
|
492
|
+
Single source of truth for vault-root resolution. Used BOTH as the
|
|
493
|
+
:attr:`Config.vault_path` field ``default_factory`` AND inside
|
|
494
|
+
:meth:`Config._load_field_dict`. Because the field default resolves the env
|
|
495
|
+
var at construction time, a ``Config`` built directly — bypassing
|
|
496
|
+
:meth:`Config.load` (as many test fixtures do) — honors ``BRAIN_VAULT_PATH``
|
|
497
|
+
exactly like ``Config.load()`` instead of silently falling back to the real
|
|
498
|
+
``~/brain-vault``. That parity is what stops the test suite's ingest mirrors
|
|
499
|
+
from leaking into the live vault; an empty / unset value yields the packaged
|
|
500
|
+
default.
|
|
501
|
+
"""
|
|
502
|
+
raw = os.environ.get("BRAIN_VAULT_PATH")
|
|
503
|
+
return Path(raw).expanduser() if raw else DEFAULT_VAULT_PATH
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@dataclass(frozen=True)
|
|
507
|
+
class Config:
|
|
508
|
+
"""Project configuration loaded from environment / .env.
|
|
509
|
+
|
|
510
|
+
``embedder`` selects the embedding backend at setup time (one of
|
|
511
|
+
``arctic`` / ``voyage`` / ``qwen3``). ``voyage_api_key`` is only
|
|
512
|
+
consulted when ``embedder == "voyage"``; for the other backends it can
|
|
513
|
+
be ``None``.
|
|
514
|
+
|
|
515
|
+
``user_email`` (P4.4) is the owner's primary email address, optionally
|
|
516
|
+
set via ``BRAIN_USER_EMAIL``. Consumed by the email-thread reading
|
|
517
|
+
mode in the rendered wiki -- the Quartz transformer reads
|
|
518
|
+
``process.env.BRAIN_USER_EMAIL`` at build time and bakes it into a
|
|
519
|
+
``window.BRAIN_USER_EMAIL`` global so the runtime "Show only my
|
|
520
|
+
replies" filter knows whose ``From:`` address counts as the user's.
|
|
521
|
+
Empty string / ``None`` disables the filter (button still renders;
|
|
522
|
+
matching no-ops).
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
database_url: str
|
|
526
|
+
brain_home: Path = field(default_factory=_brain_home_root)
|
|
527
|
+
ollama_host: str = DEFAULT_OLLAMA_HOST
|
|
528
|
+
qwen3_model: str = DEFAULT_QWEN3_MODEL
|
|
529
|
+
embedder: str = DEFAULT_EMBEDDER
|
|
530
|
+
voyage_api_key: str | None = None
|
|
531
|
+
vault_path: Path = field(default_factory=_default_vault_path)
|
|
532
|
+
user_email: str | None = None
|
|
533
|
+
vector_sim_floor: float = DEFAULT_VECTOR_SIM_FLOOR
|
|
534
|
+
# Comma-separated list of identifiers (emails AND/OR display names) that
|
|
535
|
+
# count as the corpus owner. Stripped from ``DocSnapshot.participant_keys``
|
|
536
|
+
# before R2 (``shared_participant``) and R3 (``same_day_participant``)
|
|
537
|
+
# evaluate, so a meeting/email isn't linked to every other doc the owner
|
|
538
|
+
# is on. Loaded from ``BRAIN_OWNER_PARTICIPANTS``; entries are trimmed,
|
|
539
|
+
# lowercased, and de-duplicated at load time so downstream comparisons
|
|
540
|
+
# can be a single ``.lower() in owner_participants`` check. Empty
|
|
541
|
+
# frozenset (default) is a fast-path no-op.
|
|
542
|
+
owner_participants: frozenset[str] = frozenset()
|
|
543
|
+
# Minimum number of associated documents required for a non-curated
|
|
544
|
+
# person to render a ``/people/<slug>`` hub page. Curated entries
|
|
545
|
+
# (anyone in ``<vault>/_people.yml``) always render regardless of
|
|
546
|
+
# this threshold. Loaded from ``BRAIN_PEOPLE_HUB_MIN_DOCS``; default
|
|
547
|
+
# is :data:`DEFAULT_PEOPLE_HUB_MIN_DOCS`. Negative values are
|
|
548
|
+
# rejected at load time via :class:`ConfigError` -- a negative
|
|
549
|
+
# threshold would silently flip the filter (no effective filtering)
|
|
550
|
+
# and is almost certainly a config bug.
|
|
551
|
+
people_hub_min_docs: int = DEFAULT_PEOPLE_HUB_MIN_DOCS
|
|
552
|
+
# Plan 09 -- quick-capture inbox knobs.
|
|
553
|
+
# ``capture_title_words`` is how many leading body words form the auto-title
|
|
554
|
+
# slug (>= 1); ``capture_inbox_warn_threshold`` is the inbox size above
|
|
555
|
+
# which `brain doctor` warns (>= 1; Phase 3 compares strictly greater-than).
|
|
556
|
+
# Both are validated at load time, mirroring the other int env knobs.
|
|
557
|
+
capture_title_words: int = DEFAULT_CAPTURE_TITLE_WORDS
|
|
558
|
+
capture_inbox_warn_threshold: int = DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD
|
|
559
|
+
# Plan 10 -- `brain review weekly` display caps. All three are positive
|
|
560
|
+
# integers (>= 1), validated at load time like the capture knobs.
|
|
561
|
+
review_activity_limit: int = DEFAULT_REVIEW_ACTIVITY_LIMIT
|
|
562
|
+
review_theme_limit: int = DEFAULT_REVIEW_THEME_LIMIT
|
|
563
|
+
review_open_loop_limit: int = DEFAULT_REVIEW_OPEN_LOOP_LIMIT
|
|
564
|
+
# Plan 01 -- `brain brief` daily-digest knobs. All four are positive
|
|
565
|
+
# integers (>= 1), validated at load time like the capture knobs.
|
|
566
|
+
brief_since_hours: int = DEFAULT_BRIEF_SINCE_HOURS
|
|
567
|
+
brief_todo_since_days: int = DEFAULT_BRIEF_TODO_SINCE_DAYS
|
|
568
|
+
brief_capture_limit: int = DEFAULT_BRIEF_CAPTURE_LIMIT
|
|
569
|
+
brief_pin_limit: int = DEFAULT_BRIEF_PIN_LIMIT
|
|
570
|
+
# Exponential-decay half-life (days) for the recency boost applied after
|
|
571
|
+
# RRF. ``boost = 0.5 ** (age_days / recency_halflife_days)`` where
|
|
572
|
+
# ``age_days`` is clamped to [0, +inf) so future-dated rows get boost=1.0.
|
|
573
|
+
# Loaded from ``BRAIN_RECENCY_HALFLIFE_DAYS``; must be a positive float.
|
|
574
|
+
recency_halflife_days: float = DEFAULT_RECENCY_HALFLIFE_DAYS
|
|
575
|
+
# Token budget for per-search snippet-context expansion. After the
|
|
576
|
+
# best-matching chunk is selected, this many tokens of neighboring-chunk
|
|
577
|
+
# context are stitched around it. 0 = disabled. Loaded from
|
|
578
|
+
# ``BRAIN_SNIPPET_CONTEXT_TOKENS``; must be a non-negative integer.
|
|
579
|
+
snippet_context_tokens: int = DEFAULT_SNIPPET_CONTEXT_TOKENS
|
|
580
|
+
# Plan 02 -- spaced-repetition resurfacing knobs (`brain resurface`). All
|
|
581
|
+
# four feed :func:`brain.resurface.resurface_docs`; grouped together and
|
|
582
|
+
# validated the same way as the other int/float env knobs. ``limit`` >= 1,
|
|
583
|
+
# ``min_age_days`` >= 0, both half-lives > 0.
|
|
584
|
+
resurface_limit: int = DEFAULT_RESURFACE_LIMIT
|
|
585
|
+
resurface_min_age_days: int = DEFAULT_RESURFACE_MIN_AGE_DAYS
|
|
586
|
+
resurface_age_halflife_days: float = DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS
|
|
587
|
+
resurface_access_halflife_days: float = DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS
|
|
588
|
+
# Wave Q1-D -- per-document auto-summary + auto-tag enrichment.
|
|
589
|
+
# The four fields are tightly coupled (they all feed ``OllamaEnricher``)
|
|
590
|
+
# so they live together at the tail of the dataclass.
|
|
591
|
+
enrich_model: str = DEFAULT_ENRICH_MODEL
|
|
592
|
+
enrich_min_tokens: int = DEFAULT_ENRICH_MIN_TOKENS
|
|
593
|
+
enrich_max_input_tokens: int = DEFAULT_ENRICH_MAX_INPUT_TOKENS
|
|
594
|
+
enrich_timeout_seconds: float = DEFAULT_ENRICH_TIMEOUT_SECONDS
|
|
595
|
+
# Per-request ``keep_alive`` sent in every outgoing Ollama payload (embedder
|
|
596
|
+
# ``/api/embed``, enricher ``/api/chat``, extractor ``/api/generate``). Keeps
|
|
597
|
+
# the model loaded in VRAM for this long after the last request, preventing
|
|
598
|
+
# the cold-load latency spike on the next call. Accepts any format Ollama
|
|
599
|
+
# understands: a positive integer duration string ("30m", "1h", "60s"), a
|
|
600
|
+
# bare positive integer seconds string ("60"), "-1" (keep loaded
|
|
601
|
+
# indefinitely — kills the measured 5,028ms cold-embed cliff), or "0"
|
|
602
|
+
# (unload immediately). Malformed values ("-2", "-1m", "abc", …) are
|
|
603
|
+
# rejected at load time via ConfigError. Override via
|
|
604
|
+
# ``BRAIN_OLLAMA_KEEP_ALIVE``.
|
|
605
|
+
ollama_keep_alive: str = DEFAULT_OLLAMA_KEEP_ALIVE
|
|
606
|
+
# Wave G1-c -- GraphRAG people-aspect incremental sync. ``graph_enabled``
|
|
607
|
+
# gates the post-write/delete reconcile hook; the other four resolve into
|
|
608
|
+
# the single shared :class:`ReconcileConfig`
|
|
609
|
+
# (:func:`brain.graph_rag.sync.build_reconcile_config`). ``owner_participants``
|
|
610
|
+
# (above) is reused as the reconcile ``owner_keys`` -- the corpus owner is
|
|
611
|
+
# stripped from the graph's person roster exactly as from the People Hub, so
|
|
612
|
+
# there is no separate ``BRAIN_GRAPH_OWNER_*`` knob.
|
|
613
|
+
graph_enabled: bool = DEFAULT_GRAPH_ENABLED
|
|
614
|
+
graph_tenant_id: str = DEFAULT_GRAPH_TENANT_ID
|
|
615
|
+
graph_cooccur_window: int = DEFAULT_GRAPH_COOCCUR_WINDOW
|
|
616
|
+
graph_max_entities: int | None = DEFAULT_GRAPH_MAX_ENTITIES
|
|
617
|
+
graph_generic_df_ratio: float = DEFAULT_GRAPH_GENERIC_DF_RATIO
|
|
618
|
+
# Wave G2 -- concept extraction + bounded-retrieval knobs (spec §10). Parsed
|
|
619
|
+
# in G2-a; consumed by the concept aspect (G2-b/c) and the local/themes
|
|
620
|
+
# traversal (G2-d..i). ``graph_concepts`` gates the concept aspect;
|
|
621
|
+
# ``graph_extract_model`` selects the Ollama extractor model; the remaining
|
|
622
|
+
# five are the hard traversal caps + theme-ranking knob.
|
|
623
|
+
graph_concepts: bool = DEFAULT_GRAPH_CONCEPTS
|
|
624
|
+
graph_extract_model: str = DEFAULT_GRAPH_EXTRACT_MODEL
|
|
625
|
+
# Concept-extractor input head cap (perf Fix C). ``None`` == no cap (whole
|
|
626
|
+
# document extracted); a positive int caps the body to its first N
|
|
627
|
+
# ``cl100k_base`` tokens before chunking. Threaded into
|
|
628
|
+
# :func:`brain.graph_rag.extract.make_extractor`.
|
|
629
|
+
graph_extract_max_input_tokens: int | None = DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS
|
|
630
|
+
graph_depth: int = DEFAULT_GRAPH_DEPTH
|
|
631
|
+
graph_frontier_cap: int = DEFAULT_GRAPH_FRONTIER_CAP
|
|
632
|
+
graph_max_degree: int = DEFAULT_GRAPH_MAX_DEGREE
|
|
633
|
+
graph_min_edge_weight: float = DEFAULT_GRAPH_MIN_EDGE_WEIGHT
|
|
634
|
+
graph_theme_limit: int = DEFAULT_GRAPH_THEME_LIMIT
|
|
635
|
+
# Wave G3 -- global community detection (spec §17c). Parsed in G3-a;
|
|
636
|
+
# consumed by detection (G3-b), summaries (G3-c), and global retrieval
|
|
637
|
+
# (G3-d). ``graph_community_max`` is an ops safety cap (None == unlimited).
|
|
638
|
+
graph_community_resolution: float = DEFAULT_GRAPH_COMMUNITY_RESOLUTION
|
|
639
|
+
graph_community_seed: int = DEFAULT_GRAPH_COMMUNITY_SEED
|
|
640
|
+
graph_community_min_size: int = DEFAULT_GRAPH_COMMUNITY_MIN_SIZE
|
|
641
|
+
graph_community_jaccard: float = DEFAULT_GRAPH_COMMUNITY_JACCARD
|
|
642
|
+
graph_community_limit: int = DEFAULT_GRAPH_COMMUNITY_LIMIT
|
|
643
|
+
graph_community_max: int | None = DEFAULT_GRAPH_COMMUNITY_MAX
|
|
644
|
+
# Phase 1 data-quality remediation (2026-05-23). Extra automated-sender
|
|
645
|
+
# denylist entries (substrings or full addresses) layered on top of the
|
|
646
|
+
# always-on generic heuristic (no-reply / notifications / mailer / …) used by
|
|
647
|
+
# :func:`brain.wiki._person_name.is_automated_sender`. Loaded from
|
|
648
|
+
# ``BRAIN_GRAPH_SENDER_DENYLIST`` (comma-separated); entries are trimmed,
|
|
649
|
+
# lowercased, and de-duplicated at load time. Empty frozenset (default) means
|
|
650
|
+
# only the generic heuristic runs. Threaded into both the People Hub
|
|
651
|
+
# (``emit_people_pages``) and the graph reconcile (``ReconcileConfig``) so a
|
|
652
|
+
# no-reply / org sender becomes a person in neither.
|
|
653
|
+
graph_sender_denylist: frozenset[str] = frozenset()
|
|
654
|
+
# Phase B (2026-05-25) — operator-curated concept extraction stopwords.
|
|
655
|
+
# Entities whose ``canonical_key`` appears in this set are dropped by the
|
|
656
|
+
# extractor's ``_finalize`` chokepoint (after the generic noise filter) even
|
|
657
|
+
# when they pass presence validation. Real terms are employer-specific (rule
|
|
658
|
+
# 15) so the default is **empty** — operators set
|
|
659
|
+
# ``BRAIN_GRAPH_EXTRACT_STOPWORDS`` locally. Parsed as a comma-separated list;
|
|
660
|
+
# entries are trimmed, lowercased, and de-duplicated at load time. Also folded
|
|
661
|
+
# into :func:`brain.graph_rag.concepts.concept_inputs_hash` so a stopword
|
|
662
|
+
# change re-extracts (beyond the one-time ``EXTRACTOR_VERSION`` bump).
|
|
663
|
+
graph_extract_stopwords: frozenset[str] = frozenset()
|
|
664
|
+
# Phase C (2026-05-25) — curated entity alias/merge rules. Real rules contain
|
|
665
|
+
# real entity names (rule 15) so they live in a gitignored local file. This
|
|
666
|
+
# path is resolved from ``BRAIN_GRAPH_ALIASES_PATH``; if unset it defaults to
|
|
667
|
+
# ``$BRAIN_HOME/graph_aliases.yml`` when that file exists, else ``None``
|
|
668
|
+
# (feature is opt-in — absent file → no aliases applied). Eager-validated:
|
|
669
|
+
# when a path IS resolved it must be readable (raises ``ConfigError`` if not).
|
|
670
|
+
graph_aliases_path: Path | None = None
|
|
671
|
+
# Tacit-knowledge elicitation knobs (feat/tacit-knowledge-elicitation).
|
|
672
|
+
# Minimum evidence docs required for a gap to surface. See
|
|
673
|
+
# :data:`DEFAULT_ELICIT_MIN_EVIDENCE_DOCS`. Non-negative integer.
|
|
674
|
+
elicit_min_evidence_docs: int = DEFAULT_ELICIT_MIN_EVIDENCE_DOCS
|
|
675
|
+
# Minimum gap score in [0.0, 1.0] for a gap to enter the queue. See
|
|
676
|
+
# :data:`DEFAULT_ELICIT_MIN_GAP_SCORE`. Out-of-range values are rejected
|
|
677
|
+
# at load time via :class:`ConfigError`.
|
|
678
|
+
elicit_min_gap_score: float = DEFAULT_ELICIT_MIN_GAP_SCORE
|
|
679
|
+
# Maximum rows returned by ``brain elicit list``. Non-negative integer.
|
|
680
|
+
elicit_queue_limit: int = DEFAULT_ELICIT_QUEUE_LIMIT
|
|
681
|
+
# Gates the contradiction detector. Bool, default off.
|
|
682
|
+
elicit_contradiction_enabled: bool = DEFAULT_ELICIT_CONTRADICTION_ENABLED
|
|
683
|
+
# Minimum docs per target before contradiction detection runs. Non-negative int.
|
|
684
|
+
elicit_contradiction_min_docs: int = DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS
|
|
685
|
+
# Plan 05 -- `brain timeline` knobs. ``timeline_granularity`` is the default
|
|
686
|
+
# bucket width (validated ∈ {auto, month, quarter, year}); ``timeline_limit`` the
|
|
687
|
+
# default max buckets (positive int); ``timeline_synth_limit`` the max
|
|
688
|
+
# densest buckets synthesized (non-negative int, 0 disables);
|
|
689
|
+
# ``timeline_trim`` which end is trimmed at the limit (∈ {oldest, sparsest}).
|
|
690
|
+
timeline_granularity: str = DEFAULT_TIMELINE_GRANULARITY
|
|
691
|
+
timeline_limit: int = DEFAULT_TIMELINE_LIMIT
|
|
692
|
+
timeline_synth_limit: int = DEFAULT_TIMELINE_SYNTH_LIMIT
|
|
693
|
+
timeline_trim: str = DEFAULT_TIMELINE_TRIM
|
|
694
|
+
# Plan 07 -- `brain connect` auto-link suggestion knobs. ``connect_min_score``
|
|
695
|
+
# is the RRF-blend confidence floor in (0.0, 1.0]; ``connect_candidate_limit``
|
|
696
|
+
# and ``connect_max_per_doc`` are positive-integer caps. All three are
|
|
697
|
+
# eager-validated at load time, mirroring the other env knobs.
|
|
698
|
+
connect_min_score: float = DEFAULT_CONNECT_MIN_SCORE
|
|
699
|
+
connect_candidate_limit: int = DEFAULT_CONNECT_CANDIDATE_LIMIT
|
|
700
|
+
connect_max_per_doc: int = DEFAULT_CONNECT_MAX_PER_DOC
|
|
701
|
+
# Plan 03 -- `brain review scan` contradiction + staleness knobs. The two
|
|
702
|
+
# ``*_sim_floor`` values are cosine floors in [0.0, 1.0]; the rest are
|
|
703
|
+
# positive ints. All eager-validated at load time via ``ConfigError``.
|
|
704
|
+
review_conflict_limit: int = DEFAULT_REVIEW_CONFLICT_LIMIT
|
|
705
|
+
review_conflict_pairs_per_entity: int = DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY
|
|
706
|
+
review_embed_sim_floor: float = DEFAULT_REVIEW_EMBED_SIM_FLOOR
|
|
707
|
+
review_stale_age_days: int = DEFAULT_REVIEW_STALE_AGE_DAYS
|
|
708
|
+
review_stale_supersede_window_days: int = DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS
|
|
709
|
+
review_stale_sim_floor: float = DEFAULT_REVIEW_STALE_SIM_FLOOR
|
|
710
|
+
review_stale_limit: int = DEFAULT_REVIEW_STALE_LIMIT
|
|
711
|
+
# Plan 06 -- `brain ask` agentic-synthesis knobs. ``ask_max_iterations`` and
|
|
712
|
+
# ``ask_docs_per_iter`` are positive ints (>= 1); ``ask_timeout_seconds`` is
|
|
713
|
+
# a positive float; ``ask_model`` defaults to ``enrich_model`` (blank env
|
|
714
|
+
# resets to that default), so the ask loop reuses the configured chat model
|
|
715
|
+
# unless explicitly overridden via ``BRAIN_ASK_MODEL``.
|
|
716
|
+
ask_max_iterations: int = DEFAULT_ASK_MAX_ITERATIONS
|
|
717
|
+
ask_docs_per_iter: int = DEFAULT_ASK_DOCS_PER_ITER
|
|
718
|
+
ask_model: str = DEFAULT_ENRICH_MODEL
|
|
719
|
+
ask_timeout_seconds: float = DEFAULT_ASK_TIMEOUT_SECONDS
|
|
720
|
+
# Plan 04 -- `brain audio` knobs. ``audio_script_model`` selects the Ollama
|
|
721
|
+
# dialogue model; ``audio_max_turns`` caps the dialogue (positive even int);
|
|
722
|
+
# ``audio_max_input_tokens`` bounds the bundle prompt (positive int);
|
|
723
|
+
# ``audio_theme_limit`` caps the theme/community groups pulled into the
|
|
724
|
+
# source bundle (positive int).
|
|
725
|
+
audio_script_model: str = DEFAULT_AUDIO_SCRIPT_MODEL
|
|
726
|
+
audio_max_turns: int = DEFAULT_AUDIO_MAX_TURNS
|
|
727
|
+
audio_max_input_tokens: int = DEFAULT_AUDIO_MAX_INPUT_TOKENS
|
|
728
|
+
audio_theme_limit: int = DEFAULT_AUDIO_THEME_LIMIT
|
|
729
|
+
# Plan 08 -- `brain gaps` search-failure knobs. Both positive ints (>= 1),
|
|
730
|
+
# eager-validated at load time via ``ConfigError`` like the review knobs.
|
|
731
|
+
gaps_lookback_days: int = DEFAULT_GAPS_LOOKBACK_DAYS
|
|
732
|
+
gaps_min_cluster_size: int = DEFAULT_GAPS_MIN_CLUSTER_SIZE
|
|
733
|
+
|
|
734
|
+
@classmethod
|
|
735
|
+
def load(cls) -> "Config":
|
|
736
|
+
"""Load config from env / .env files. Raises ConfigError if DATABASE_URL is unset."""
|
|
737
|
+
fields = cls._load_field_dict(require_db=True)
|
|
738
|
+
return cls(**fields)
|
|
739
|
+
|
|
740
|
+
@classmethod
|
|
741
|
+
def load_minimal(cls) -> "Config":
|
|
742
|
+
"""Same as load() but doesn't require DATABASE_URL.
|
|
743
|
+
|
|
744
|
+
Used by purely-filesystem commands (brain vault render --overlay,
|
|
745
|
+
brain wiki install, brain claude install-skill) that run BEFORE
|
|
746
|
+
brain setup writes .env. The brain_home field still resolves, but
|
|
747
|
+
database_url defaults to an empty string sentinel and any caller
|
|
748
|
+
that tries to actually USE database_url on this config will fail
|
|
749
|
+
at the first DB connection attempt -- which is the right level to
|
|
750
|
+
fail.
|
|
751
|
+
"""
|
|
752
|
+
fields = cls._load_field_dict(require_db=False)
|
|
753
|
+
return cls(**fields)
|
|
754
|
+
|
|
755
|
+
@classmethod
|
|
756
|
+
def _load_field_dict(cls, *, require_db: bool) -> dict[str, Any]:
|
|
757
|
+
"""Shared parser. Loads the dotenv chain, then parses every field.
|
|
758
|
+
|
|
759
|
+
If require_db=True, raises ConfigError on missing DATABASE_URL;
|
|
760
|
+
if False, sets it to "" (empty string sentinel). This is the single
|
|
761
|
+
source of truth for all field parsing -- load() and load_minimal()
|
|
762
|
+
both delegate here so overlapping fields can never drift.
|
|
763
|
+
"""
|
|
764
|
+
# Load .env files using a merged-dict + setdefault algorithm so that:
|
|
765
|
+
# 1. os.environ (process env) is NEVER overwritten -- highest priority.
|
|
766
|
+
# 2. <repo-root>/.env wins over cwd and BRAIN_HOME .env files.
|
|
767
|
+
# 3. <cwd>/.env (via walk-up) wins over BRAIN_HOME .env.
|
|
768
|
+
# 4. $BRAIN_HOME/.env is the lowest-priority file source.
|
|
769
|
+
#
|
|
770
|
+
# Files are layered in REVERSE priority order (lowest first) into a
|
|
771
|
+
# merged dict; higher-priority files overwrite lower-priority ones on
|
|
772
|
+
# key collisions. Process env is applied last via os.environ.setdefault
|
|
773
|
+
# so an existing value is never clobbered -- preserving the precedence
|
|
774
|
+
# contract regardless of who set it (shell, parent process, or
|
|
775
|
+
# monkeypatch.setenv).
|
|
776
|
+
merged: dict[str, str] = {}
|
|
777
|
+
cwd_env_str = find_dotenv(usecwd=True)
|
|
778
|
+
for candidate in (
|
|
779
|
+
_brain_home_dotenv(),
|
|
780
|
+
Path(cwd_env_str) if cwd_env_str else None,
|
|
781
|
+
_project_dotenv(),
|
|
782
|
+
):
|
|
783
|
+
if candidate is not None and candidate.exists():
|
|
784
|
+
merged.update(
|
|
785
|
+
{k: v for k, v in dotenv_values(candidate).items() if v is not None}
|
|
786
|
+
)
|
|
787
|
+
for key, value in merged.items():
|
|
788
|
+
os.environ.setdefault(key, value)
|
|
789
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
790
|
+
if require_db and not database_url:
|
|
791
|
+
raise ConfigError("DATABASE_URL is not set (see .env.example)")
|
|
792
|
+
database_url = database_url or "" # empty sentinel when require_db=False
|
|
793
|
+
ollama_host = os.environ.get("OLLAMA_HOST", DEFAULT_OLLAMA_HOST)
|
|
794
|
+
qwen3_model = os.environ.get("QWEN3_MODEL", DEFAULT_QWEN3_MODEL)
|
|
795
|
+
embedder = os.environ.get("BRAIN_EMBEDDER", DEFAULT_EMBEDDER).lower()
|
|
796
|
+
if embedder not in _VALID_EMBEDDERS:
|
|
797
|
+
raise ConfigError(
|
|
798
|
+
f"BRAIN_EMBEDDER must be one of: arctic, voyage, qwen3, none "
|
|
799
|
+
f"(got {embedder!r})"
|
|
800
|
+
)
|
|
801
|
+
voyage_api_key = os.environ.get("VOYAGE_API_KEY")
|
|
802
|
+
vault_path = _default_vault_path()
|
|
803
|
+
# P4.4 -- owner identity for the email-thread "Show only my replies"
|
|
804
|
+
# filter. Optional; an unset/empty value renders the button but
|
|
805
|
+
# the runtime filter no-ops (no message ever matches the empty
|
|
806
|
+
# user identity, so toggling the button hides every section --
|
|
807
|
+
# which is the right "I forgot to set this" feedback signal).
|
|
808
|
+
# ``.strip()`` so trailing newlines from a `.env` quirk don't
|
|
809
|
+
# bleed into the JS global.
|
|
810
|
+
user_email_raw = os.environ.get("BRAIN_USER_EMAIL")
|
|
811
|
+
user_email = (user_email_raw or "").strip() or None
|
|
812
|
+
# Vector cosine floor -- see DEFAULT_VECTOR_SIM_FLOOR. Validation:
|
|
813
|
+
# must parse as float in [0.0, 1.0] (cosine similarity range).
|
|
814
|
+
# Negative values would silently re-admit the noise tail; >1
|
|
815
|
+
# would exclude every chunk. Either is a config bug -- surface it
|
|
816
|
+
# eagerly with ``ConfigError`` (per plan revision #6).
|
|
817
|
+
floor_raw = os.environ.get("BRAIN_VECTOR_SIM_FLOOR")
|
|
818
|
+
if floor_raw is None or floor_raw.strip() == "":
|
|
819
|
+
vector_sim_floor = DEFAULT_VECTOR_SIM_FLOOR
|
|
820
|
+
else:
|
|
821
|
+
try:
|
|
822
|
+
vector_sim_floor = float(floor_raw)
|
|
823
|
+
except ValueError as exc:
|
|
824
|
+
raise ConfigError(
|
|
825
|
+
f"BRAIN_VECTOR_SIM_FLOOR must be a float in [0.0, 1.0] "
|
|
826
|
+
f"(got {floor_raw!r})"
|
|
827
|
+
) from exc
|
|
828
|
+
if not (0.0 <= vector_sim_floor <= 1.0):
|
|
829
|
+
raise ConfigError(
|
|
830
|
+
f"BRAIN_VECTOR_SIM_FLOOR must be a float in [0.0, 1.0] "
|
|
831
|
+
f"(got {vector_sim_floor!r})"
|
|
832
|
+
)
|
|
833
|
+
# Owner participants -- identifiers (emails and/or display names)
|
|
834
|
+
# whose presence in a doc's participant set is treated as
|
|
835
|
+
# ``corpus owner``. Comma-separated; trim + lowercase + drop empty
|
|
836
|
+
# entries at load time so the downstream filter is a fast
|
|
837
|
+
# ``key.lower() in owner_participants`` check. Unset / blank /
|
|
838
|
+
# whitespace-only strings produce an empty frozenset (no
|
|
839
|
+
# behavioral change).
|
|
840
|
+
owner_raw = os.environ.get("BRAIN_OWNER_PARTICIPANTS", "")
|
|
841
|
+
owner_participants = frozenset(
|
|
842
|
+
piece
|
|
843
|
+
for piece in (entry.strip().lower() for entry in owner_raw.split(","))
|
|
844
|
+
if piece
|
|
845
|
+
)
|
|
846
|
+
# People Hub doc-count threshold -- see DEFAULT_PEOPLE_HUB_MIN_DOCS.
|
|
847
|
+
# Validation: must parse as a non-negative integer. A negative
|
|
848
|
+
# threshold is almost certainly a config typo (it would render every
|
|
849
|
+
# person, since ``len(docs) < negative`` is never true) -- surface
|
|
850
|
+
# eagerly via ConfigError so the user fixes the typo rather than
|
|
851
|
+
# silently getting a hub flooded with one-off recipients.
|
|
852
|
+
people_min_raw = os.environ.get("BRAIN_PEOPLE_HUB_MIN_DOCS")
|
|
853
|
+
if people_min_raw is None or people_min_raw.strip() == "":
|
|
854
|
+
people_hub_min_docs = DEFAULT_PEOPLE_HUB_MIN_DOCS
|
|
855
|
+
else:
|
|
856
|
+
try:
|
|
857
|
+
people_hub_min_docs = int(people_min_raw)
|
|
858
|
+
except ValueError as exc:
|
|
859
|
+
raise ConfigError(
|
|
860
|
+
f"BRAIN_PEOPLE_HUB_MIN_DOCS must be a non-negative integer "
|
|
861
|
+
f"(got {people_min_raw!r})"
|
|
862
|
+
) from exc
|
|
863
|
+
if people_hub_min_docs < 0:
|
|
864
|
+
raise ConfigError(
|
|
865
|
+
f"BRAIN_PEOPLE_HUB_MIN_DOCS must be a non-negative integer "
|
|
866
|
+
f"(got {people_hub_min_docs!r})"
|
|
867
|
+
)
|
|
868
|
+
# Plan 09 -- quick-capture inbox knobs. Same eager-validation idiom as
|
|
869
|
+
# the other int env vars: unset -> default; non-parseable or
|
|
870
|
+
# out-of-range -> ConfigError at startup instead of a mid-capture crash.
|
|
871
|
+
capture_words_raw = os.environ.get("BRAIN_CAPTURE_TITLE_WORDS")
|
|
872
|
+
if capture_words_raw is None or capture_words_raw.strip() == "":
|
|
873
|
+
capture_title_words = DEFAULT_CAPTURE_TITLE_WORDS
|
|
874
|
+
else:
|
|
875
|
+
try:
|
|
876
|
+
capture_title_words = int(capture_words_raw)
|
|
877
|
+
except ValueError as exc:
|
|
878
|
+
raise ConfigError(
|
|
879
|
+
f"BRAIN_CAPTURE_TITLE_WORDS must be an integer >= 1 "
|
|
880
|
+
f"(got {capture_words_raw!r})"
|
|
881
|
+
) from exc
|
|
882
|
+
if capture_title_words < 1:
|
|
883
|
+
raise ConfigError(
|
|
884
|
+
f"BRAIN_CAPTURE_TITLE_WORDS must be an integer >= 1 "
|
|
885
|
+
f"(got {capture_title_words!r})"
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
capture_warn_raw = os.environ.get("BRAIN_CAPTURE_INBOX_WARN_THRESHOLD")
|
|
889
|
+
if capture_warn_raw is None or capture_warn_raw.strip() == "":
|
|
890
|
+
capture_inbox_warn_threshold = DEFAULT_CAPTURE_INBOX_WARN_THRESHOLD
|
|
891
|
+
else:
|
|
892
|
+
try:
|
|
893
|
+
capture_inbox_warn_threshold = int(capture_warn_raw)
|
|
894
|
+
except ValueError as exc:
|
|
895
|
+
raise ConfigError(
|
|
896
|
+
f"BRAIN_CAPTURE_INBOX_WARN_THRESHOLD must be an integer >= 1 "
|
|
897
|
+
f"(got {capture_warn_raw!r})"
|
|
898
|
+
) from exc
|
|
899
|
+
if capture_inbox_warn_threshold < 1:
|
|
900
|
+
raise ConfigError(
|
|
901
|
+
f"BRAIN_CAPTURE_INBOX_WARN_THRESHOLD must be an integer >= 1 "
|
|
902
|
+
f"(got {capture_inbox_warn_threshold!r})"
|
|
903
|
+
)
|
|
904
|
+
# Plan 10 -- `brain review weekly` display caps. Positive ints.
|
|
905
|
+
review_activity_limit = _parse_positive_int_env(
|
|
906
|
+
"BRAIN_REVIEW_ACTIVITY_LIMIT", DEFAULT_REVIEW_ACTIVITY_LIMIT
|
|
907
|
+
)
|
|
908
|
+
review_theme_limit = _parse_positive_int_env(
|
|
909
|
+
"BRAIN_REVIEW_THEME_LIMIT", DEFAULT_REVIEW_THEME_LIMIT
|
|
910
|
+
)
|
|
911
|
+
review_open_loop_limit = _parse_positive_int_env(
|
|
912
|
+
"BRAIN_REVIEW_OPEN_LOOP_LIMIT", DEFAULT_REVIEW_OPEN_LOOP_LIMIT
|
|
913
|
+
)
|
|
914
|
+
# Plan 01 -- `brain brief` daily-digest knobs. Positive ints.
|
|
915
|
+
brief_since_hours = _parse_positive_int_env(
|
|
916
|
+
"BRAIN_BRIEF_SINCE_HOURS", DEFAULT_BRIEF_SINCE_HOURS
|
|
917
|
+
)
|
|
918
|
+
brief_todo_since_days = _parse_positive_int_env(
|
|
919
|
+
"BRAIN_BRIEF_TODO_SINCE_DAYS", DEFAULT_BRIEF_TODO_SINCE_DAYS
|
|
920
|
+
)
|
|
921
|
+
brief_capture_limit = _parse_positive_int_env(
|
|
922
|
+
"BRAIN_BRIEF_CAPTURE_LIMIT", DEFAULT_BRIEF_CAPTURE_LIMIT
|
|
923
|
+
)
|
|
924
|
+
brief_pin_limit = _parse_positive_int_env(
|
|
925
|
+
"BRAIN_BRIEF_PIN_LIMIT", DEFAULT_BRIEF_PIN_LIMIT
|
|
926
|
+
)
|
|
927
|
+
# Recency half-life -- see DEFAULT_RECENCY_HALFLIFE_DAYS.
|
|
928
|
+
# Validation: must parse as a positive float. Zero is invalid
|
|
929
|
+
# (produces 0 ** inf = 0 for any finite age, degenerate). Negative
|
|
930
|
+
# is invalid -- flips the decay direction so old docs score higher.
|
|
931
|
+
halflife_raw = os.environ.get("BRAIN_RECENCY_HALFLIFE_DAYS")
|
|
932
|
+
if halflife_raw is None or halflife_raw.strip() == "":
|
|
933
|
+
recency_halflife_days = DEFAULT_RECENCY_HALFLIFE_DAYS
|
|
934
|
+
else:
|
|
935
|
+
try:
|
|
936
|
+
recency_halflife_days = float(halflife_raw)
|
|
937
|
+
except ValueError as exc:
|
|
938
|
+
raise ConfigError(
|
|
939
|
+
f"BRAIN_RECENCY_HALFLIFE_DAYS must be a positive float "
|
|
940
|
+
f"(got {halflife_raw!r})"
|
|
941
|
+
) from exc
|
|
942
|
+
if recency_halflife_days <= 0:
|
|
943
|
+
raise ConfigError(
|
|
944
|
+
f"BRAIN_RECENCY_HALFLIFE_DAYS must be a positive float "
|
|
945
|
+
f"(got {recency_halflife_days!r})"
|
|
946
|
+
)
|
|
947
|
+
# Snippet context tokens -- see DEFAULT_SNIPPET_CONTEXT_TOKENS.
|
|
948
|
+
# Validation: must parse as a non-negative integer. 0 = disabled.
|
|
949
|
+
# Negative is invalid -- there is no sensible semantic for a negative
|
|
950
|
+
# token budget.
|
|
951
|
+
ctx_raw = os.environ.get("BRAIN_SNIPPET_CONTEXT_TOKENS")
|
|
952
|
+
if ctx_raw is None or ctx_raw.strip() == "":
|
|
953
|
+
snippet_context_tokens = DEFAULT_SNIPPET_CONTEXT_TOKENS
|
|
954
|
+
else:
|
|
955
|
+
try:
|
|
956
|
+
snippet_context_tokens = int(ctx_raw)
|
|
957
|
+
except ValueError as exc:
|
|
958
|
+
raise ConfigError(
|
|
959
|
+
f"BRAIN_SNIPPET_CONTEXT_TOKENS must be a non-negative integer "
|
|
960
|
+
f"(got {ctx_raw!r})"
|
|
961
|
+
) from exc
|
|
962
|
+
if snippet_context_tokens < 0:
|
|
963
|
+
raise ConfigError(
|
|
964
|
+
f"BRAIN_SNIPPET_CONTEXT_TOKENS must be a non-negative integer "
|
|
965
|
+
f"(got {snippet_context_tokens!r})"
|
|
966
|
+
)
|
|
967
|
+
# Plan 02 -- resurface env vars. Same eager-validation idiom as the
|
|
968
|
+
# snippet-context / recency-halflife knobs above: unset/blank ->
|
|
969
|
+
# default; non-parseable / out-of-range -> ConfigError at startup so a
|
|
970
|
+
# config typo surfaces immediately rather than mid-resurface.
|
|
971
|
+
resurface_limit_raw = os.environ.get("BRAIN_RESURFACE_LIMIT")
|
|
972
|
+
if resurface_limit_raw is None or resurface_limit_raw.strip() == "":
|
|
973
|
+
resurface_limit = DEFAULT_RESURFACE_LIMIT
|
|
974
|
+
else:
|
|
975
|
+
try:
|
|
976
|
+
resurface_limit = int(resurface_limit_raw)
|
|
977
|
+
except ValueError as exc:
|
|
978
|
+
raise ConfigError(
|
|
979
|
+
f"BRAIN_RESURFACE_LIMIT must be an integer >= 1 "
|
|
980
|
+
f"(got {resurface_limit_raw!r})"
|
|
981
|
+
) from exc
|
|
982
|
+
if resurface_limit < 1:
|
|
983
|
+
raise ConfigError(
|
|
984
|
+
f"BRAIN_RESURFACE_LIMIT must be an integer >= 1 "
|
|
985
|
+
f"(got {resurface_limit!r})"
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
resurface_min_age_raw = os.environ.get("BRAIN_RESURFACE_MIN_AGE_DAYS")
|
|
989
|
+
if resurface_min_age_raw is None or resurface_min_age_raw.strip() == "":
|
|
990
|
+
resurface_min_age_days = DEFAULT_RESURFACE_MIN_AGE_DAYS
|
|
991
|
+
else:
|
|
992
|
+
try:
|
|
993
|
+
resurface_min_age_days = int(resurface_min_age_raw)
|
|
994
|
+
except ValueError as exc:
|
|
995
|
+
raise ConfigError(
|
|
996
|
+
f"BRAIN_RESURFACE_MIN_AGE_DAYS must be a non-negative integer "
|
|
997
|
+
f"(got {resurface_min_age_raw!r})"
|
|
998
|
+
) from exc
|
|
999
|
+
if resurface_min_age_days < 0:
|
|
1000
|
+
raise ConfigError(
|
|
1001
|
+
f"BRAIN_RESURFACE_MIN_AGE_DAYS must be a non-negative integer "
|
|
1002
|
+
f"(got {resurface_min_age_days!r})"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
resurface_age_hl_raw = os.environ.get("BRAIN_RESURFACE_AGE_HALFLIFE_DAYS")
|
|
1006
|
+
if resurface_age_hl_raw is None or resurface_age_hl_raw.strip() == "":
|
|
1007
|
+
resurface_age_halflife_days = DEFAULT_RESURFACE_AGE_HALFLIFE_DAYS
|
|
1008
|
+
else:
|
|
1009
|
+
try:
|
|
1010
|
+
resurface_age_halflife_days = float(resurface_age_hl_raw)
|
|
1011
|
+
except ValueError as exc:
|
|
1012
|
+
raise ConfigError(
|
|
1013
|
+
f"BRAIN_RESURFACE_AGE_HALFLIFE_DAYS must be a positive float "
|
|
1014
|
+
f"(got {resurface_age_hl_raw!r})"
|
|
1015
|
+
) from exc
|
|
1016
|
+
if resurface_age_halflife_days <= 0:
|
|
1017
|
+
raise ConfigError(
|
|
1018
|
+
f"BRAIN_RESURFACE_AGE_HALFLIFE_DAYS must be a positive float "
|
|
1019
|
+
f"(got {resurface_age_halflife_days!r})"
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
resurface_access_hl_raw = os.environ.get(
|
|
1023
|
+
"BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS"
|
|
1024
|
+
)
|
|
1025
|
+
if resurface_access_hl_raw is None or resurface_access_hl_raw.strip() == "":
|
|
1026
|
+
resurface_access_halflife_days = DEFAULT_RESURFACE_ACCESS_HALFLIFE_DAYS
|
|
1027
|
+
else:
|
|
1028
|
+
try:
|
|
1029
|
+
resurface_access_halflife_days = float(resurface_access_hl_raw)
|
|
1030
|
+
except ValueError as exc:
|
|
1031
|
+
raise ConfigError(
|
|
1032
|
+
f"BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS must be a positive float "
|
|
1033
|
+
f"(got {resurface_access_hl_raw!r})"
|
|
1034
|
+
) from exc
|
|
1035
|
+
if resurface_access_halflife_days <= 0:
|
|
1036
|
+
raise ConfigError(
|
|
1037
|
+
f"BRAIN_RESURFACE_ACCESS_HALFLIFE_DAYS must be a positive float "
|
|
1038
|
+
f"(got {resurface_access_halflife_days!r})"
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# Wave Q1-D -- enrichment env vars. Same validation pattern as the
|
|
1042
|
+
# snippet-context / recency-halflife knobs above: unset/blank ->
|
|
1043
|
+
# default; non-parseable / out-of-range -> ConfigError eagerly so a
|
|
1044
|
+
# config typo surfaces at startup instead of mid-ingest.
|
|
1045
|
+
enrich_model_raw = os.environ.get("BRAIN_ENRICH_MODEL")
|
|
1046
|
+
if enrich_model_raw is None or enrich_model_raw.strip() == "":
|
|
1047
|
+
enrich_model = DEFAULT_ENRICH_MODEL
|
|
1048
|
+
else:
|
|
1049
|
+
enrich_model = enrich_model_raw.strip()
|
|
1050
|
+
|
|
1051
|
+
enrich_min_raw = os.environ.get("BRAIN_ENRICH_MIN_TOKENS")
|
|
1052
|
+
if enrich_min_raw is None or enrich_min_raw.strip() == "":
|
|
1053
|
+
enrich_min_tokens = DEFAULT_ENRICH_MIN_TOKENS
|
|
1054
|
+
else:
|
|
1055
|
+
try:
|
|
1056
|
+
enrich_min_tokens = int(enrich_min_raw)
|
|
1057
|
+
except ValueError as exc:
|
|
1058
|
+
raise ConfigError(
|
|
1059
|
+
f"BRAIN_ENRICH_MIN_TOKENS must be a non-negative integer "
|
|
1060
|
+
f"(got {enrich_min_raw!r})"
|
|
1061
|
+
) from exc
|
|
1062
|
+
if enrich_min_tokens < 0:
|
|
1063
|
+
raise ConfigError(
|
|
1064
|
+
f"BRAIN_ENRICH_MIN_TOKENS must be a non-negative integer "
|
|
1065
|
+
f"(got {enrich_min_raw!r})"
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
enrich_max_raw = os.environ.get("BRAIN_ENRICH_MAX_INPUT_TOKENS")
|
|
1069
|
+
if enrich_max_raw is None or enrich_max_raw.strip() == "":
|
|
1070
|
+
enrich_max_input_tokens = DEFAULT_ENRICH_MAX_INPUT_TOKENS
|
|
1071
|
+
else:
|
|
1072
|
+
try:
|
|
1073
|
+
enrich_max_input_tokens = int(enrich_max_raw)
|
|
1074
|
+
except ValueError as exc:
|
|
1075
|
+
raise ConfigError(
|
|
1076
|
+
f"BRAIN_ENRICH_MAX_INPUT_TOKENS must be a positive integer "
|
|
1077
|
+
f"(got {enrich_max_raw!r})"
|
|
1078
|
+
) from exc
|
|
1079
|
+
if enrich_max_input_tokens <= 0:
|
|
1080
|
+
raise ConfigError(
|
|
1081
|
+
f"BRAIN_ENRICH_MAX_INPUT_TOKENS must be a positive integer "
|
|
1082
|
+
f"(got {enrich_max_input_tokens!r})"
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
enrich_timeout_raw = os.environ.get("BRAIN_ENRICH_TIMEOUT_SECONDS")
|
|
1086
|
+
if enrich_timeout_raw is None or enrich_timeout_raw.strip() == "":
|
|
1087
|
+
enrich_timeout_seconds = DEFAULT_ENRICH_TIMEOUT_SECONDS
|
|
1088
|
+
else:
|
|
1089
|
+
try:
|
|
1090
|
+
enrich_timeout_seconds = float(enrich_timeout_raw)
|
|
1091
|
+
except ValueError as exc:
|
|
1092
|
+
raise ConfigError(
|
|
1093
|
+
f"BRAIN_ENRICH_TIMEOUT_SECONDS must be a positive float "
|
|
1094
|
+
f"(got {enrich_timeout_raw!r})"
|
|
1095
|
+
) from exc
|
|
1096
|
+
if enrich_timeout_seconds <= 0:
|
|
1097
|
+
raise ConfigError(
|
|
1098
|
+
f"BRAIN_ENRICH_TIMEOUT_SECONDS must be a positive float "
|
|
1099
|
+
f"(got {enrich_timeout_raw!r})"
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
# Ollama keep_alive -- see DEFAULT_OLLAMA_KEEP_ALIVE. Accepts a positive
|
|
1103
|
+
# integer optionally followed by m/h/s (e.g. "30m", "1h", "60s", "60")
|
|
1104
|
+
# plus the two Ollama sentinels "-1" (keep loaded indefinitely) and "0"
|
|
1105
|
+
# (unload immediately). Empty / whitespace-only falls back to the
|
|
1106
|
+
# default; every other malformed value ("-2", "-1m", "abc", …) is
|
|
1107
|
+
# rejected via ConfigError so a config typo surfaces at startup, not
|
|
1108
|
+
# during an embed/chat call.
|
|
1109
|
+
keep_alive_raw = os.environ.get("BRAIN_OLLAMA_KEEP_ALIVE")
|
|
1110
|
+
if keep_alive_raw is None or keep_alive_raw.strip() == "":
|
|
1111
|
+
ollama_keep_alive = DEFAULT_OLLAMA_KEEP_ALIVE
|
|
1112
|
+
else:
|
|
1113
|
+
stripped_ka = keep_alive_raw.strip()
|
|
1114
|
+
if not _KEEP_ALIVE_RE.match(stripped_ka):
|
|
1115
|
+
raise ConfigError(
|
|
1116
|
+
"BRAIN_OLLAMA_KEEP_ALIVE must be '-1' (keep loaded), '0' "
|
|
1117
|
+
"(unload immediately), or a positive integer / duration "
|
|
1118
|
+
"string like '30m', '1h', '60s', '60' "
|
|
1119
|
+
f"(got {keep_alive_raw!r})"
|
|
1120
|
+
)
|
|
1121
|
+
ollama_keep_alive = stripped_ka
|
|
1122
|
+
|
|
1123
|
+
# Wave G1-c -- GraphRAG sync env vars. Same eager-validation pattern as
|
|
1124
|
+
# the enrich knobs above: unset/blank -> default; non-parseable /
|
|
1125
|
+
# out-of-range -> ConfigError so a typo surfaces at startup, never
|
|
1126
|
+
# mid-ingest.
|
|
1127
|
+
graph_enabled_raw = os.environ.get("BRAIN_GRAPH_ENABLED")
|
|
1128
|
+
if graph_enabled_raw is None or graph_enabled_raw.strip() == "":
|
|
1129
|
+
graph_enabled = DEFAULT_GRAPH_ENABLED
|
|
1130
|
+
else:
|
|
1131
|
+
token = graph_enabled_raw.strip().lower()
|
|
1132
|
+
if token in _GRAPH_ENABLED_TRUTHY:
|
|
1133
|
+
graph_enabled = True
|
|
1134
|
+
elif token in _GRAPH_ENABLED_FALSY:
|
|
1135
|
+
graph_enabled = False
|
|
1136
|
+
else:
|
|
1137
|
+
raise ConfigError(
|
|
1138
|
+
"BRAIN_GRAPH_ENABLED must be one of "
|
|
1139
|
+
"1/true/yes/on or 0/false/no/off "
|
|
1140
|
+
f"(got {graph_enabled_raw!r})"
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
graph_tenant_raw = os.environ.get("BRAIN_GRAPH_TENANT")
|
|
1144
|
+
if graph_tenant_raw is None or graph_tenant_raw.strip() == "":
|
|
1145
|
+
graph_tenant_id = DEFAULT_GRAPH_TENANT_ID
|
|
1146
|
+
else:
|
|
1147
|
+
graph_tenant_id = graph_tenant_raw.strip()
|
|
1148
|
+
|
|
1149
|
+
graph_window_raw = os.environ.get("BRAIN_GRAPH_COOCCUR_WINDOW")
|
|
1150
|
+
if graph_window_raw is None or graph_window_raw.strip() == "":
|
|
1151
|
+
graph_cooccur_window = DEFAULT_GRAPH_COOCCUR_WINDOW
|
|
1152
|
+
else:
|
|
1153
|
+
try:
|
|
1154
|
+
graph_cooccur_window = int(graph_window_raw)
|
|
1155
|
+
except ValueError as exc:
|
|
1156
|
+
raise ConfigError(
|
|
1157
|
+
f"BRAIN_GRAPH_COOCCUR_WINDOW must be a positive integer "
|
|
1158
|
+
f"(got {graph_window_raw!r})"
|
|
1159
|
+
) from exc
|
|
1160
|
+
if graph_cooccur_window < 1:
|
|
1161
|
+
raise ConfigError(
|
|
1162
|
+
f"BRAIN_GRAPH_COOCCUR_WINDOW must be a positive integer "
|
|
1163
|
+
f"(got {graph_window_raw!r})"
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
# ``none`` / ``unlimited`` disables the per-doc cap (maps to ``None``,
|
|
1167
|
+
# which :class:`ReconcileConfig` accepts); otherwise a positive int.
|
|
1168
|
+
graph_max_raw = os.environ.get("BRAIN_GRAPH_MAX_ENTITIES_PER_DOC")
|
|
1169
|
+
graph_max_entities: int | None
|
|
1170
|
+
if graph_max_raw is None or graph_max_raw.strip() == "":
|
|
1171
|
+
graph_max_entities = DEFAULT_GRAPH_MAX_ENTITIES
|
|
1172
|
+
elif graph_max_raw.strip().lower() in {"none", "unlimited"}:
|
|
1173
|
+
graph_max_entities = None
|
|
1174
|
+
else:
|
|
1175
|
+
try:
|
|
1176
|
+
graph_max_entities = int(graph_max_raw)
|
|
1177
|
+
except ValueError as exc:
|
|
1178
|
+
raise ConfigError(
|
|
1179
|
+
"BRAIN_GRAPH_MAX_ENTITIES_PER_DOC must be a positive integer "
|
|
1180
|
+
f"or 'none' (got {graph_max_raw!r})"
|
|
1181
|
+
) from exc
|
|
1182
|
+
if graph_max_entities < 1:
|
|
1183
|
+
raise ConfigError(
|
|
1184
|
+
"BRAIN_GRAPH_MAX_ENTITIES_PER_DOC must be a positive integer "
|
|
1185
|
+
f"or 'none' (got {graph_max_raw!r})"
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
graph_ratio_raw = os.environ.get("BRAIN_GRAPH_GENERIC_DF")
|
|
1189
|
+
if graph_ratio_raw is None or graph_ratio_raw.strip() == "":
|
|
1190
|
+
graph_generic_df_ratio = DEFAULT_GRAPH_GENERIC_DF_RATIO
|
|
1191
|
+
else:
|
|
1192
|
+
try:
|
|
1193
|
+
graph_generic_df_ratio = float(graph_ratio_raw)
|
|
1194
|
+
except ValueError as exc:
|
|
1195
|
+
raise ConfigError(
|
|
1196
|
+
"BRAIN_GRAPH_GENERIC_DF must be a float in (0.0, 1.0] "
|
|
1197
|
+
f"(got {graph_ratio_raw!r})"
|
|
1198
|
+
) from exc
|
|
1199
|
+
if not (0.0 < graph_generic_df_ratio <= 1.0):
|
|
1200
|
+
raise ConfigError(
|
|
1201
|
+
"BRAIN_GRAPH_GENERIC_DF must be a float in (0.0, 1.0] "
|
|
1202
|
+
f"(got {graph_ratio_raw!r})"
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
# Wave G2 -- concept-extraction + bounded-retrieval env vars. Same
|
|
1206
|
+
# eager-validation idiom as the G1-c graph knobs above: unset/blank ->
|
|
1207
|
+
# default; non-parseable / out-of-range -> ConfigError at startup so a
|
|
1208
|
+
# typo never surfaces mid-retrieval.
|
|
1209
|
+
graph_concepts_raw = os.environ.get("BRAIN_GRAPH_CONCEPTS")
|
|
1210
|
+
if graph_concepts_raw is None or graph_concepts_raw.strip() == "":
|
|
1211
|
+
graph_concepts = DEFAULT_GRAPH_CONCEPTS
|
|
1212
|
+
else:
|
|
1213
|
+
token = graph_concepts_raw.strip().lower()
|
|
1214
|
+
if token in _GRAPH_ENABLED_TRUTHY:
|
|
1215
|
+
graph_concepts = True
|
|
1216
|
+
elif token in _GRAPH_ENABLED_FALSY:
|
|
1217
|
+
graph_concepts = False
|
|
1218
|
+
else:
|
|
1219
|
+
raise ConfigError(
|
|
1220
|
+
"BRAIN_GRAPH_CONCEPTS must be one of "
|
|
1221
|
+
"1/true/yes/on or 0/false/no/off "
|
|
1222
|
+
f"(got {graph_concepts_raw!r})"
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
graph_extract_model_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_MODEL")
|
|
1226
|
+
if (
|
|
1227
|
+
graph_extract_model_raw is None
|
|
1228
|
+
or graph_extract_model_raw.strip() == ""
|
|
1229
|
+
):
|
|
1230
|
+
graph_extract_model = DEFAULT_GRAPH_EXTRACT_MODEL
|
|
1231
|
+
else:
|
|
1232
|
+
graph_extract_model = graph_extract_model_raw.strip()
|
|
1233
|
+
|
|
1234
|
+
# Concept-extractor input head cap (perf Fix C). ``0`` / ``none`` /
|
|
1235
|
+
# ``unlimited`` disables the cap (maps to ``None``); otherwise a positive
|
|
1236
|
+
# int. Mirrors the ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC`` sentinel idiom,
|
|
1237
|
+
# additionally accepting ``0`` as the disable token per the perf plan.
|
|
1238
|
+
gem_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS")
|
|
1239
|
+
graph_extract_max_input_tokens: int | None
|
|
1240
|
+
if gem_raw is None or gem_raw.strip() == "":
|
|
1241
|
+
graph_extract_max_input_tokens = DEFAULT_GRAPH_EXTRACT_MAX_INPUT_TOKENS
|
|
1242
|
+
elif gem_raw.strip().lower() in {"0", "none", "unlimited"}:
|
|
1243
|
+
graph_extract_max_input_tokens = None
|
|
1244
|
+
else:
|
|
1245
|
+
try:
|
|
1246
|
+
graph_extract_max_input_tokens = int(gem_raw.strip())
|
|
1247
|
+
except ValueError as exc:
|
|
1248
|
+
raise ConfigError(
|
|
1249
|
+
"BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS must be a positive integer "
|
|
1250
|
+
f"or 0/'none'/'unlimited' to disable (got {gem_raw!r})"
|
|
1251
|
+
) from exc
|
|
1252
|
+
if graph_extract_max_input_tokens < 1:
|
|
1253
|
+
raise ConfigError(
|
|
1254
|
+
"BRAIN_GRAPH_EXTRACT_MAX_INPUT_TOKENS must be a positive integer "
|
|
1255
|
+
f"or 0/'none'/'unlimited' to disable (got {gem_raw!r})"
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
graph_depth_raw = os.environ.get("BRAIN_GRAPH_DEPTH")
|
|
1259
|
+
if graph_depth_raw is None or graph_depth_raw.strip() == "":
|
|
1260
|
+
graph_depth = DEFAULT_GRAPH_DEPTH
|
|
1261
|
+
else:
|
|
1262
|
+
try:
|
|
1263
|
+
graph_depth = int(graph_depth_raw)
|
|
1264
|
+
except ValueError as exc:
|
|
1265
|
+
raise ConfigError(
|
|
1266
|
+
f"BRAIN_GRAPH_DEPTH must be a positive integer "
|
|
1267
|
+
f"(got {graph_depth_raw!r})"
|
|
1268
|
+
) from exc
|
|
1269
|
+
if graph_depth < 1:
|
|
1270
|
+
raise ConfigError(
|
|
1271
|
+
f"BRAIN_GRAPH_DEPTH must be a positive integer "
|
|
1272
|
+
f"(got {graph_depth_raw!r})"
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
graph_frontier_raw = os.environ.get("BRAIN_GRAPH_FRONTIER_CAP")
|
|
1276
|
+
if graph_frontier_raw is None or graph_frontier_raw.strip() == "":
|
|
1277
|
+
graph_frontier_cap = DEFAULT_GRAPH_FRONTIER_CAP
|
|
1278
|
+
else:
|
|
1279
|
+
try:
|
|
1280
|
+
graph_frontier_cap = int(graph_frontier_raw)
|
|
1281
|
+
except ValueError as exc:
|
|
1282
|
+
raise ConfigError(
|
|
1283
|
+
f"BRAIN_GRAPH_FRONTIER_CAP must be a positive integer "
|
|
1284
|
+
f"(got {graph_frontier_raw!r})"
|
|
1285
|
+
) from exc
|
|
1286
|
+
if graph_frontier_cap < 1:
|
|
1287
|
+
raise ConfigError(
|
|
1288
|
+
f"BRAIN_GRAPH_FRONTIER_CAP must be a positive integer "
|
|
1289
|
+
f"(got {graph_frontier_raw!r})"
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
graph_max_degree_raw = os.environ.get("BRAIN_GRAPH_MAX_DEGREE")
|
|
1293
|
+
if graph_max_degree_raw is None or graph_max_degree_raw.strip() == "":
|
|
1294
|
+
graph_max_degree = DEFAULT_GRAPH_MAX_DEGREE
|
|
1295
|
+
else:
|
|
1296
|
+
try:
|
|
1297
|
+
graph_max_degree = int(graph_max_degree_raw)
|
|
1298
|
+
except ValueError as exc:
|
|
1299
|
+
raise ConfigError(
|
|
1300
|
+
f"BRAIN_GRAPH_MAX_DEGREE must be a positive integer "
|
|
1301
|
+
f"(got {graph_max_degree_raw!r})"
|
|
1302
|
+
) from exc
|
|
1303
|
+
if graph_max_degree < 1:
|
|
1304
|
+
raise ConfigError(
|
|
1305
|
+
f"BRAIN_GRAPH_MAX_DEGREE must be a positive integer "
|
|
1306
|
+
f"(got {graph_max_degree_raw!r})"
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
graph_min_edge_raw = os.environ.get("BRAIN_GRAPH_MIN_EDGE_WEIGHT")
|
|
1310
|
+
if graph_min_edge_raw is None or graph_min_edge_raw.strip() == "":
|
|
1311
|
+
graph_min_edge_weight = DEFAULT_GRAPH_MIN_EDGE_WEIGHT
|
|
1312
|
+
else:
|
|
1313
|
+
try:
|
|
1314
|
+
graph_min_edge_weight = float(graph_min_edge_raw)
|
|
1315
|
+
except ValueError as exc:
|
|
1316
|
+
raise ConfigError(
|
|
1317
|
+
"BRAIN_GRAPH_MIN_EDGE_WEIGHT must be a float in [0.0, 1.0] "
|
|
1318
|
+
f"(got {graph_min_edge_raw!r})"
|
|
1319
|
+
) from exc
|
|
1320
|
+
if not (0.0 <= graph_min_edge_weight <= 1.0):
|
|
1321
|
+
raise ConfigError(
|
|
1322
|
+
"BRAIN_GRAPH_MIN_EDGE_WEIGHT must be a float in [0.0, 1.0] "
|
|
1323
|
+
f"(got {graph_min_edge_raw!r})"
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
graph_theme_limit_raw = os.environ.get("BRAIN_GRAPH_THEME_LIMIT")
|
|
1327
|
+
if graph_theme_limit_raw is None or graph_theme_limit_raw.strip() == "":
|
|
1328
|
+
graph_theme_limit = DEFAULT_GRAPH_THEME_LIMIT
|
|
1329
|
+
else:
|
|
1330
|
+
try:
|
|
1331
|
+
graph_theme_limit = int(graph_theme_limit_raw)
|
|
1332
|
+
except ValueError as exc:
|
|
1333
|
+
raise ConfigError(
|
|
1334
|
+
f"BRAIN_GRAPH_THEME_LIMIT must be a positive integer "
|
|
1335
|
+
f"(got {graph_theme_limit_raw!r})"
|
|
1336
|
+
) from exc
|
|
1337
|
+
if graph_theme_limit < 1:
|
|
1338
|
+
raise ConfigError(
|
|
1339
|
+
f"BRAIN_GRAPH_THEME_LIMIT must be a positive integer "
|
|
1340
|
+
f"(got {graph_theme_limit_raw!r})"
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Wave G3 -- global community-detection env vars (spec §17c). Same
|
|
1344
|
+
# eager-validation idiom as the G2 graph knobs above: unset/blank ->
|
|
1345
|
+
# default; non-parseable / out-of-range -> ConfigError at startup so a
|
|
1346
|
+
# typo never surfaces mid community-build.
|
|
1347
|
+
gc_resolution_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_RESOLUTION")
|
|
1348
|
+
if gc_resolution_raw is None or gc_resolution_raw.strip() == "":
|
|
1349
|
+
graph_community_resolution = DEFAULT_GRAPH_COMMUNITY_RESOLUTION
|
|
1350
|
+
else:
|
|
1351
|
+
try:
|
|
1352
|
+
graph_community_resolution = float(gc_resolution_raw)
|
|
1353
|
+
except ValueError as exc:
|
|
1354
|
+
raise ConfigError(
|
|
1355
|
+
"BRAIN_GRAPH_COMMUNITY_RESOLUTION must be a positive float "
|
|
1356
|
+
f"(got {gc_resolution_raw!r})"
|
|
1357
|
+
) from exc
|
|
1358
|
+
if graph_community_resolution <= 0:
|
|
1359
|
+
raise ConfigError(
|
|
1360
|
+
"BRAIN_GRAPH_COMMUNITY_RESOLUTION must be a positive float "
|
|
1361
|
+
f"(got {gc_resolution_raw!r})"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
gc_seed_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_SEED")
|
|
1365
|
+
if gc_seed_raw is None or gc_seed_raw.strip() == "":
|
|
1366
|
+
graph_community_seed = DEFAULT_GRAPH_COMMUNITY_SEED
|
|
1367
|
+
else:
|
|
1368
|
+
try:
|
|
1369
|
+
graph_community_seed = int(gc_seed_raw)
|
|
1370
|
+
except ValueError as exc:
|
|
1371
|
+
raise ConfigError(
|
|
1372
|
+
"BRAIN_GRAPH_COMMUNITY_SEED must be a non-negative integer "
|
|
1373
|
+
f"(got {gc_seed_raw!r})"
|
|
1374
|
+
) from exc
|
|
1375
|
+
if graph_community_seed < 0:
|
|
1376
|
+
raise ConfigError(
|
|
1377
|
+
"BRAIN_GRAPH_COMMUNITY_SEED must be a non-negative integer "
|
|
1378
|
+
f"(got {gc_seed_raw!r})"
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
gc_min_size_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_MIN_SIZE")
|
|
1382
|
+
if gc_min_size_raw is None or gc_min_size_raw.strip() == "":
|
|
1383
|
+
graph_community_min_size = DEFAULT_GRAPH_COMMUNITY_MIN_SIZE
|
|
1384
|
+
else:
|
|
1385
|
+
try:
|
|
1386
|
+
graph_community_min_size = int(gc_min_size_raw)
|
|
1387
|
+
except ValueError as exc:
|
|
1388
|
+
raise ConfigError(
|
|
1389
|
+
"BRAIN_GRAPH_COMMUNITY_MIN_SIZE must be a positive integer "
|
|
1390
|
+
f"(got {gc_min_size_raw!r})"
|
|
1391
|
+
) from exc
|
|
1392
|
+
if graph_community_min_size < 1:
|
|
1393
|
+
raise ConfigError(
|
|
1394
|
+
"BRAIN_GRAPH_COMMUNITY_MIN_SIZE must be a positive integer "
|
|
1395
|
+
f"(got {gc_min_size_raw!r})"
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
gc_jaccard_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_JACCARD")
|
|
1399
|
+
if gc_jaccard_raw is None or gc_jaccard_raw.strip() == "":
|
|
1400
|
+
graph_community_jaccard = DEFAULT_GRAPH_COMMUNITY_JACCARD
|
|
1401
|
+
else:
|
|
1402
|
+
try:
|
|
1403
|
+
graph_community_jaccard = float(gc_jaccard_raw)
|
|
1404
|
+
except ValueError as exc:
|
|
1405
|
+
raise ConfigError(
|
|
1406
|
+
"BRAIN_GRAPH_COMMUNITY_JACCARD must be a float in [0.0, 1.0] "
|
|
1407
|
+
f"(got {gc_jaccard_raw!r})"
|
|
1408
|
+
) from exc
|
|
1409
|
+
if not (0.0 <= graph_community_jaccard <= 1.0):
|
|
1410
|
+
raise ConfigError(
|
|
1411
|
+
"BRAIN_GRAPH_COMMUNITY_JACCARD must be a float in [0.0, 1.0] "
|
|
1412
|
+
f"(got {gc_jaccard_raw!r})"
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
gc_limit_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_LIMIT")
|
|
1416
|
+
if gc_limit_raw is None or gc_limit_raw.strip() == "":
|
|
1417
|
+
graph_community_limit = DEFAULT_GRAPH_COMMUNITY_LIMIT
|
|
1418
|
+
else:
|
|
1419
|
+
try:
|
|
1420
|
+
graph_community_limit = int(gc_limit_raw)
|
|
1421
|
+
except ValueError as exc:
|
|
1422
|
+
raise ConfigError(
|
|
1423
|
+
"BRAIN_GRAPH_COMMUNITY_LIMIT must be a positive integer "
|
|
1424
|
+
f"(got {gc_limit_raw!r})"
|
|
1425
|
+
) from exc
|
|
1426
|
+
if graph_community_limit < 1:
|
|
1427
|
+
raise ConfigError(
|
|
1428
|
+
"BRAIN_GRAPH_COMMUNITY_LIMIT must be a positive integer "
|
|
1429
|
+
f"(got {gc_limit_raw!r})"
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
# ``none`` / ``unlimited`` disables the per-tenant community cap (maps to
|
|
1433
|
+
# ``None``); otherwise a positive int. Mirrors
|
|
1434
|
+
# ``BRAIN_GRAPH_MAX_ENTITIES_PER_DOC``.
|
|
1435
|
+
gc_max_raw = os.environ.get("BRAIN_GRAPH_COMMUNITY_MAX")
|
|
1436
|
+
graph_community_max: int | None
|
|
1437
|
+
if gc_max_raw is None or gc_max_raw.strip() == "":
|
|
1438
|
+
graph_community_max = DEFAULT_GRAPH_COMMUNITY_MAX
|
|
1439
|
+
elif gc_max_raw.strip().lower() in {"none", "unlimited"}:
|
|
1440
|
+
graph_community_max = None
|
|
1441
|
+
else:
|
|
1442
|
+
try:
|
|
1443
|
+
graph_community_max = int(gc_max_raw)
|
|
1444
|
+
except ValueError as exc:
|
|
1445
|
+
raise ConfigError(
|
|
1446
|
+
"BRAIN_GRAPH_COMMUNITY_MAX must be a positive integer "
|
|
1447
|
+
f"or 'none' (got {gc_max_raw!r})"
|
|
1448
|
+
) from exc
|
|
1449
|
+
if graph_community_max < 1:
|
|
1450
|
+
raise ConfigError(
|
|
1451
|
+
"BRAIN_GRAPH_COMMUNITY_MAX must be a positive integer "
|
|
1452
|
+
f"or 'none' (got {gc_max_raw!r})"
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
# Phase 1 -- extra automated-sender denylist entries. Same comma-split /
|
|
1456
|
+
# trim / lowercase / dedupe shape as ``BRAIN_OWNER_PARTICIPANTS``;
|
|
1457
|
+
# unset/blank yields an empty frozenset (generic heuristic only).
|
|
1458
|
+
sender_denylist_raw = os.environ.get("BRAIN_GRAPH_SENDER_DENYLIST", "")
|
|
1459
|
+
graph_sender_denylist = frozenset(
|
|
1460
|
+
piece
|
|
1461
|
+
for piece in (
|
|
1462
|
+
entry.strip().lower() for entry in sender_denylist_raw.split(",")
|
|
1463
|
+
)
|
|
1464
|
+
if piece
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
# Phase B (2026-05-25) -- operator-curated concept extraction stopwords.
|
|
1468
|
+
# Same comma-split / trim / lowercase / dedupe shape as
|
|
1469
|
+
# ``BRAIN_GRAPH_SENDER_DENYLIST``; default empty (real terms are
|
|
1470
|
+
# employer-specific, rule 15 — operators set this locally).
|
|
1471
|
+
stopwords_raw = os.environ.get("BRAIN_GRAPH_EXTRACT_STOPWORDS", "")
|
|
1472
|
+
graph_extract_stopwords = frozenset(
|
|
1473
|
+
piece
|
|
1474
|
+
for piece in (
|
|
1475
|
+
entry.strip().lower() for entry in stopwords_raw.split(",")
|
|
1476
|
+
)
|
|
1477
|
+
if piece
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
# Phase C (2026-05-25) -- curated entity alias rules path. Opt-in:
|
|
1481
|
+
# 1. ``BRAIN_GRAPH_ALIASES_PATH`` when set → use that path (expand user).
|
|
1482
|
+
# 2. Else ``$BRAIN_HOME/graph_aliases.yml`` when that file exists.
|
|
1483
|
+
# 3. Else ``None`` (feature off; ``load_alias_rules(None)`` returns ``[]``).
|
|
1484
|
+
# When a path is resolved, eager-validate it is readable (ConfigError if not).
|
|
1485
|
+
graph_aliases_path: Path | None
|
|
1486
|
+
aliases_env_raw = os.environ.get("BRAIN_GRAPH_ALIASES_PATH")
|
|
1487
|
+
if aliases_env_raw and aliases_env_raw.strip():
|
|
1488
|
+
graph_aliases_path = Path(aliases_env_raw.strip()).expanduser()
|
|
1489
|
+
try:
|
|
1490
|
+
graph_aliases_path.read_text(encoding="utf-8")
|
|
1491
|
+
except OSError as exc:
|
|
1492
|
+
raise ConfigError(
|
|
1493
|
+
f"BRAIN_GRAPH_ALIASES_PATH is not readable: {graph_aliases_path} "
|
|
1494
|
+
f"({exc})"
|
|
1495
|
+
) from exc
|
|
1496
|
+
else:
|
|
1497
|
+
_default_aliases = _brain_home_root() / "graph_aliases.yml"
|
|
1498
|
+
graph_aliases_path = _default_aliases if _default_aliases.exists() else None
|
|
1499
|
+
|
|
1500
|
+
# Tacit-knowledge elicitation knobs. Same eager-validation idiom as the
|
|
1501
|
+
# enrich / graph knobs above: unset/blank -> default; non-parseable /
|
|
1502
|
+
# out-of-range -> ConfigError at startup so a typo never surfaces
|
|
1503
|
+
# mid-elicitation.
|
|
1504
|
+
elicit_min_evidence_raw = os.environ.get("BRAIN_ELICIT_MIN_EVIDENCE_DOCS")
|
|
1505
|
+
if elicit_min_evidence_raw is None or elicit_min_evidence_raw.strip() == "":
|
|
1506
|
+
elicit_min_evidence_docs = DEFAULT_ELICIT_MIN_EVIDENCE_DOCS
|
|
1507
|
+
else:
|
|
1508
|
+
try:
|
|
1509
|
+
elicit_min_evidence_docs = int(elicit_min_evidence_raw)
|
|
1510
|
+
except ValueError as exc:
|
|
1511
|
+
raise ConfigError(
|
|
1512
|
+
f"BRAIN_ELICIT_MIN_EVIDENCE_DOCS must be a non-negative integer "
|
|
1513
|
+
f"(got {elicit_min_evidence_raw!r})"
|
|
1514
|
+
) from exc
|
|
1515
|
+
if elicit_min_evidence_docs < 0:
|
|
1516
|
+
raise ConfigError(
|
|
1517
|
+
f"BRAIN_ELICIT_MIN_EVIDENCE_DOCS must be a non-negative integer "
|
|
1518
|
+
f"(got {elicit_min_evidence_docs!r})"
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
elicit_gap_score_raw = os.environ.get("BRAIN_ELICIT_MIN_GAP_SCORE")
|
|
1522
|
+
if elicit_gap_score_raw is None or elicit_gap_score_raw.strip() == "":
|
|
1523
|
+
elicit_min_gap_score = DEFAULT_ELICIT_MIN_GAP_SCORE
|
|
1524
|
+
else:
|
|
1525
|
+
try:
|
|
1526
|
+
elicit_min_gap_score = float(elicit_gap_score_raw)
|
|
1527
|
+
except ValueError as exc:
|
|
1528
|
+
raise ConfigError(
|
|
1529
|
+
f"BRAIN_ELICIT_MIN_GAP_SCORE must be a float in [0.0, 1.0] "
|
|
1530
|
+
f"(got {elicit_gap_score_raw!r})"
|
|
1531
|
+
) from exc
|
|
1532
|
+
if not (0.0 <= elicit_min_gap_score <= 1.0):
|
|
1533
|
+
raise ConfigError(
|
|
1534
|
+
f"BRAIN_ELICIT_MIN_GAP_SCORE must be a float in [0.0, 1.0] "
|
|
1535
|
+
f"(got {elicit_min_gap_score!r})"
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
elicit_queue_limit_raw = os.environ.get("BRAIN_ELICIT_QUEUE_LIMIT")
|
|
1539
|
+
if elicit_queue_limit_raw is None or elicit_queue_limit_raw.strip() == "":
|
|
1540
|
+
elicit_queue_limit = DEFAULT_ELICIT_QUEUE_LIMIT
|
|
1541
|
+
else:
|
|
1542
|
+
try:
|
|
1543
|
+
elicit_queue_limit = int(elicit_queue_limit_raw)
|
|
1544
|
+
except ValueError as exc:
|
|
1545
|
+
raise ConfigError(
|
|
1546
|
+
f"BRAIN_ELICIT_QUEUE_LIMIT must be a non-negative integer "
|
|
1547
|
+
f"(got {elicit_queue_limit_raw!r})"
|
|
1548
|
+
) from exc
|
|
1549
|
+
if elicit_queue_limit < 0:
|
|
1550
|
+
raise ConfigError(
|
|
1551
|
+
f"BRAIN_ELICIT_QUEUE_LIMIT must be a non-negative integer "
|
|
1552
|
+
f"(got {elicit_queue_limit_raw!r})"
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
elicit_contradiction_enabled_raw = os.environ.get(
|
|
1556
|
+
"BRAIN_ELICIT_CONTRADICTION_ENABLED"
|
|
1557
|
+
)
|
|
1558
|
+
if (
|
|
1559
|
+
elicit_contradiction_enabled_raw is None
|
|
1560
|
+
or elicit_contradiction_enabled_raw.strip() == ""
|
|
1561
|
+
):
|
|
1562
|
+
elicit_contradiction_enabled = DEFAULT_ELICIT_CONTRADICTION_ENABLED
|
|
1563
|
+
else:
|
|
1564
|
+
token = elicit_contradiction_enabled_raw.strip().lower()
|
|
1565
|
+
if token in _GRAPH_ENABLED_TRUTHY:
|
|
1566
|
+
elicit_contradiction_enabled = True
|
|
1567
|
+
elif token in _GRAPH_ENABLED_FALSY:
|
|
1568
|
+
elicit_contradiction_enabled = False
|
|
1569
|
+
else:
|
|
1570
|
+
raise ConfigError(
|
|
1571
|
+
"BRAIN_ELICIT_CONTRADICTION_ENABLED must be one of "
|
|
1572
|
+
"1/true/yes/on or 0/false/no/off "
|
|
1573
|
+
f"(got {elicit_contradiction_enabled_raw!r})"
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
elicit_contradiction_min_docs_raw = os.environ.get(
|
|
1577
|
+
"BRAIN_ELICIT_CONTRADICTION_MIN_DOCS"
|
|
1578
|
+
)
|
|
1579
|
+
if (
|
|
1580
|
+
elicit_contradiction_min_docs_raw is None
|
|
1581
|
+
or elicit_contradiction_min_docs_raw.strip() == ""
|
|
1582
|
+
):
|
|
1583
|
+
elicit_contradiction_min_docs = DEFAULT_ELICIT_CONTRADICTION_MIN_DOCS
|
|
1584
|
+
else:
|
|
1585
|
+
try:
|
|
1586
|
+
elicit_contradiction_min_docs = int(elicit_contradiction_min_docs_raw)
|
|
1587
|
+
except ValueError as exc:
|
|
1588
|
+
raise ConfigError(
|
|
1589
|
+
f"BRAIN_ELICIT_CONTRADICTION_MIN_DOCS must be a non-negative integer "
|
|
1590
|
+
f"(got {elicit_contradiction_min_docs_raw!r})"
|
|
1591
|
+
) from exc
|
|
1592
|
+
if elicit_contradiction_min_docs < 0:
|
|
1593
|
+
raise ConfigError(
|
|
1594
|
+
f"BRAIN_ELICIT_CONTRADICTION_MIN_DOCS must be a non-negative integer "
|
|
1595
|
+
f"(got {elicit_contradiction_min_docs!r})"
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
# Plan 05 -- `brain timeline` env vars. Same eager-validation idiom as
|
|
1599
|
+
# the enrich / graph knobs above: unset/blank -> default; invalid value
|
|
1600
|
+
# -> ConfigError at startup so a typo surfaces before any timeline runs.
|
|
1601
|
+
timeline_gran_raw = os.environ.get("BRAIN_TIMELINE_GRANULARITY")
|
|
1602
|
+
if timeline_gran_raw is None or timeline_gran_raw.strip() == "":
|
|
1603
|
+
timeline_granularity = DEFAULT_TIMELINE_GRANULARITY
|
|
1604
|
+
else:
|
|
1605
|
+
timeline_granularity = timeline_gran_raw.strip().lower()
|
|
1606
|
+
if timeline_granularity not in _VALID_TIMELINE_GRANULARITIES:
|
|
1607
|
+
raise ConfigError(
|
|
1608
|
+
"BRAIN_TIMELINE_GRANULARITY must be one of auto/month/quarter/year "
|
|
1609
|
+
f"(got {timeline_gran_raw!r})"
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
timeline_limit_raw = os.environ.get("BRAIN_TIMELINE_LIMIT")
|
|
1613
|
+
if timeline_limit_raw is None or timeline_limit_raw.strip() == "":
|
|
1614
|
+
timeline_limit = DEFAULT_TIMELINE_LIMIT
|
|
1615
|
+
else:
|
|
1616
|
+
try:
|
|
1617
|
+
timeline_limit = int(timeline_limit_raw)
|
|
1618
|
+
except ValueError as exc:
|
|
1619
|
+
raise ConfigError(
|
|
1620
|
+
f"BRAIN_TIMELINE_LIMIT must be a positive integer "
|
|
1621
|
+
f"(got {timeline_limit_raw!r})"
|
|
1622
|
+
) from exc
|
|
1623
|
+
if timeline_limit < 1:
|
|
1624
|
+
raise ConfigError(
|
|
1625
|
+
f"BRAIN_TIMELINE_LIMIT must be a positive integer "
|
|
1626
|
+
f"(got {timeline_limit_raw!r})"
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
timeline_synth_raw = os.environ.get("BRAIN_TIMELINE_SYNTH_LIMIT")
|
|
1630
|
+
if timeline_synth_raw is None or timeline_synth_raw.strip() == "":
|
|
1631
|
+
timeline_synth_limit = DEFAULT_TIMELINE_SYNTH_LIMIT
|
|
1632
|
+
else:
|
|
1633
|
+
try:
|
|
1634
|
+
timeline_synth_limit = int(timeline_synth_raw)
|
|
1635
|
+
except ValueError as exc:
|
|
1636
|
+
raise ConfigError(
|
|
1637
|
+
f"BRAIN_TIMELINE_SYNTH_LIMIT must be a non-negative integer "
|
|
1638
|
+
f"(got {timeline_synth_raw!r})"
|
|
1639
|
+
) from exc
|
|
1640
|
+
if timeline_synth_limit < 0:
|
|
1641
|
+
raise ConfigError(
|
|
1642
|
+
f"BRAIN_TIMELINE_SYNTH_LIMIT must be a non-negative integer "
|
|
1643
|
+
f"(got {timeline_synth_raw!r})"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
timeline_trim_raw = os.environ.get("BRAIN_TIMELINE_TRIM")
|
|
1647
|
+
if timeline_trim_raw is None or timeline_trim_raw.strip() == "":
|
|
1648
|
+
timeline_trim = DEFAULT_TIMELINE_TRIM
|
|
1649
|
+
else:
|
|
1650
|
+
timeline_trim = timeline_trim_raw.strip().lower()
|
|
1651
|
+
if timeline_trim not in _VALID_TIMELINE_TRIMS:
|
|
1652
|
+
raise ConfigError(
|
|
1653
|
+
"BRAIN_TIMELINE_TRIM must be one of oldest/sparsest "
|
|
1654
|
+
f"(got {timeline_trim_raw!r})"
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
# Plan 07 -- `brain connect` knobs. Same eager-validation idiom as the
|
|
1658
|
+
# elicit / graph knobs above: unset/blank -> default; non-parseable /
|
|
1659
|
+
# out-of-range -> ConfigError at startup so a typo never surfaces
|
|
1660
|
+
# mid-refresh.
|
|
1661
|
+
connect_min_score_raw = os.environ.get("BRAIN_CONNECT_MIN_SCORE")
|
|
1662
|
+
if connect_min_score_raw is None or connect_min_score_raw.strip() == "":
|
|
1663
|
+
connect_min_score = DEFAULT_CONNECT_MIN_SCORE
|
|
1664
|
+
else:
|
|
1665
|
+
try:
|
|
1666
|
+
connect_min_score = float(connect_min_score_raw)
|
|
1667
|
+
except ValueError as exc:
|
|
1668
|
+
raise ConfigError(
|
|
1669
|
+
"BRAIN_CONNECT_MIN_SCORE must be a float in (0.0, 1.0] "
|
|
1670
|
+
f"(got {connect_min_score_raw!r})"
|
|
1671
|
+
) from exc
|
|
1672
|
+
if not (0.0 < connect_min_score <= 1.0):
|
|
1673
|
+
raise ConfigError(
|
|
1674
|
+
"BRAIN_CONNECT_MIN_SCORE must be a float in (0.0, 1.0] "
|
|
1675
|
+
f"(got {connect_min_score_raw!r})"
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
connect_candidate_raw = os.environ.get("BRAIN_CONNECT_CANDIDATE_LIMIT")
|
|
1679
|
+
if connect_candidate_raw is None or connect_candidate_raw.strip() == "":
|
|
1680
|
+
connect_candidate_limit = DEFAULT_CONNECT_CANDIDATE_LIMIT
|
|
1681
|
+
else:
|
|
1682
|
+
try:
|
|
1683
|
+
connect_candidate_limit = int(connect_candidate_raw)
|
|
1684
|
+
except ValueError as exc:
|
|
1685
|
+
raise ConfigError(
|
|
1686
|
+
"BRAIN_CONNECT_CANDIDATE_LIMIT must be an integer >= 1 "
|
|
1687
|
+
f"(got {connect_candidate_raw!r})"
|
|
1688
|
+
) from exc
|
|
1689
|
+
if connect_candidate_limit < 1:
|
|
1690
|
+
raise ConfigError(
|
|
1691
|
+
"BRAIN_CONNECT_CANDIDATE_LIMIT must be an integer >= 1 "
|
|
1692
|
+
f"(got {connect_candidate_raw!r})"
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
connect_max_per_doc_raw = os.environ.get("BRAIN_CONNECT_MAX_PER_DOC")
|
|
1696
|
+
if connect_max_per_doc_raw is None or connect_max_per_doc_raw.strip() == "":
|
|
1697
|
+
connect_max_per_doc = DEFAULT_CONNECT_MAX_PER_DOC
|
|
1698
|
+
else:
|
|
1699
|
+
try:
|
|
1700
|
+
connect_max_per_doc = int(connect_max_per_doc_raw)
|
|
1701
|
+
except ValueError as exc:
|
|
1702
|
+
raise ConfigError(
|
|
1703
|
+
"BRAIN_CONNECT_MAX_PER_DOC must be an integer >= 1 "
|
|
1704
|
+
f"(got {connect_max_per_doc_raw!r})"
|
|
1705
|
+
) from exc
|
|
1706
|
+
if connect_max_per_doc < 1:
|
|
1707
|
+
raise ConfigError(
|
|
1708
|
+
"BRAIN_CONNECT_MAX_PER_DOC must be an integer >= 1 "
|
|
1709
|
+
f"(got {connect_max_per_doc_raw!r})"
|
|
1710
|
+
)
|
|
1711
|
+
# Plan 03 -- `brain review scan` knobs (positive ints + cosine floors).
|
|
1712
|
+
review_conflict_limit = _parse_positive_int_env(
|
|
1713
|
+
"BRAIN_REVIEW_CONFLICT_LIMIT", DEFAULT_REVIEW_CONFLICT_LIMIT
|
|
1714
|
+
)
|
|
1715
|
+
review_conflict_pairs_per_entity = _parse_positive_int_env(
|
|
1716
|
+
"BRAIN_REVIEW_CONFLICT_PAIRS_PER_ENTITY",
|
|
1717
|
+
DEFAULT_REVIEW_CONFLICT_PAIRS_PER_ENTITY,
|
|
1718
|
+
)
|
|
1719
|
+
review_embed_sim_floor = _parse_unit_interval_env(
|
|
1720
|
+
"BRAIN_REVIEW_EMBED_SIM_FLOOR", DEFAULT_REVIEW_EMBED_SIM_FLOOR
|
|
1721
|
+
)
|
|
1722
|
+
review_stale_age_days = _parse_positive_int_env(
|
|
1723
|
+
"BRAIN_REVIEW_STALE_AGE_DAYS", DEFAULT_REVIEW_STALE_AGE_DAYS
|
|
1724
|
+
)
|
|
1725
|
+
review_stale_supersede_window_days = _parse_positive_int_env(
|
|
1726
|
+
"BRAIN_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS",
|
|
1727
|
+
DEFAULT_REVIEW_STALE_SUPERSEDE_WINDOW_DAYS,
|
|
1728
|
+
)
|
|
1729
|
+
review_stale_sim_floor = _parse_unit_interval_env(
|
|
1730
|
+
"BRAIN_REVIEW_STALE_SIM_FLOOR", DEFAULT_REVIEW_STALE_SIM_FLOOR
|
|
1731
|
+
)
|
|
1732
|
+
review_stale_limit = _parse_positive_int_env(
|
|
1733
|
+
"BRAIN_REVIEW_STALE_LIMIT", DEFAULT_REVIEW_STALE_LIMIT
|
|
1734
|
+
)
|
|
1735
|
+
# Plan 06 -- `brain ask` knobs. Same eager-validation pattern as the
|
|
1736
|
+
# enrich / timeline knobs above: unset/blank -> default; non-parseable /
|
|
1737
|
+
# out-of-range -> ConfigError so a typo surfaces at startup, not mid-loop.
|
|
1738
|
+
ask_max_iter_raw = os.environ.get("BRAIN_ASK_MAX_ITERATIONS")
|
|
1739
|
+
if ask_max_iter_raw is None or ask_max_iter_raw.strip() == "":
|
|
1740
|
+
ask_max_iterations = DEFAULT_ASK_MAX_ITERATIONS
|
|
1741
|
+
else:
|
|
1742
|
+
try:
|
|
1743
|
+
ask_max_iterations = int(ask_max_iter_raw)
|
|
1744
|
+
except ValueError as exc:
|
|
1745
|
+
raise ConfigError(
|
|
1746
|
+
f"BRAIN_ASK_MAX_ITERATIONS must be a positive integer "
|
|
1747
|
+
f"(got {ask_max_iter_raw!r})"
|
|
1748
|
+
) from exc
|
|
1749
|
+
if ask_max_iterations < 1:
|
|
1750
|
+
raise ConfigError(
|
|
1751
|
+
f"BRAIN_ASK_MAX_ITERATIONS must be a positive integer "
|
|
1752
|
+
f"(got {ask_max_iter_raw!r})"
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
ask_docs_raw = os.environ.get("BRAIN_ASK_DOCS_PER_ITER")
|
|
1756
|
+
if ask_docs_raw is None or ask_docs_raw.strip() == "":
|
|
1757
|
+
ask_docs_per_iter = DEFAULT_ASK_DOCS_PER_ITER
|
|
1758
|
+
else:
|
|
1759
|
+
try:
|
|
1760
|
+
ask_docs_per_iter = int(ask_docs_raw)
|
|
1761
|
+
except ValueError as exc:
|
|
1762
|
+
raise ConfigError(
|
|
1763
|
+
f"BRAIN_ASK_DOCS_PER_ITER must be a positive integer "
|
|
1764
|
+
f"(got {ask_docs_raw!r})"
|
|
1765
|
+
) from exc
|
|
1766
|
+
if ask_docs_per_iter < 1:
|
|
1767
|
+
raise ConfigError(
|
|
1768
|
+
f"BRAIN_ASK_DOCS_PER_ITER must be a positive integer "
|
|
1769
|
+
f"(got {ask_docs_raw!r})"
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
# ``ask_model`` inherits ``enrich_model`` when unset/blank (the locked
|
|
1773
|
+
# default per the plan's config table) so the ask loop reuses the same
|
|
1774
|
+
# chat model as enrichment unless explicitly overridden.
|
|
1775
|
+
ask_model_raw = os.environ.get("BRAIN_ASK_MODEL")
|
|
1776
|
+
if ask_model_raw is None or ask_model_raw.strip() == "":
|
|
1777
|
+
ask_model = enrich_model
|
|
1778
|
+
else:
|
|
1779
|
+
ask_model = ask_model_raw.strip()
|
|
1780
|
+
|
|
1781
|
+
ask_timeout_raw = os.environ.get("BRAIN_ASK_TIMEOUT_SECONDS")
|
|
1782
|
+
if ask_timeout_raw is None or ask_timeout_raw.strip() == "":
|
|
1783
|
+
ask_timeout_seconds = DEFAULT_ASK_TIMEOUT_SECONDS
|
|
1784
|
+
else:
|
|
1785
|
+
try:
|
|
1786
|
+
ask_timeout_seconds = float(ask_timeout_raw)
|
|
1787
|
+
except ValueError as exc:
|
|
1788
|
+
raise ConfigError(
|
|
1789
|
+
f"BRAIN_ASK_TIMEOUT_SECONDS must be a positive float "
|
|
1790
|
+
f"(got {ask_timeout_raw!r})"
|
|
1791
|
+
) from exc
|
|
1792
|
+
if ask_timeout_seconds <= 0:
|
|
1793
|
+
raise ConfigError(
|
|
1794
|
+
f"BRAIN_ASK_TIMEOUT_SECONDS must be a positive float "
|
|
1795
|
+
f"(got {ask_timeout_raw!r})"
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
# Plan 04 -- `brain audio` env vars. Same eager-validation idiom as the
|
|
1799
|
+
# enrich / timeline knobs above: unset/blank -> default; non-parseable /
|
|
1800
|
+
# out-of-range -> ConfigError at startup so a typo never surfaces
|
|
1801
|
+
# mid-generation.
|
|
1802
|
+
audio_model_raw = os.environ.get("BRAIN_AUDIO_SCRIPT_MODEL")
|
|
1803
|
+
if audio_model_raw is None or audio_model_raw.strip() == "":
|
|
1804
|
+
audio_script_model = DEFAULT_AUDIO_SCRIPT_MODEL
|
|
1805
|
+
else:
|
|
1806
|
+
audio_script_model = audio_model_raw.strip()
|
|
1807
|
+
|
|
1808
|
+
audio_max_turns_raw = os.environ.get("BRAIN_AUDIO_MAX_TURNS")
|
|
1809
|
+
if audio_max_turns_raw is None or audio_max_turns_raw.strip() == "":
|
|
1810
|
+
audio_max_turns = DEFAULT_AUDIO_MAX_TURNS
|
|
1811
|
+
else:
|
|
1812
|
+
try:
|
|
1813
|
+
audio_max_turns = int(audio_max_turns_raw)
|
|
1814
|
+
except ValueError as exc:
|
|
1815
|
+
raise ConfigError(
|
|
1816
|
+
"BRAIN_AUDIO_MAX_TURNS must be a positive even integer "
|
|
1817
|
+
f"(got {audio_max_turns_raw!r})"
|
|
1818
|
+
) from exc
|
|
1819
|
+
if audio_max_turns <= 0 or audio_max_turns % 2 != 0:
|
|
1820
|
+
raise ConfigError(
|
|
1821
|
+
"BRAIN_AUDIO_MAX_TURNS must be a positive even integer "
|
|
1822
|
+
f"(got {audio_max_turns_raw!r})"
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
audio_max_input_raw = os.environ.get("BRAIN_AUDIO_MAX_INPUT_TOKENS")
|
|
1826
|
+
if audio_max_input_raw is None or audio_max_input_raw.strip() == "":
|
|
1827
|
+
audio_max_input_tokens = DEFAULT_AUDIO_MAX_INPUT_TOKENS
|
|
1828
|
+
else:
|
|
1829
|
+
try:
|
|
1830
|
+
audio_max_input_tokens = int(audio_max_input_raw)
|
|
1831
|
+
except ValueError as exc:
|
|
1832
|
+
raise ConfigError(
|
|
1833
|
+
"BRAIN_AUDIO_MAX_INPUT_TOKENS must be a positive integer "
|
|
1834
|
+
f"(got {audio_max_input_raw!r})"
|
|
1835
|
+
) from exc
|
|
1836
|
+
if audio_max_input_tokens <= 0:
|
|
1837
|
+
raise ConfigError(
|
|
1838
|
+
"BRAIN_AUDIO_MAX_INPUT_TOKENS must be a positive integer "
|
|
1839
|
+
f"(got {audio_max_input_raw!r})"
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
audio_theme_limit_raw = os.environ.get("BRAIN_AUDIO_THEME_LIMIT")
|
|
1843
|
+
if audio_theme_limit_raw is None or audio_theme_limit_raw.strip() == "":
|
|
1844
|
+
audio_theme_limit = DEFAULT_AUDIO_THEME_LIMIT
|
|
1845
|
+
else:
|
|
1846
|
+
try:
|
|
1847
|
+
audio_theme_limit = int(audio_theme_limit_raw)
|
|
1848
|
+
except ValueError as exc:
|
|
1849
|
+
raise ConfigError(
|
|
1850
|
+
"BRAIN_AUDIO_THEME_LIMIT must be a positive integer "
|
|
1851
|
+
f"(got {audio_theme_limit_raw!r})"
|
|
1852
|
+
) from exc
|
|
1853
|
+
if audio_theme_limit <= 0:
|
|
1854
|
+
raise ConfigError(
|
|
1855
|
+
"BRAIN_AUDIO_THEME_LIMIT must be a positive integer "
|
|
1856
|
+
f"(got {audio_theme_limit_raw!r})"
|
|
1857
|
+
)
|
|
1858
|
+
# Plan 08 -- `brain gaps` knobs (positive ints).
|
|
1859
|
+
gaps_lookback_days = _parse_positive_int_env(
|
|
1860
|
+
"BRAIN_GAPS_LOOKBACK_DAYS", DEFAULT_GAPS_LOOKBACK_DAYS
|
|
1861
|
+
)
|
|
1862
|
+
gaps_min_cluster_size = _parse_positive_int_env(
|
|
1863
|
+
"BRAIN_GAPS_MIN_CLUSTER_SIZE", DEFAULT_GAPS_MIN_CLUSTER_SIZE
|
|
1864
|
+
)
|
|
1865
|
+
|
|
1866
|
+
return {
|
|
1867
|
+
# brain_home resolves via default_factory=_brain_home_root.
|
|
1868
|
+
"database_url": database_url,
|
|
1869
|
+
"ollama_host": ollama_host,
|
|
1870
|
+
"qwen3_model": qwen3_model,
|
|
1871
|
+
"embedder": embedder,
|
|
1872
|
+
"voyage_api_key": voyage_api_key,
|
|
1873
|
+
"vault_path": vault_path,
|
|
1874
|
+
"user_email": user_email,
|
|
1875
|
+
"vector_sim_floor": vector_sim_floor,
|
|
1876
|
+
"owner_participants": owner_participants,
|
|
1877
|
+
"people_hub_min_docs": people_hub_min_docs,
|
|
1878
|
+
"capture_title_words": capture_title_words,
|
|
1879
|
+
"capture_inbox_warn_threshold": capture_inbox_warn_threshold,
|
|
1880
|
+
"review_activity_limit": review_activity_limit,
|
|
1881
|
+
"review_theme_limit": review_theme_limit,
|
|
1882
|
+
"review_open_loop_limit": review_open_loop_limit,
|
|
1883
|
+
"brief_since_hours": brief_since_hours,
|
|
1884
|
+
"brief_todo_since_days": brief_todo_since_days,
|
|
1885
|
+
"brief_capture_limit": brief_capture_limit,
|
|
1886
|
+
"brief_pin_limit": brief_pin_limit,
|
|
1887
|
+
"recency_halflife_days": recency_halflife_days,
|
|
1888
|
+
"snippet_context_tokens": snippet_context_tokens,
|
|
1889
|
+
"resurface_limit": resurface_limit,
|
|
1890
|
+
"resurface_min_age_days": resurface_min_age_days,
|
|
1891
|
+
"resurface_age_halflife_days": resurface_age_halflife_days,
|
|
1892
|
+
"resurface_access_halflife_days": resurface_access_halflife_days,
|
|
1893
|
+
"enrich_model": enrich_model,
|
|
1894
|
+
"enrich_min_tokens": enrich_min_tokens,
|
|
1895
|
+
"enrich_max_input_tokens": enrich_max_input_tokens,
|
|
1896
|
+
"enrich_timeout_seconds": enrich_timeout_seconds,
|
|
1897
|
+
"ollama_keep_alive": ollama_keep_alive,
|
|
1898
|
+
"graph_enabled": graph_enabled,
|
|
1899
|
+
"graph_tenant_id": graph_tenant_id,
|
|
1900
|
+
"graph_cooccur_window": graph_cooccur_window,
|
|
1901
|
+
"graph_max_entities": graph_max_entities,
|
|
1902
|
+
"graph_generic_df_ratio": graph_generic_df_ratio,
|
|
1903
|
+
"graph_concepts": graph_concepts,
|
|
1904
|
+
"graph_extract_model": graph_extract_model,
|
|
1905
|
+
"graph_extract_max_input_tokens": graph_extract_max_input_tokens,
|
|
1906
|
+
"graph_depth": graph_depth,
|
|
1907
|
+
"graph_frontier_cap": graph_frontier_cap,
|
|
1908
|
+
"graph_max_degree": graph_max_degree,
|
|
1909
|
+
"graph_min_edge_weight": graph_min_edge_weight,
|
|
1910
|
+
"graph_theme_limit": graph_theme_limit,
|
|
1911
|
+
"graph_community_resolution": graph_community_resolution,
|
|
1912
|
+
"graph_community_seed": graph_community_seed,
|
|
1913
|
+
"graph_community_min_size": graph_community_min_size,
|
|
1914
|
+
"graph_community_jaccard": graph_community_jaccard,
|
|
1915
|
+
"graph_community_limit": graph_community_limit,
|
|
1916
|
+
"graph_community_max": graph_community_max,
|
|
1917
|
+
"graph_sender_denylist": graph_sender_denylist,
|
|
1918
|
+
"graph_extract_stopwords": graph_extract_stopwords,
|
|
1919
|
+
"graph_aliases_path": graph_aliases_path,
|
|
1920
|
+
"elicit_min_evidence_docs": elicit_min_evidence_docs,
|
|
1921
|
+
"elicit_min_gap_score": elicit_min_gap_score,
|
|
1922
|
+
"elicit_queue_limit": elicit_queue_limit,
|
|
1923
|
+
"elicit_contradiction_enabled": elicit_contradiction_enabled,
|
|
1924
|
+
"elicit_contradiction_min_docs": elicit_contradiction_min_docs,
|
|
1925
|
+
"timeline_granularity": timeline_granularity,
|
|
1926
|
+
"timeline_limit": timeline_limit,
|
|
1927
|
+
"timeline_synth_limit": timeline_synth_limit,
|
|
1928
|
+
"timeline_trim": timeline_trim,
|
|
1929
|
+
"connect_min_score": connect_min_score,
|
|
1930
|
+
"connect_candidate_limit": connect_candidate_limit,
|
|
1931
|
+
"connect_max_per_doc": connect_max_per_doc,
|
|
1932
|
+
"review_conflict_limit": review_conflict_limit,
|
|
1933
|
+
"review_conflict_pairs_per_entity": review_conflict_pairs_per_entity,
|
|
1934
|
+
"review_embed_sim_floor": review_embed_sim_floor,
|
|
1935
|
+
"review_stale_age_days": review_stale_age_days,
|
|
1936
|
+
"review_stale_supersede_window_days": review_stale_supersede_window_days,
|
|
1937
|
+
"review_stale_sim_floor": review_stale_sim_floor,
|
|
1938
|
+
"review_stale_limit": review_stale_limit,
|
|
1939
|
+
"ask_max_iterations": ask_max_iterations,
|
|
1940
|
+
"ask_docs_per_iter": ask_docs_per_iter,
|
|
1941
|
+
"ask_model": ask_model,
|
|
1942
|
+
"ask_timeout_seconds": ask_timeout_seconds,
|
|
1943
|
+
"audio_script_model": audio_script_model,
|
|
1944
|
+
"audio_max_turns": audio_max_turns,
|
|
1945
|
+
"audio_max_input_tokens": audio_max_input_tokens,
|
|
1946
|
+
"audio_theme_limit": audio_theme_limit,
|
|
1947
|
+
"gaps_lookback_days": gaps_lookback_days,
|
|
1948
|
+
"gaps_min_cluster_size": gaps_min_cluster_size,
|
|
1949
|
+
}
|