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/__init__.py
ADDED
|
File without changes
|
brain/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Enable ``python -m brain [command]`` invocation of the brain CLI.
|
|
2
|
+
|
|
3
|
+
This module is the standard entry-point for Python's ``-m`` flag:
|
|
4
|
+
|
|
5
|
+
python -m brain init
|
|
6
|
+
python -m brain doctor
|
|
7
|
+
python -m brain search "..."
|
|
8
|
+
|
|
9
|
+
It delegates directly to the Typer app in :mod:`brain.cli`, which
|
|
10
|
+
processes ``sys.argv`` and dispatches to the appropriate subcommand.
|
|
11
|
+
``brain setup`` uses this path (via ``sys.executable``) to invoke ``brain
|
|
12
|
+
init`` and ``brain doctor`` inside the same virtualenv that is running the
|
|
13
|
+
installer, without relying on the ``brain`` console-script being on ``PATH``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from brain.cli import app
|
|
17
|
+
|
|
18
|
+
app()
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""`brain capture` quick-capture inbox sub-app (Plan 09 Phases 1-2)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import date
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from brain import capture as capture_mod
|
|
12
|
+
from brain.config import Config
|
|
13
|
+
from brain.db import connect
|
|
14
|
+
from brain.errors import EnrichmentError, OllamaUnavailable, VaultNoteSyncError
|
|
15
|
+
from brain.format import emit_json
|
|
16
|
+
from brain.ingest import apply_tags
|
|
17
|
+
from brain.queries import (
|
|
18
|
+
DocumentRow,
|
|
19
|
+
count_documents_with_tag,
|
|
20
|
+
list_documents,
|
|
21
|
+
list_existing_tags,
|
|
22
|
+
)
|
|
23
|
+
from brain.tags import normalize_tags
|
|
24
|
+
from brain.vault.note_builder import create_vault_note
|
|
25
|
+
|
|
26
|
+
# The always-on tag every capture carries until it is reviewed out of the inbox.
|
|
27
|
+
_INBOX_TAG = "inbox"
|
|
28
|
+
# Default number of inbox items surfaced by ``brain capture review``.
|
|
29
|
+
_REVIEW_DEFAULT_LIMIT = 10
|
|
30
|
+
# Upper bound on items listed by ``brain capture list`` (the inbox is small by
|
|
31
|
+
# design; this caps a runaway listing without paginating).
|
|
32
|
+
_INBOX_LIST_LIMIT = 100
|
|
33
|
+
# Cap on brand-new (non-vocabulary) tags applied per item in ``--auto`` mode.
|
|
34
|
+
_AUTO_MAX_NEW = 1
|
|
35
|
+
|
|
36
|
+
capture_app = typer.Typer(
|
|
37
|
+
name="capture",
|
|
38
|
+
help=(
|
|
39
|
+
"Quick-capture inbox: jot a thought into the brain, tagged `inbox`. "
|
|
40
|
+
"Pipe text on stdin or pass --text; review later with the inbox tools."
|
|
41
|
+
),
|
|
42
|
+
invoke_without_command=True,
|
|
43
|
+
no_args_is_help=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@capture_app.callback(invoke_without_command=True)
|
|
48
|
+
def capture(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
title: str | None = typer.Option(
|
|
51
|
+
None, "--title", help="Document title. Defaults to a date-stamped auto-title."
|
|
52
|
+
),
|
|
53
|
+
text: str | None = typer.Option(
|
|
54
|
+
None, "--text", help="Capture content inline instead of piping stdin."
|
|
55
|
+
),
|
|
56
|
+
tag: list[str] = typer.Option(
|
|
57
|
+
[], "--tag", "-t", help="Extra tag(s) applied alongside the always-on `inbox` tag."
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Capture a quick note into the inbox (tagged `inbox`).
|
|
61
|
+
|
|
62
|
+
Content comes from ``--text`` when provided, otherwise from stdin. Each
|
|
63
|
+
capture authors a vault-tier note under ``capture/`` (visible in the
|
|
64
|
+
Quartz UI without the "Show ingested" toggle). No inline graph sync is
|
|
65
|
+
performed — captures are picked up by the normal ``brain-rebuild`` cycle.
|
|
66
|
+
|
|
67
|
+
No inline enrichment is performed — ``documents.summary`` stays ``NULL``
|
|
68
|
+
until ``brain enrich --backfill`` (or a full ``brain-rebuild``) runs, so
|
|
69
|
+
``brain capture review --auto`` will defer freshly-captured items until
|
|
70
|
+
a summary is present.
|
|
71
|
+
"""
|
|
72
|
+
# Phase 2 registers `review` / `list` subcommands under this app; when a
|
|
73
|
+
# subcommand is invoked the callback must defer to it instead of capturing.
|
|
74
|
+
if ctx.invoked_subcommand is not None:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
content = text if text is not None else sys.stdin.read()
|
|
78
|
+
if not content.strip():
|
|
79
|
+
typer.secho(
|
|
80
|
+
"capture content is empty (pass --text or pipe content on stdin)",
|
|
81
|
+
fg="red",
|
|
82
|
+
err=True,
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
cfg = Config.load()
|
|
87
|
+
if cfg.vault_path is None:
|
|
88
|
+
typer.secho(
|
|
89
|
+
"vault path is not configured — set BRAIN_VAULT_PATH or run "
|
|
90
|
+
"`brain vault init`",
|
|
91
|
+
fg="red",
|
|
92
|
+
err=True,
|
|
93
|
+
)
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
resolved_title = (
|
|
97
|
+
title.strip()
|
|
98
|
+
if title and title.strip()
|
|
99
|
+
else capture_mod.make_capture_title(
|
|
100
|
+
content, today=date.today(), max_words=cfg.capture_title_words
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
# Normalize at the capture boundary so the vault frontmatter tags are
|
|
104
|
+
# queryable by exact tag filters. "inbox" is already canonical; prepending
|
|
105
|
+
# it never collides.
|
|
106
|
+
tags = normalize_tags(["inbox", *tag])
|
|
107
|
+
|
|
108
|
+
# Resolve the embedder factory through ``brain.cli`` at call time: importing
|
|
109
|
+
# at module load would create an import cycle (cli imports this module), and
|
|
110
|
+
# the test suite monkeypatches ``brain.cli._build_embedder`` — reading the
|
|
111
|
+
# attribute off the module lazily honors that patch.
|
|
112
|
+
from brain import cli as _cli
|
|
113
|
+
|
|
114
|
+
embedder = _cli._build_embedder(cfg) # type: ignore[attr-defined]
|
|
115
|
+
try:
|
|
116
|
+
with connect(cfg.database_url) as conn:
|
|
117
|
+
conn.autocommit = True
|
|
118
|
+
doc_id = create_vault_note(
|
|
119
|
+
conn,
|
|
120
|
+
cfg=cfg,
|
|
121
|
+
vault_path=cfg.vault_path,
|
|
122
|
+
title=resolved_title,
|
|
123
|
+
body=content,
|
|
124
|
+
tags=tags,
|
|
125
|
+
folder="capture",
|
|
126
|
+
embedder=embedder,
|
|
127
|
+
)
|
|
128
|
+
except VaultNoteSyncError as exc:
|
|
129
|
+
for path, reason in exc.errors:
|
|
130
|
+
typer.secho(f"sync error: {path}: {reason}", fg="red", err=True)
|
|
131
|
+
raise typer.Exit(code=1) from exc
|
|
132
|
+
|
|
133
|
+
typer.echo(f"✓ captured {doc_id[:8]} ({resolved_title}) [inbox]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Phase 2 — inbox review + list subcommands.
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fetch_summaries(
|
|
142
|
+
conn: psycopg.Connection[Any], ids: list[str]
|
|
143
|
+
) -> dict[str, str | None]:
|
|
144
|
+
"""Return ``{document_id: summary}`` for ``ids`` in one round-trip.
|
|
145
|
+
|
|
146
|
+
``list_documents`` returns the light projection (``summary`` is always
|
|
147
|
+
``None``), so the review views fetch summaries separately. A single
|
|
148
|
+
``= ANY`` query avoids an N+1 over the inbox.
|
|
149
|
+
"""
|
|
150
|
+
if not ids:
|
|
151
|
+
return {}
|
|
152
|
+
rows = conn.execute(
|
|
153
|
+
"SELECT id::text, summary FROM documents WHERE id = ANY(%s::uuid[])",
|
|
154
|
+
(ids,),
|
|
155
|
+
).fetchall()
|
|
156
|
+
return {str(r[0]): r[1] for r in rows}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _count_inbox(conn: psycopg.Connection[Any]) -> int:
|
|
160
|
+
"""Return the number of documents still carrying the ``inbox`` tag.
|
|
161
|
+
|
|
162
|
+
Delegates to :func:`brain.queries.count_documents_with_tag` — the same
|
|
163
|
+
shared helper the ``brain doctor`` inbox WARN uses — so the inbox-size
|
|
164
|
+
query lives in exactly one place.
|
|
165
|
+
"""
|
|
166
|
+
return count_documents_with_tag(conn, _INBOX_TAG)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_item(
|
|
170
|
+
*, index: int, total: int, row: DocumentRow, summary: str | None
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Render one inbox item (id + title, optional summary, current tags).
|
|
173
|
+
|
|
174
|
+
Never logs the document body — only the title, summary, and tags, per the
|
|
175
|
+
"no document content at INFO" boundary.
|
|
176
|
+
"""
|
|
177
|
+
typer.echo(f"[{index}/{total}] {row.id[:8]} {row.title}")
|
|
178
|
+
if summary:
|
|
179
|
+
typer.echo(f" summary: {summary}")
|
|
180
|
+
typer.echo(f" tags: {', '.join(row.tags)}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _discard_item(
|
|
184
|
+
conn: psycopg.Connection[Any],
|
|
185
|
+
*,
|
|
186
|
+
cfg: Config,
|
|
187
|
+
row: DocumentRow,
|
|
188
|
+
graph_syncer: Any,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Confirm + delete one inbox document (row, chunks, vault mirror).
|
|
191
|
+
|
|
192
|
+
Prints the destructive confirmation and only proceeds on an exact ``y`` /
|
|
193
|
+
``Y``. The ``DELETE FROM documents`` cascades to chunks and to the relational
|
|
194
|
+
graph source-of-truth tables (``graph_entity_mentions``,
|
|
195
|
+
``graph_edge_contributions``, ``graph_index_state``) via ``ON DELETE CASCADE``
|
|
196
|
+
FKs, so no inline relational cleanup is needed.
|
|
197
|
+
|
|
198
|
+
However, *derived* aggregate state (``graph_relationships`` and the AGE mirror)
|
|
199
|
+
is NOT cascade-covered — it requires :meth:`~brain.graph_rag.sync.GraphSyncer.remove`
|
|
200
|
+
to recompute aggregates and GC orphaned vertices/edges. This is only needed
|
|
201
|
+
when the document was previously graph-indexed, so we check
|
|
202
|
+
``graph_index_state`` BEFORE the DELETE (the watermark row cascade-deletes
|
|
203
|
+
with the document). Fresh captures (no ``graph_index_state`` row) skip the
|
|
204
|
+
AGE work entirely, paying zero cost on discard. This mirrors the behaviour
|
|
205
|
+
of ``brain rm``, which always calls ``graph_syncer.remove``.
|
|
206
|
+
|
|
207
|
+
``brain.cli._rm_unlink_vault_mirror`` removes the on-disk mirror with the
|
|
208
|
+
same guards/contract as ``brain rm``.
|
|
209
|
+
"""
|
|
210
|
+
answer = typer.prompt(
|
|
211
|
+
f'Discard "{row.title}"? This cannot be undone. [y/N]',
|
|
212
|
+
default="n",
|
|
213
|
+
show_default=False,
|
|
214
|
+
prompt_suffix=" ",
|
|
215
|
+
).strip()
|
|
216
|
+
if answer not in ("y", "Y"):
|
|
217
|
+
typer.echo(f" kept {row.id[:8]}")
|
|
218
|
+
return
|
|
219
|
+
# Read the graph watermark BEFORE the DELETE — it cascade-deletes with the doc.
|
|
220
|
+
_gis_row = conn.execute(
|
|
221
|
+
"SELECT EXISTS(SELECT 1 FROM graph_index_state WHERE document_id = %s)",
|
|
222
|
+
(row.id,),
|
|
223
|
+
).fetchone()
|
|
224
|
+
had_graph_state: bool = bool(_gis_row[0]) if _gis_row is not None else False
|
|
225
|
+
# vault_path is not in the list projection — fetch it for the unlink.
|
|
226
|
+
vrow = conn.execute(
|
|
227
|
+
"SELECT vault_path FROM documents WHERE id = %s", (row.id,)
|
|
228
|
+
).fetchone()
|
|
229
|
+
vault_path_rel: str | None = vrow[0] if vrow else None
|
|
230
|
+
# DELETE cascades to chunks and relational graph source-of-truth tables via FK.
|
|
231
|
+
conn.execute("DELETE FROM documents WHERE id = %s", (row.id,))
|
|
232
|
+
# For docs that were graph-indexed, clean up derived aggregate/AGE state.
|
|
233
|
+
# Fresh captures (no graph_index_state row) skip this — zero AGE cost.
|
|
234
|
+
if had_graph_state:
|
|
235
|
+
graph_syncer.remove(conn, row.id)
|
|
236
|
+
# Reuse the shared rm helper for the file-side contract (suffix strings +
|
|
237
|
+
# missing-file tolerance) rather than re-implementing the unlink.
|
|
238
|
+
from brain import cli as _cli
|
|
239
|
+
|
|
240
|
+
suffix = _cli._rm_unlink_vault_mirror(cfg=cfg, vault_path_rel=vault_path_rel)
|
|
241
|
+
typer.echo(f" discarded {row.id[:8]}{suffix}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _writeback_routed_tags(
|
|
245
|
+
conn: psycopg.Connection[Any],
|
|
246
|
+
*,
|
|
247
|
+
cfg: Config,
|
|
248
|
+
doc_id: str,
|
|
249
|
+
new_tags: list[str],
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Sync a routed item's vault frontmatter after ``apply_tags``.
|
|
252
|
+
|
|
253
|
+
Captures write a ``capture/<slug>.md`` vault note, so a promote /
|
|
254
|
+
tag / ``--auto`` route that drops ``inbox`` (and adds routing tags) in the
|
|
255
|
+
DB must also rewrite that file's YAML ``tags`` — otherwise the file keeps
|
|
256
|
+
a stale ``inbox`` tag that a later ``brain vault sync`` could reintroduce.
|
|
257
|
+
Reuses :func:`brain.cli._tag_file_writeback` (the same post-``apply_tags``
|
|
258
|
+
writeback ``brain tag`` uses) with ``regenerate_file=False``: a missing
|
|
259
|
+
file degrades to DB-only + a warning rather than being force-regenerated.
|
|
260
|
+
"""
|
|
261
|
+
row = conn.execute(
|
|
262
|
+
"SELECT vault_path, kind FROM documents WHERE id = %s", (doc_id,)
|
|
263
|
+
).fetchone()
|
|
264
|
+
if row is None: # pragma: no cover - apply_tags above proved the row exists
|
|
265
|
+
return
|
|
266
|
+
vault_path_rel: str | None = row[0]
|
|
267
|
+
kind: str = row[1]
|
|
268
|
+
from brain import cli as _cli
|
|
269
|
+
|
|
270
|
+
_cli._tag_file_writeback(
|
|
271
|
+
conn,
|
|
272
|
+
cfg=cfg,
|
|
273
|
+
vault_path_rel=vault_path_rel,
|
|
274
|
+
kind=kind,
|
|
275
|
+
new_tags=new_tags,
|
|
276
|
+
doc_id=doc_id,
|
|
277
|
+
regenerate_file=False,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _review_interactive(
|
|
282
|
+
conn: psycopg.Connection[Any],
|
|
283
|
+
rows: list[DocumentRow],
|
|
284
|
+
*,
|
|
285
|
+
cfg: Config,
|
|
286
|
+
graph_syncer: Any,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Drive the per-item ``[p]romote [t]ag [d]iscard [s]kip [q]uit`` loop."""
|
|
289
|
+
summaries = _fetch_summaries(conn, [r.id for r in rows])
|
|
290
|
+
for index, row in enumerate(rows, start=1):
|
|
291
|
+
_print_item(
|
|
292
|
+
index=index, total=len(rows), row=row, summary=summaries.get(row.id)
|
|
293
|
+
)
|
|
294
|
+
raw = typer.prompt(
|
|
295
|
+
"[p]romote [t]ag [d]iscard [s]kip [q]uit >",
|
|
296
|
+
default="s",
|
|
297
|
+
show_default=False,
|
|
298
|
+
prompt_suffix=" ",
|
|
299
|
+
).strip()
|
|
300
|
+
parts = raw.split()
|
|
301
|
+
command = parts[0].lower() if parts else "s"
|
|
302
|
+
args = parts[1:]
|
|
303
|
+
if command == "q":
|
|
304
|
+
break
|
|
305
|
+
if command == "p":
|
|
306
|
+
new_tags = apply_tags(conn, row.id, remove=[_INBOX_TAG])
|
|
307
|
+
_writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
|
|
308
|
+
typer.echo(f" promoted {row.id[:8]}")
|
|
309
|
+
elif command == "t":
|
|
310
|
+
if not args:
|
|
311
|
+
typer.echo(" no tags given; left in inbox")
|
|
312
|
+
continue
|
|
313
|
+
new_tags = apply_tags(conn, row.id, add=args, remove=[_INBOX_TAG])
|
|
314
|
+
_writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
|
|
315
|
+
applied = " +".join(normalize_tags(args))
|
|
316
|
+
typer.echo(f" tagged {row.id[:8]}: +{applied}")
|
|
317
|
+
elif command == "d":
|
|
318
|
+
_discard_item(conn, cfg=cfg, row=row, graph_syncer=graph_syncer)
|
|
319
|
+
elif command == "s":
|
|
320
|
+
typer.echo(f" skipped {row.id[:8]}")
|
|
321
|
+
else:
|
|
322
|
+
typer.echo(f" unknown command {command!r}; left in inbox")
|
|
323
|
+
typer.echo(f"{_count_inbox(conn)} item(s) remaining in inbox")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _review_auto(
|
|
327
|
+
conn: psycopg.Connection[Any],
|
|
328
|
+
rows: list[DocumentRow],
|
|
329
|
+
*,
|
|
330
|
+
cfg: Config,
|
|
331
|
+
enricher: Any,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Non-interactive routing: LLM-propose tags per item, remove ``inbox``.
|
|
334
|
+
|
|
335
|
+
Items without a summary (the LLM is unreliable on raw bodies) or for which
|
|
336
|
+
enrichment fails are reported and left in the inbox — a single bad item
|
|
337
|
+
never aborts the batch.
|
|
338
|
+
"""
|
|
339
|
+
summaries = _fetch_summaries(conn, [r.id for r in rows])
|
|
340
|
+
# Hoist the vocabulary query outside the loop — one round-trip for N items.
|
|
341
|
+
existing_vocab = list_existing_tags(conn)
|
|
342
|
+
for row in rows:
|
|
343
|
+
summary = summaries.get(row.id)
|
|
344
|
+
if not summary:
|
|
345
|
+
typer.echo(
|
|
346
|
+
f"{row.id[:8]} left in inbox (no summary; run "
|
|
347
|
+
"`brain enrich --backfill`)"
|
|
348
|
+
)
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
proposal = enricher.propose_tags(
|
|
352
|
+
title=row.title,
|
|
353
|
+
summary=summary,
|
|
354
|
+
existing_vocab=existing_vocab,
|
|
355
|
+
current_tags=[_INBOX_TAG],
|
|
356
|
+
max_new=_AUTO_MAX_NEW,
|
|
357
|
+
)
|
|
358
|
+
except (OllamaUnavailable, EnrichmentError) as exc:
|
|
359
|
+
typer.echo(
|
|
360
|
+
f"{row.id[:8]} left in inbox (enrichment failed: "
|
|
361
|
+
f"{type(exc).__name__})"
|
|
362
|
+
)
|
|
363
|
+
continue
|
|
364
|
+
accepted = list(proposal.existing) + list(proposal.new[:_AUTO_MAX_NEW])
|
|
365
|
+
if not accepted:
|
|
366
|
+
typer.echo(f"{row.id[:8]} no tags proposed; left in inbox")
|
|
367
|
+
continue
|
|
368
|
+
new_tags = apply_tags(conn, row.id, add=accepted, remove=[_INBOX_TAG])
|
|
369
|
+
_writeback_routed_tags(conn, cfg=cfg, doc_id=row.id, new_tags=new_tags)
|
|
370
|
+
typer.echo(f"{row.id[:8]} routed: +{' +'.join(accepted)} (inbox removed)")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@capture_app.command("review")
|
|
374
|
+
def capture_review(
|
|
375
|
+
auto: bool = typer.Option(
|
|
376
|
+
False,
|
|
377
|
+
"--auto",
|
|
378
|
+
help="Non-interactive: LLM-route each item out of the inbox via tags.",
|
|
379
|
+
),
|
|
380
|
+
limit: int = typer.Option(
|
|
381
|
+
_REVIEW_DEFAULT_LIMIT,
|
|
382
|
+
"--limit",
|
|
383
|
+
"-n",
|
|
384
|
+
help="Max inbox items to review this pass.",
|
|
385
|
+
),
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Review the quick-capture inbox: promote, tag, or discard each item.
|
|
388
|
+
|
|
389
|
+
Interactive by default — per item choose ``[p]romote`` (drop the ``inbox``
|
|
390
|
+
tag), ``[t]ag TAG ...`` (add tags + drop ``inbox``), ``[d]iscard`` (delete
|
|
391
|
+
after an explicit ``y``/``Y`` confirmation), ``[s]kip``, or ``[q]uit``.
|
|
392
|
+
``--auto`` routes every item non-interactively via the local-Ollama tag
|
|
393
|
+
proposer (items without a summary are left untouched).
|
|
394
|
+
"""
|
|
395
|
+
cfg = Config.load()
|
|
396
|
+
from brain import cli as _cli
|
|
397
|
+
|
|
398
|
+
with connect(cfg.database_url) as conn:
|
|
399
|
+
conn.autocommit = True
|
|
400
|
+
rows = list_documents(conn, tag=_INBOX_TAG, limit=limit)
|
|
401
|
+
if not rows:
|
|
402
|
+
typer.echo("inbox is empty")
|
|
403
|
+
return
|
|
404
|
+
if auto:
|
|
405
|
+
enricher = _cli._build_enricher(cfg)
|
|
406
|
+
_review_auto(conn, rows, cfg=cfg, enricher=enricher)
|
|
407
|
+
return
|
|
408
|
+
graph_syncer = _cli._build_graph_syncer(cfg)
|
|
409
|
+
_review_interactive(conn, rows, cfg=cfg, graph_syncer=graph_syncer)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@capture_app.command("list")
|
|
413
|
+
def capture_list(
|
|
414
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON instead."),
|
|
415
|
+
) -> None:
|
|
416
|
+
"""List the documents currently sitting in the quick-capture inbox.
|
|
417
|
+
|
|
418
|
+
Mirrors ``brain list`` formatting: a compact ``<id> <kind> <type> <title>``
|
|
419
|
+
table by default, or a JSON array (each item carrying its ``tags``, which
|
|
420
|
+
always include ``inbox``) under ``--json``.
|
|
421
|
+
"""
|
|
422
|
+
cfg = Config.load()
|
|
423
|
+
with connect(cfg.database_url) as conn:
|
|
424
|
+
rows = list_documents(conn, tag=_INBOX_TAG, limit=_INBOX_LIST_LIMIT)
|
|
425
|
+
if json_output:
|
|
426
|
+
emit_json(
|
|
427
|
+
[
|
|
428
|
+
{
|
|
429
|
+
"id": r.id,
|
|
430
|
+
"title": r.title,
|
|
431
|
+
"content_type": r.content_type,
|
|
432
|
+
"tags": r.tags,
|
|
433
|
+
"source_kind": r.source_kind,
|
|
434
|
+
"ingested_at": r.ingested_at,
|
|
435
|
+
}
|
|
436
|
+
for r in rows
|
|
437
|
+
]
|
|
438
|
+
)
|
|
439
|
+
return
|
|
440
|
+
if not rows:
|
|
441
|
+
typer.echo("inbox is empty")
|
|
442
|
+
return
|
|
443
|
+
for r in rows:
|
|
444
|
+
kind = r.source_kind or "manual"
|
|
445
|
+
typer.echo(f"{r.id[:8]} {kind:<8} {r.content_type:<10} {r.title}")
|
brain/_compose.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Helper that wraps every `docker compose` invocation with the correct flags.
|
|
2
|
+
|
|
3
|
+
Centralising this prevents call sites from drifting — every invocation MUST
|
|
4
|
+
use both ``-f <BRAIN_HOME>/docker-compose.yml`` AND ``--project-name`` (default
|
|
5
|
+
``brain``). Without ``--project-name``, the project name defaults to cwd
|
|
6
|
+
basename and shells from different directories can target different compose
|
|
7
|
+
projects against the same compose file.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .config import _brain_home_root
|
|
13
|
+
|
|
14
|
+
# Default compose project name. Overridable via ``BRAIN_COMPOSE_PROJECT`` so a
|
|
15
|
+
# second stack (QA, a throwaway ``BRAIN_HOME``) targets an isolated project +
|
|
16
|
+
# container name instead of colliding with the real ``brain`` stack.
|
|
17
|
+
DEFAULT_COMPOSE_PROJECT = "brain"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compose_project_name() -> str:
|
|
21
|
+
"""Return the active compose project name (``$BRAIN_COMPOSE_PROJECT`` or ``brain``).
|
|
22
|
+
|
|
23
|
+
Single source of truth so :func:`compose_cmd` and the setup-time compose
|
|
24
|
+
render (``container_name``) stay in lock-step — a non-default project must
|
|
25
|
+
both switch ``--project-name`` and derive a non-colliding container name.
|
|
26
|
+
"""
|
|
27
|
+
return os.environ.get("BRAIN_COMPOSE_PROJECT") or DEFAULT_COMPOSE_PROJECT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compose_cmd(*args: str, brain_home: Path | None = None) -> list[str]:
|
|
31
|
+
"""Build a docker-compose argv list with mandatory -f and --project-name flags.
|
|
32
|
+
|
|
33
|
+
Resolves ``$BRAIN_HOME`` via :func:`brain.config._brain_home_root` — so the
|
|
34
|
+
compose file path is consistent with the rest of the runtime. The optional
|
|
35
|
+
``brain_home`` kwarg overrides the resolved path and is the recommended seam
|
|
36
|
+
for tests: pass ``tmp_path / ".brain"`` rather than monkeypatching env vars.
|
|
37
|
+
|
|
38
|
+
``--project-name`` comes from :func:`compose_project_name` so the default
|
|
39
|
+
stays ``brain`` for real users while ``BRAIN_COMPOSE_PROJECT`` isolates a
|
|
40
|
+
parallel stack.
|
|
41
|
+
"""
|
|
42
|
+
home = brain_home if brain_home is not None else _brain_home_root()
|
|
43
|
+
compose_file = home / "docker-compose.yml"
|
|
44
|
+
return [
|
|
45
|
+
"docker",
|
|
46
|
+
"compose",
|
|
47
|
+
"-f",
|
|
48
|
+
str(compose_file),
|
|
49
|
+
"--project-name",
|
|
50
|
+
compose_project_name(),
|
|
51
|
+
*args,
|
|
52
|
+
]
|