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/demo/__init__.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""`brain demo` — zero-Ollama taste test over a synthetic Larkspur corpus.
|
|
2
|
+
|
|
3
|
+
Orchestration core for the demo experience: load the packaged synthetic
|
|
4
|
+
corpus, provision an isolated throwaway Postgres (stock ``pgvector`` image, a
|
|
5
|
+
Docker named volume, NEVER the prod container), seed it deterministically with
|
|
6
|
+
:class:`~brain.demo.embedder.DemoEmbedder` (no Ollama), run FTS-only hybrid
|
|
7
|
+
search, and tear the whole thing down.
|
|
8
|
+
|
|
9
|
+
The isolation contract is binding (see the module constants): a dedicated
|
|
10
|
+
compose project / container / database / named volume, and a hard guard
|
|
11
|
+
(:func:`_assert_not_demo_prod_db`) that refuses to operate against the prod
|
|
12
|
+
database on port 55432 / db ``second_brain``. The Typer surface lives in
|
|
13
|
+
:mod:`brain.cli_demo`; this module owns the provision / seed / query / status /
|
|
14
|
+
teardown primitives it calls.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.resources
|
|
19
|
+
import json
|
|
20
|
+
import socket
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
from brain.db import connect, ensure_embedding_column, run_migrations
|
|
30
|
+
from brain.errors import BrainError, DemoError
|
|
31
|
+
from brain.ingest import ExtractedDoc, ingest_document
|
|
32
|
+
from brain.queries import finalize_embedding_index
|
|
33
|
+
from brain.search import SearchResult, hybrid_search
|
|
34
|
+
|
|
35
|
+
from .embedder import DemoEmbedder
|
|
36
|
+
|
|
37
|
+
# --- Isolation contract (binding) ------------------------------------------
|
|
38
|
+
# A dedicated compose project + container + database + Docker NAMED VOLUME, on
|
|
39
|
+
# the stock pgvector image (NOT the custom AGE prod image). Nothing here may
|
|
40
|
+
# ever resolve to the prod container (port 55432, db ``second_brain``,
|
|
41
|
+
# ./data/postgres bind-mount) — the demo is a throwaway sandbox.
|
|
42
|
+
COMPOSE_PROJECT = "brain-demo"
|
|
43
|
+
CONTAINER_NAME = "second-brain-demo-postgres"
|
|
44
|
+
DEMO_DB_NAME = "second_brain_demo"
|
|
45
|
+
DEMO_IMAGE = "pgvector/pgvector:pg16"
|
|
46
|
+
DEMO_VOLUME = "brain-demo-pgdata"
|
|
47
|
+
DEMO_DB_USER = "brain"
|
|
48
|
+
DEMO_DB_PASSWORD = "brain" # noqa: S105 — throwaway local sandbox credential
|
|
49
|
+
DEFAULT_DEMO_PORT = 55433
|
|
50
|
+
|
|
51
|
+
# Where the generated compose file is materialized (a throwaway dir under the
|
|
52
|
+
# user's home — never the repo, never $BRAIN_HOME prod state).
|
|
53
|
+
DEMO_HOME = Path.home() / ".brain-demo"
|
|
54
|
+
COMPOSE_FILENAME = "docker-compose.yml"
|
|
55
|
+
|
|
56
|
+
# Prod-safety guard values — mirror ``tests/conftest.py``'s ``_assert_not_prod_db``.
|
|
57
|
+
_PROD_PORTS = frozenset({5433, 55432})
|
|
58
|
+
_PROD_DB_NAME = "second_brain"
|
|
59
|
+
# ``0.0.0.0`` binds all interfaces on the local host — treat it as local so a
|
|
60
|
+
# ``0.0.0.0:55432`` URL can't slip past the prod guard.
|
|
61
|
+
_LOCAL_HOSTS = frozenset({"", "localhost", "127.0.0.1", "::1", "0.0.0.0"})
|
|
62
|
+
|
|
63
|
+
# How long to wait for the freshly-provisioned container to accept connections.
|
|
64
|
+
_PROVISION_READY_TIMEOUT_S = 30.0
|
|
65
|
+
_PROVISION_POLL_INTERVAL_S = 0.5
|
|
66
|
+
_SUBPROCESS_TIMEOUT_S = 120
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SeedReport:
|
|
71
|
+
"""Outcome of :func:`seed_demo`.
|
|
72
|
+
|
|
73
|
+
``ingested`` counts newly-created documents, ``skipped`` counts content-hash
|
|
74
|
+
/source dedup no-ops (a re-seed reports every doc skipped). ``total`` is the
|
|
75
|
+
corpus size — ``ingested + skipped`` on a clean seed.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
ingested: int
|
|
79
|
+
skipped: int
|
|
80
|
+
total: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class DemoStatus:
|
|
85
|
+
"""Snapshot of the demo sandbox for ``brain demo status``."""
|
|
86
|
+
|
|
87
|
+
running: bool
|
|
88
|
+
container: str
|
|
89
|
+
database_url: str | None
|
|
90
|
+
doc_count: int | None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_corpus() -> list[dict[str, Any]]:
|
|
94
|
+
"""Load the packaged synthetic corpus manifest (22 docs).
|
|
95
|
+
|
|
96
|
+
Resolved via :func:`importlib.resources.files` so it works in both editable
|
|
97
|
+
checkouts and pipx/wheel installs. Returns the raw record list exactly as
|
|
98
|
+
authored in ``corpus/manifest.json``.
|
|
99
|
+
"""
|
|
100
|
+
resource = importlib.resources.files("brain.demo") / "corpus" / "manifest.json"
|
|
101
|
+
text = resource.read_text(encoding="utf-8")
|
|
102
|
+
data = json.loads(text)
|
|
103
|
+
if not isinstance(data, list):
|
|
104
|
+
raise BrainError("demo corpus manifest must be a JSON list of records")
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def demo_database_url(port: int) -> str:
|
|
109
|
+
"""Return the Postgres URL for the demo container at ``port``."""
|
|
110
|
+
return (
|
|
111
|
+
f"postgresql://{DEMO_DB_USER}:{DEMO_DB_PASSWORD}"
|
|
112
|
+
f"@localhost:{port}/{DEMO_DB_NAME}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _assert_not_demo_prod_db(database_url: str) -> None:
|
|
117
|
+
"""Refuse to operate against the prod database.
|
|
118
|
+
|
|
119
|
+
Copied in spirit from ``tests/conftest.py``'s ``_assert_not_prod_db``:
|
|
120
|
+
aborts when the resolved (host, port, dbname) looks like the prod container
|
|
121
|
+
— any known prod port (55432 current, 5433 historical) on a local host, OR
|
|
122
|
+
the exact prod database name on any host. Every demo primitive that opens a
|
|
123
|
+
connection calls this first so a mis-pointed ``--database-url`` can never
|
|
124
|
+
seed / query / drop prod data.
|
|
125
|
+
"""
|
|
126
|
+
parsed = urlparse(database_url)
|
|
127
|
+
host = (parsed.hostname or "").lower()
|
|
128
|
+
port = parsed.port
|
|
129
|
+
dbname = (parsed.path or "").lstrip("/")
|
|
130
|
+
is_local = host in _LOCAL_HOSTS
|
|
131
|
+
if (is_local and port in _PROD_PORTS) or (dbname == _PROD_DB_NAME):
|
|
132
|
+
raise BrainError(
|
|
133
|
+
"REFUSING to run `brain demo` against what looks like the PROD "
|
|
134
|
+
f"database (host={host!r} port={port!r} db={dbname!r}). The demo "
|
|
135
|
+
"must only ever touch its own throwaway container "
|
|
136
|
+
f"(db {DEMO_DB_NAME!r}, named volume {DEMO_VOLUME!r}). Fix "
|
|
137
|
+
"--database-url so it does not point at prod."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _record_to_doc(record: dict[str, Any]) -> ExtractedDoc:
|
|
142
|
+
"""Project a manifest record onto an :class:`ExtractedDoc` for ingest.
|
|
143
|
+
|
|
144
|
+
Promotable metadata (``date`` → ``sent_at``, ``participants``,
|
|
145
|
+
``duration_min``, ``thread_id``) rides in ``metadata`` and is picked up by
|
|
146
|
+
the ingest pipeline's column-promotion. ``None`` / empty values are omitted
|
|
147
|
+
so promotion skips them cleanly. Demo docs are never file-backed, so
|
|
148
|
+
``source_path`` stays ``None`` (dedup is by ``(source, external_id)``).
|
|
149
|
+
"""
|
|
150
|
+
metadata: dict[str, Any] = {"date": record["date"]}
|
|
151
|
+
participants = record.get("participants") or []
|
|
152
|
+
if participants:
|
|
153
|
+
metadata["participants"] = list(participants)
|
|
154
|
+
duration = record.get("duration_min")
|
|
155
|
+
if duration is not None:
|
|
156
|
+
metadata["duration_min"] = duration
|
|
157
|
+
thread_id = record.get("thread_id")
|
|
158
|
+
if thread_id:
|
|
159
|
+
metadata["thread_id"] = thread_id
|
|
160
|
+
return ExtractedDoc(
|
|
161
|
+
title=record["title"],
|
|
162
|
+
content=record["body"],
|
|
163
|
+
content_type=record["content_type"],
|
|
164
|
+
source_path=None,
|
|
165
|
+
metadata=metadata,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def seed_demo(database_url: str, *, with_embeddings: bool = False) -> SeedReport:
|
|
170
|
+
"""Ingest the full synthetic corpus into ``database_url``.
|
|
171
|
+
|
|
172
|
+
Reconciles ``chunks.embedding`` to :class:`DemoEmbedder`'s dim first (so the
|
|
173
|
+
demo runs regardless of the schema's shipped default dim), then ingests all
|
|
174
|
+
22 docs with the deterministic embedder — no Ollama, no enrichment, no graph
|
|
175
|
+
sync, no vault mirror. Idempotent: a re-seed dedups on ``(source,
|
|
176
|
+
external_id)`` and reports every doc skipped.
|
|
177
|
+
|
|
178
|
+
``with_embeddings=True`` additionally finalizes the embedding column (NOT
|
|
179
|
+
NULL + HNSW cosine index) so the vector leg of hybrid search is exercised;
|
|
180
|
+
the default leaves the column unindexed since the demo query path is
|
|
181
|
+
FTS-only.
|
|
182
|
+
"""
|
|
183
|
+
_assert_not_demo_prod_db(database_url)
|
|
184
|
+
embedder = DemoEmbedder()
|
|
185
|
+
corpus = load_corpus()
|
|
186
|
+
ingested = 0
|
|
187
|
+
skipped = 0
|
|
188
|
+
with connect(database_url) as conn:
|
|
189
|
+
conn.autocommit = True
|
|
190
|
+
ensure_embedding_column(conn, embedder)
|
|
191
|
+
for record in corpus:
|
|
192
|
+
result = ingest_document(
|
|
193
|
+
conn,
|
|
194
|
+
embedder=embedder,
|
|
195
|
+
doc=_record_to_doc(record),
|
|
196
|
+
source_kind=record["source"],
|
|
197
|
+
source_external_id=record["external_id"],
|
|
198
|
+
tags=list(record.get("tags") or []),
|
|
199
|
+
enrich=False,
|
|
200
|
+
enricher=None,
|
|
201
|
+
vault_root=None,
|
|
202
|
+
graph_syncer=None,
|
|
203
|
+
)
|
|
204
|
+
if result.created:
|
|
205
|
+
ingested += 1
|
|
206
|
+
else:
|
|
207
|
+
skipped += 1
|
|
208
|
+
if with_embeddings:
|
|
209
|
+
finalize_embedding_index(conn, embedder)
|
|
210
|
+
return SeedReport(ingested=ingested, skipped=skipped, total=len(corpus))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def query_demo(
|
|
214
|
+
database_url: str,
|
|
215
|
+
query: str,
|
|
216
|
+
*,
|
|
217
|
+
limit: int = 5,
|
|
218
|
+
source: str | None = None,
|
|
219
|
+
tag: str | None = None,
|
|
220
|
+
person: str | None = None,
|
|
221
|
+
after: datetime | None = None,
|
|
222
|
+
with_embeddings: bool = False,
|
|
223
|
+
) -> list[SearchResult]:
|
|
224
|
+
"""Run hybrid search over the seeded demo corpus.
|
|
225
|
+
|
|
226
|
+
FTS-only by default (``with_embeddings=False`` → ``fts_only=True``): no
|
|
227
|
+
query embedding, fully deterministic, zero Ollama. ``person`` is resolved
|
|
228
|
+
directly to a lowercased participant key (the demo has no directory layer),
|
|
229
|
+
matching the case-insensitive participant overlap the search SQL applies.
|
|
230
|
+
"""
|
|
231
|
+
_assert_not_demo_prod_db(database_url)
|
|
232
|
+
embedder = DemoEmbedder()
|
|
233
|
+
person_keys = [person.strip().lower()] if person else None
|
|
234
|
+
with connect(database_url) as conn:
|
|
235
|
+
conn.autocommit = True
|
|
236
|
+
return hybrid_search(
|
|
237
|
+
conn,
|
|
238
|
+
embedder=embedder,
|
|
239
|
+
query=query,
|
|
240
|
+
limit=limit,
|
|
241
|
+
source_kind=source,
|
|
242
|
+
tag=tag,
|
|
243
|
+
fts_only=not with_embeddings,
|
|
244
|
+
person_keys=person_keys,
|
|
245
|
+
person_display_name=person,
|
|
246
|
+
after=after,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --- Docker / provisioning primitives --------------------------------------
|
|
251
|
+
# All Docker access funnels through :func:`_run` so tests can mock the single
|
|
252
|
+
# subprocess boundary (CLAUDE.md rule 13: subprocess-boundary mocks are fine).
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
256
|
+
"""Run a subprocess, capturing output. The single mocked Docker boundary."""
|
|
257
|
+
return subprocess.run(
|
|
258
|
+
args,
|
|
259
|
+
check=check,
|
|
260
|
+
capture_output=True,
|
|
261
|
+
text=True,
|
|
262
|
+
timeout=_SUBPROCESS_TIMEOUT_S,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run_docker(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
267
|
+
"""Run a docker command (check=True), translating failures into ``DemoError``.
|
|
268
|
+
|
|
269
|
+
Mirrors the boundary translation in :class:`brain.audio.TtsCommandRunner`: a
|
|
270
|
+
missing binary, a dead daemon, or a timeout surfaces as a single actionable
|
|
271
|
+
:class:`DemoError` carrying Docker's own stderr — never a raw traceback that
|
|
272
|
+
escapes the CLI's ``except BrainError`` catch.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
return _run(args)
|
|
276
|
+
except FileNotFoundError as exc:
|
|
277
|
+
raise DemoError(
|
|
278
|
+
"Docker CLI not found while starting the demo container — install "
|
|
279
|
+
"Docker, or pass --database-url <postgres-url> to seed an existing "
|
|
280
|
+
"empty database."
|
|
281
|
+
) from exc
|
|
282
|
+
except subprocess.CalledProcessError as exc:
|
|
283
|
+
detail = (exc.stderr or "").strip() or "<no stderr captured>"
|
|
284
|
+
raise DemoError(
|
|
285
|
+
f"Docker failed to start the demo container (exit {exc.returncode}). "
|
|
286
|
+
f"Is the Docker daemon running?\n{detail}"
|
|
287
|
+
) from exc
|
|
288
|
+
except subprocess.TimeoutExpired as exc:
|
|
289
|
+
raise DemoError(
|
|
290
|
+
f"Docker timed out after {exc.timeout:.0f}s while starting the demo "
|
|
291
|
+
"container. Check that the Docker daemon is responsive."
|
|
292
|
+
) from exc
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _compose_file_text(port: int) -> str:
|
|
296
|
+
"""Render the minimal demo compose file (stock image, named volume)."""
|
|
297
|
+
return (
|
|
298
|
+
"services:\n"
|
|
299
|
+
" postgres:\n"
|
|
300
|
+
f" image: {DEMO_IMAGE}\n"
|
|
301
|
+
f" container_name: {CONTAINER_NAME}\n"
|
|
302
|
+
" environment:\n"
|
|
303
|
+
f" POSTGRES_USER: {DEMO_DB_USER}\n"
|
|
304
|
+
f" POSTGRES_PASSWORD: {DEMO_DB_PASSWORD}\n"
|
|
305
|
+
f" POSTGRES_DB: {DEMO_DB_NAME}\n"
|
|
306
|
+
" ports:\n"
|
|
307
|
+
f' - "{port}:5432"\n'
|
|
308
|
+
" volumes:\n"
|
|
309
|
+
f" - {DEMO_VOLUME}:/var/lib/postgresql/data\n"
|
|
310
|
+
"volumes:\n"
|
|
311
|
+
f" {DEMO_VOLUME}:\n"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _write_compose_file(port: int) -> Path:
|
|
316
|
+
"""Materialize the compose file under :data:`DEMO_HOME`; return its path."""
|
|
317
|
+
DEMO_HOME.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
path = DEMO_HOME / COMPOSE_FILENAME
|
|
319
|
+
path.write_text(_compose_file_text(port), encoding="utf-8")
|
|
320
|
+
return path
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _port_is_free(port: int) -> bool:
|
|
324
|
+
"""True iff ``port`` can be bound on localhost (nothing else listening)."""
|
|
325
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
326
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
327
|
+
try:
|
|
328
|
+
sock.bind(("127.0.0.1", port))
|
|
329
|
+
except OSError:
|
|
330
|
+
return False
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def resolve_port(start: int, *, attempts: int = 20) -> int:
|
|
335
|
+
"""Return ``start`` or the next free port above it (auto-bump on collision)."""
|
|
336
|
+
for candidate in range(start, start + attempts):
|
|
337
|
+
if _port_is_free(candidate):
|
|
338
|
+
return candidate
|
|
339
|
+
raise BrainError(
|
|
340
|
+
f"no free port found in [{start}, {start + attempts}) for the demo container"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _wait_until_ready(database_url: str) -> None:
|
|
345
|
+
"""Poll the provisioned container until it accepts connections (≤30s)."""
|
|
346
|
+
deadline = time.monotonic() + _PROVISION_READY_TIMEOUT_S
|
|
347
|
+
last_error: Exception | None = None
|
|
348
|
+
while time.monotonic() < deadline:
|
|
349
|
+
try:
|
|
350
|
+
with connect(database_url) as conn:
|
|
351
|
+
conn.execute("SELECT 1")
|
|
352
|
+
return
|
|
353
|
+
except Exception as exc: # noqa: BLE001 — retry any connection failure
|
|
354
|
+
last_error = exc
|
|
355
|
+
time.sleep(_PROVISION_POLL_INTERVAL_S)
|
|
356
|
+
raise BrainError(
|
|
357
|
+
f"demo Postgres did not become ready within "
|
|
358
|
+
f"{_PROVISION_READY_TIMEOUT_S:.0f}s: {last_error}"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def provision(port: int) -> str:
|
|
363
|
+
"""Bring up the isolated demo Postgres and migrate it; return its URL.
|
|
364
|
+
|
|
365
|
+
Writes the minimal compose file, ``docker compose -p brain-demo up -d`` on
|
|
366
|
+
the stock pgvector image + named volume, waits for readiness, then applies
|
|
367
|
+
the relational migrations and reconciles ``chunks.embedding`` to the demo
|
|
368
|
+
embedder's dim. Graph/AGE is never bootstrapped — the stock image has no
|
|
369
|
+
AGE, and the demo query path never needs it.
|
|
370
|
+
"""
|
|
371
|
+
database_url = demo_database_url(port)
|
|
372
|
+
_assert_not_demo_prod_db(database_url)
|
|
373
|
+
_write_compose_file(port)
|
|
374
|
+
_run_docker(
|
|
375
|
+
[
|
|
376
|
+
"docker", "compose",
|
|
377
|
+
"-p", COMPOSE_PROJECT,
|
|
378
|
+
"-f", str(DEMO_HOME / COMPOSE_FILENAME),
|
|
379
|
+
"up", "-d",
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
_wait_until_ready(database_url)
|
|
383
|
+
with connect(database_url) as conn:
|
|
384
|
+
conn.autocommit = True
|
|
385
|
+
run_migrations(conn)
|
|
386
|
+
ensure_embedding_column(conn, DemoEmbedder())
|
|
387
|
+
return database_url
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _container_running() -> bool:
|
|
391
|
+
"""True iff the demo container is up (``docker ps`` names it)."""
|
|
392
|
+
try:
|
|
393
|
+
result = _run(
|
|
394
|
+
[
|
|
395
|
+
"docker", "ps",
|
|
396
|
+
"--filter", f"name={CONTAINER_NAME}",
|
|
397
|
+
"--format", "{{.Names}}",
|
|
398
|
+
],
|
|
399
|
+
check=False,
|
|
400
|
+
)
|
|
401
|
+
except (OSError, subprocess.SubprocessError):
|
|
402
|
+
return False
|
|
403
|
+
return CONTAINER_NAME in result.stdout.split()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _count_documents(database_url: str) -> int | None:
|
|
407
|
+
"""Return the demo corpus doc count, or ``None`` if the DB is unreachable."""
|
|
408
|
+
try:
|
|
409
|
+
with connect(database_url) as conn:
|
|
410
|
+
row = conn.execute("SELECT count(*) FROM documents").fetchone()
|
|
411
|
+
return int(row[0]) if row is not None else None
|
|
412
|
+
except Exception: # noqa: BLE001 — status is best-effort; unreachable → None
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def status(port: int = DEFAULT_DEMO_PORT) -> DemoStatus:
|
|
417
|
+
"""Report whether the demo sandbox is running + its doc count."""
|
|
418
|
+
running = _container_running()
|
|
419
|
+
url = demo_database_url(port) if running else None
|
|
420
|
+
doc_count = _count_documents(url) if url is not None else None
|
|
421
|
+
return DemoStatus(
|
|
422
|
+
running=running,
|
|
423
|
+
container=CONTAINER_NAME,
|
|
424
|
+
database_url=url,
|
|
425
|
+
doc_count=doc_count,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def teardown() -> None:
|
|
430
|
+
"""Destroy the demo sandbox: ``docker compose -p brain-demo down -v``.
|
|
431
|
+
|
|
432
|
+
``down -v`` removes the named volume too, so nothing of the demo survives.
|
|
433
|
+
Only ever targets the ``brain-demo`` compose project — never prod (a
|
|
434
|
+
separate project on a bind-mount that ``down -v`` cannot reach anyway).
|
|
435
|
+
|
|
436
|
+
``check=False`` already tolerates a non-zero ``down`` (nothing to remove),
|
|
437
|
+
but a missing Docker binary (``FileNotFoundError``) or a hung daemon
|
|
438
|
+
(``TimeoutExpired``) would still escape — translate those into a clean
|
|
439
|
+
:class:`DemoError` so the CLI reports gracefully instead of tracebacking.
|
|
440
|
+
"""
|
|
441
|
+
compose_file = DEMO_HOME / COMPOSE_FILENAME
|
|
442
|
+
args = ["docker", "compose", "-p", COMPOSE_PROJECT]
|
|
443
|
+
if compose_file.is_file():
|
|
444
|
+
args += ["-f", str(compose_file)]
|
|
445
|
+
args += ["down", "-v"]
|
|
446
|
+
try:
|
|
447
|
+
_run(args, check=False)
|
|
448
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
449
|
+
raise DemoError(
|
|
450
|
+
f"Docker was unavailable while tearing down the demo sandbox: {exc}. "
|
|
451
|
+
"If Docker isn't installed there is nothing to tear down."
|
|
452
|
+
) from exc
|