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/ingest/pdf.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""PDF extractor — pypdf primary, pdfplumber fallback."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pypdf import PdfReader
|
|
5
|
+
|
|
6
|
+
from . import ExtractedDoc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_pdf(path: Path) -> ExtractedDoc:
|
|
10
|
+
"""Extract an :class:`ExtractedDoc` from a PDF file on disk.
|
|
11
|
+
|
|
12
|
+
Uses ``pypdf`` as the primary extractor. If no text is recovered (e.g. a
|
|
13
|
+
scanned PDF with images only), falls back to ``pdfplumber``, which handles
|
|
14
|
+
some layouts ``pypdf`` misses. Strips repeated header/footer lines that
|
|
15
|
+
appear on a majority of pages.
|
|
16
|
+
"""
|
|
17
|
+
reader = PdfReader(str(path))
|
|
18
|
+
pages_text: list[str] = []
|
|
19
|
+
for page in reader.pages:
|
|
20
|
+
text = page.extract_text() or ""
|
|
21
|
+
pages_text.append(text.strip())
|
|
22
|
+
|
|
23
|
+
full_text = _strip_repeated_lines("\n\n".join(pages_text))
|
|
24
|
+
|
|
25
|
+
if not full_text.strip(): # pragma: no cover - only triggered by image-only PDFs
|
|
26
|
+
full_text = _fallback_pdfplumber(path)
|
|
27
|
+
|
|
28
|
+
return ExtractedDoc(
|
|
29
|
+
title=Path(path).stem,
|
|
30
|
+
content=full_text.strip(),
|
|
31
|
+
content_type="pdf",
|
|
32
|
+
source_path=str(Path(path).resolve()),
|
|
33
|
+
metadata={"page_count": len(reader.pages)},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fallback_pdfplumber(path: Path) -> str: # pragma: no cover - image-only PDFs
|
|
38
|
+
import pdfplumber
|
|
39
|
+
|
|
40
|
+
with pdfplumber.open(str(path)) as pdf:
|
|
41
|
+
return "\n\n".join((p.extract_text() or "").strip() for p in pdf.pages)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _strip_repeated_lines(text: str) -> str:
|
|
45
|
+
"""Remove header/footer lines that appear on >50% of pages."""
|
|
46
|
+
pages = text.split("\n\n")
|
|
47
|
+
if len(pages) < 3:
|
|
48
|
+
return text
|
|
49
|
+
line_counts: dict[str, int] = {}
|
|
50
|
+
for page in pages:
|
|
51
|
+
for line in {ln.strip() for ln in page.splitlines() if ln.strip()}:
|
|
52
|
+
line_counts[line] = line_counts.get(line, 0) + 1
|
|
53
|
+
threshold = len(pages) // 2
|
|
54
|
+
repeated = {line for line, count in line_counts.items() if count > threshold}
|
|
55
|
+
if not repeated:
|
|
56
|
+
return text
|
|
57
|
+
cleaned_pages = [
|
|
58
|
+
"\n".join(ln for ln in page.splitlines() if ln.strip() not in repeated)
|
|
59
|
+
for page in pages
|
|
60
|
+
]
|
|
61
|
+
return "\n\n".join(cleaned_pages)
|
brain/ingest/stdin.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Generic stdin ingester for Claude-orchestrated sources (Krisp, Slack, etc)."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from . import ExtractedDoc
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_doc(
|
|
8
|
+
*,
|
|
9
|
+
content: str,
|
|
10
|
+
title: str,
|
|
11
|
+
content_type: str,
|
|
12
|
+
source_path: str | None = None,
|
|
13
|
+
metadata: dict[str, Any] | None = None,
|
|
14
|
+
) -> ExtractedDoc:
|
|
15
|
+
"""Build an :class:`ExtractedDoc` from raw stdin content plus metadata."""
|
|
16
|
+
return ExtractedDoc(
|
|
17
|
+
title=title,
|
|
18
|
+
content=content.strip(),
|
|
19
|
+
content_type=content_type,
|
|
20
|
+
source_path=source_path,
|
|
21
|
+
metadata=metadata or {},
|
|
22
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Sub-token extractor — surface email/URL/hostname components for FTS."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
# Standard noise TLDs / very common suffix words that add no retrieval value
|
|
5
|
+
# when emitted as standalone sub-tokens. Kept lowercase; comparison is
|
|
6
|
+
# case-insensitive.
|
|
7
|
+
_NOISE_TOKENS: frozenset[str] = frozenset(
|
|
8
|
+
{"com", "org", "net", "io", "co", "gov", "edu"}
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Match emails: local@host.tld[.tld...]
|
|
12
|
+
_EMAIL_RE = re.compile(r"[\w.+\-]+@[\w.\-]+\.\w+", re.UNICODE)
|
|
13
|
+
|
|
14
|
+
# Match URLs (http/https). Stops at whitespace and a few common closing
|
|
15
|
+
# punctuation chars so trailing `)`/`]`/`>` don't get folded into the URL.
|
|
16
|
+
_URL_RE = re.compile(r"https?://[^\s)\]>]+", re.UNICODE)
|
|
17
|
+
|
|
18
|
+
# Match bare hostnames: at least two dot-separated word groups, e.g.
|
|
19
|
+
# ``example.com/groups``. Restricted to word chars + hyphen so it doesn't
|
|
20
|
+
# match every dotted phrase.
|
|
21
|
+
_HOST_RE = re.compile(r"\b[\w\-]+(?:\.[\w\-]+)+\b", re.UNICODE)
|
|
22
|
+
|
|
23
|
+
# Splitter applied to each captured email/URL/host to break it into the
|
|
24
|
+
# individual word components. Includes ``@`` so email captures split on
|
|
25
|
+
# the local/host boundary, and ``:`` so any leftover scheme markers (e.g.
|
|
26
|
+
# port suffixes) don't fold a port number into the host word.
|
|
27
|
+
_SPLIT_RE = re.compile(r"[./+_@:?#&=]+", re.UNICODE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_useful(token: str) -> bool:
|
|
31
|
+
"""Filter predicate — drop tokens too short or too noisy to keep."""
|
|
32
|
+
if len(token) <= 1:
|
|
33
|
+
return False
|
|
34
|
+
if token.isdigit():
|
|
35
|
+
return False
|
|
36
|
+
return token.lower() not in _NOISE_TOKENS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _split_components(value: str) -> list[str]:
|
|
40
|
+
"""Split a captured email/URL/hostname into its component words."""
|
|
41
|
+
return [part for part in _SPLIT_RE.split(value) if part]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_sub_tokens(text: str) -> str:
|
|
45
|
+
"""Extract sub-tokens from emails, URLs, and bare hostnames in ``text``.
|
|
46
|
+
|
|
47
|
+
Returns a single whitespace-joined string of word components in
|
|
48
|
+
*first-seen order* (deduplicated). Returns ``""`` when ``text`` has no
|
|
49
|
+
matches or is empty. Pure function — safe to call twice; idempotent in
|
|
50
|
+
the sense that re-running it on its own output never crashes and
|
|
51
|
+
produces a sane subset (no new sub-tokens to find since the output is
|
|
52
|
+
already plain space-separated words with no `.`/`@`/`/`).
|
|
53
|
+
|
|
54
|
+
Filters: tokens of length ≤ 1, digits-only tokens, and the noise
|
|
55
|
+
suffixes in :data:`_NOISE_TOKENS` are dropped.
|
|
56
|
+
"""
|
|
57
|
+
if not text:
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
ordered: list[str] = []
|
|
62
|
+
|
|
63
|
+
def _add(token: str) -> None:
|
|
64
|
+
if not _is_useful(token):
|
|
65
|
+
return
|
|
66
|
+
key = token.lower()
|
|
67
|
+
if key in seen:
|
|
68
|
+
return
|
|
69
|
+
seen.add(key)
|
|
70
|
+
ordered.append(token)
|
|
71
|
+
|
|
72
|
+
for match in _EMAIL_RE.finditer(text):
|
|
73
|
+
for component in _split_components(match.group(0)):
|
|
74
|
+
_add(component)
|
|
75
|
+
|
|
76
|
+
for match in _URL_RE.finditer(text):
|
|
77
|
+
url = match.group(0)
|
|
78
|
+
# Strip the scheme so we don't emit "https" / "http" as a sub-token.
|
|
79
|
+
if "://" in url:
|
|
80
|
+
url = url.split("://", 1)[1]
|
|
81
|
+
for component in _split_components(url):
|
|
82
|
+
_add(component)
|
|
83
|
+
|
|
84
|
+
for match in _HOST_RE.finditer(text):
|
|
85
|
+
host = match.group(0)
|
|
86
|
+
# Skip bare numerics like "1.2.3" (each part already filtered as
|
|
87
|
+
# digits-only, but we'd still touch the dedup table). Cheap guard.
|
|
88
|
+
for component in _split_components(host):
|
|
89
|
+
_add(component)
|
|
90
|
+
|
|
91
|
+
return " ".join(ordered)
|
brain/ingest/text.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Plain-text extractor."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import ExtractedDoc
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_text(path: Path) -> ExtractedDoc:
|
|
8
|
+
"""Extract an :class:`ExtractedDoc` from a plain-text file on disk."""
|
|
9
|
+
content = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
10
|
+
return ExtractedDoc(
|
|
11
|
+
title=Path(path).stem,
|
|
12
|
+
content=content.strip(),
|
|
13
|
+
content_type="txt",
|
|
14
|
+
source_path=str(Path(path).resolve()),
|
|
15
|
+
metadata={},
|
|
16
|
+
)
|
brain/interactions.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Append-only interaction log writes (one row per user event).
|
|
2
|
+
|
|
3
|
+
Each row in ``interactions`` records one feedback event (open / rate /
|
|
4
|
+
pin / click). Q1-C populated this table from two DOCUMENT surfaces:
|
|
5
|
+
|
|
6
|
+
- CLI: ``brain rate <id> useful|irrelevant`` → source='cli', action one
|
|
7
|
+
of ``rated_useful`` / ``rated_irrelevant``, ``session_id=NULL``.
|
|
8
|
+
- MCP: ``brain_show(..., originating_query=...)`` → source='mcp',
|
|
9
|
+
action='opened', ``query=originating_query``, optional
|
|
10
|
+
``session_id`` minted by a prior ``brain_search`` call.
|
|
11
|
+
|
|
12
|
+
The wiki click surface (source='wiki') is reserved in the schema for a
|
|
13
|
+
future wave; this module accepts it today so the deferred surface needs
|
|
14
|
+
no additional migration.
|
|
15
|
+
|
|
16
|
+
G4-a (migration 015, spec §17d Q2) generalizes the writer so the graph
|
|
17
|
+
surfaces — entity / community / theme — become FIRST-CLASS rateable
|
|
18
|
+
targets. A row now targets EITHER a document (``document_id``) OR a graph
|
|
19
|
+
target (``target_type`` + ``target_id``), never both and never neither —
|
|
20
|
+
the XOR enforced both here (Python boundary) and by the authoritative DB
|
|
21
|
+
``CHECK``. ``graph_retrieved`` is a PROVENANCE flag (a graph surface
|
|
22
|
+
produced this row), independent of the target shape: a document row
|
|
23
|
+
surfaced via a graph path is still a document row with
|
|
24
|
+
``graph_retrieved=True``. No new ``source`` value is introduced.
|
|
25
|
+
|
|
26
|
+
Per plan §3.a, the writer is intentionally narrow: callers go through
|
|
27
|
+
:func:`record_interaction` instead of issuing INSERTs directly so the
|
|
28
|
+
Python-side enum + XOR validation can produce a clean
|
|
29
|
+
:class:`InteractionError` ahead of the authoritative DB-level ``CHECK``
|
|
30
|
+
constraints.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
from .errors import InteractionError
|
|
42
|
+
|
|
43
|
+
InteractionAction = Literal[
|
|
44
|
+
"clicked", "opened", "rated_useful", "rated_irrelevant", "pinned"
|
|
45
|
+
]
|
|
46
|
+
InteractionSource = Literal["cli", "mcp", "wiki"]
|
|
47
|
+
InteractionTargetType = Literal["entity", "community", "theme"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Python-side enum gates — kept in sync with the SQL ``CHECK`` constraints
|
|
51
|
+
# on ``interactions.action`` / ``interactions.source`` (migration 010) and
|
|
52
|
+
# ``interactions.target_type`` (migration 015).
|
|
53
|
+
# Adding a new value here without the matching migration would silently
|
|
54
|
+
# bypass the Python gate but still trip the SQL ``CHECK``; adding to the
|
|
55
|
+
# SQL side without updating these sets would let a Python caller pass an
|
|
56
|
+
# enum value the DB rejects at INSERT time. Keep both in lockstep.
|
|
57
|
+
_VALID_ACTIONS: frozenset[str] = frozenset({
|
|
58
|
+
"clicked", "opened", "rated_useful", "rated_irrelevant", "pinned",
|
|
59
|
+
})
|
|
60
|
+
_VALID_SOURCES: frozenset[str] = frozenset({"cli", "mcp", "wiki"})
|
|
61
|
+
_VALID_TARGET_TYPES: frozenset[str] = frozenset({"entity", "community", "theme"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class InteractionRow:
|
|
66
|
+
"""Read-back projection of one ``interactions`` row.
|
|
67
|
+
|
|
68
|
+
Used by tests + a future ``brain interactions show`` command (not
|
|
69
|
+
exposed yet — the table is write-only from the user's perspective for
|
|
70
|
+
now). ``id`` and ``document_id`` are stringified UUIDs so callers
|
|
71
|
+
don't need to import ``uuid`` just to compare; ``document_id`` is
|
|
72
|
+
``None`` for graph-target rows (G4-a). ``target_type`` / ``target_id``
|
|
73
|
+
are set (and ``document_id`` ``None``) for graph-target rows, mutually
|
|
74
|
+
exclusive with ``document_id`` per the XOR. ``graph_retrieved`` is the
|
|
75
|
+
provenance flag, orthogonal to the target shape.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
document_id: str | None
|
|
80
|
+
query: str | None
|
|
81
|
+
action: str
|
|
82
|
+
source: str
|
|
83
|
+
session_id: str | None
|
|
84
|
+
at: datetime
|
|
85
|
+
target_type: str | None = None
|
|
86
|
+
target_id: str | None = None
|
|
87
|
+
graph_retrieved: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def record_interaction(
|
|
91
|
+
conn: psycopg.Connection[Any],
|
|
92
|
+
*,
|
|
93
|
+
document_id: str | None = None,
|
|
94
|
+
action: InteractionAction,
|
|
95
|
+
source: InteractionSource,
|
|
96
|
+
query: str | None = None,
|
|
97
|
+
session_id: uuid.UUID | None = None,
|
|
98
|
+
target_type: InteractionTargetType | None = None,
|
|
99
|
+
target_id: str | None = None,
|
|
100
|
+
graph_retrieved: bool = False,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""INSERT one row into ``interactions`` and return its UUID as text.
|
|
103
|
+
|
|
104
|
+
Validates ``action`` / ``source`` / ``target_type`` against the
|
|
105
|
+
Python-side enums and enforces the document-XOR-graph-target shape
|
|
106
|
+
before issuing the SQL, so an obvious mistake produces a clean
|
|
107
|
+
:class:`brain.errors.InteractionError` rather than a generic
|
|
108
|
+
:class:`psycopg.errors.CheckViolation`. The DB-level ``CHECK``
|
|
109
|
+
constraints remain the authoritative gate; this is belt-and-braces.
|
|
110
|
+
|
|
111
|
+
A row targets EITHER a document OR a graph target, never both and
|
|
112
|
+
never neither (migration 015 / spec §17d Q2):
|
|
113
|
+
|
|
114
|
+
- Document row: pass ``document_id``; leave ``target_type`` /
|
|
115
|
+
``target_id`` unset. This is the unchanged Q1-C path used by
|
|
116
|
+
``brain rate`` and MCP ``brain_show``.
|
|
117
|
+
- Graph-target row: pass BOTH ``target_type`` and ``target_id``;
|
|
118
|
+
leave ``document_id`` unset.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
conn: Live Postgres connection. The caller controls the
|
|
122
|
+
transaction — for CLI / MCP we run with ``autocommit=True``
|
|
123
|
+
so each interaction is one round-trip.
|
|
124
|
+
document_id: Stringified UUID of the doc the event applies to.
|
|
125
|
+
Foreign-key enforced; deleting the doc cascades to its
|
|
126
|
+
interaction rows. ``None`` (the default) for graph-target rows.
|
|
127
|
+
action: One of :data:`_VALID_ACTIONS`. Type-narrowed by
|
|
128
|
+
:data:`InteractionAction` for static checkers.
|
|
129
|
+
source: One of :data:`_VALID_SOURCES`. Type-narrowed by
|
|
130
|
+
:data:`InteractionSource` for static checkers.
|
|
131
|
+
query: Optional originating query (the search string that led
|
|
132
|
+
the user to this doc). ``None`` is valid for surfaces with
|
|
133
|
+
no query intent (e.g., a direct CLI ``brain show``).
|
|
134
|
+
session_id: Optional UUID grouping a search-then-open pair.
|
|
135
|
+
``None`` for the CLI rating path; populated by MCP when the
|
|
136
|
+
client passes back the id returned from ``brain_search``.
|
|
137
|
+
target_type: One of :data:`_VALID_TARGET_TYPES` ('entity' /
|
|
138
|
+
'community' / 'theme') for a graph-target row; ``None`` for a
|
|
139
|
+
document row. Must be paired with ``target_id``.
|
|
140
|
+
target_id: Durable id of the graph target (entity UUID /
|
|
141
|
+
community_key / theme key) as text; ``None`` for a document
|
|
142
|
+
row. Must be paired with ``target_type``.
|
|
143
|
+
graph_retrieved: Provenance flag — ``True`` when a graph surface
|
|
144
|
+
produced this interaction. Orthogonal to the target shape: a
|
|
145
|
+
document row surfaced via a graph path is still a document row
|
|
146
|
+
with ``graph_retrieved=True``.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The inserted row's UUID as a string.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
InteractionError: ``action`` / ``source`` / ``target_type`` is not
|
|
153
|
+
a recognised enum value, or the document-XOR-graph-target shape
|
|
154
|
+
is violated (both set, neither set, or a half-specified graph
|
|
155
|
+
target).
|
|
156
|
+
psycopg.Error: The INSERT itself failed (FK violation, DB
|
|
157
|
+
outage, etc.) — propagated unchanged so the CLI / MCP outer
|
|
158
|
+
wrappers can surface it with their framework's error type.
|
|
159
|
+
"""
|
|
160
|
+
if action not in _VALID_ACTIONS:
|
|
161
|
+
raise InteractionError(f"unknown action: {action!r}")
|
|
162
|
+
if source not in _VALID_SOURCES:
|
|
163
|
+
raise InteractionError(f"unknown source: {source!r}")
|
|
164
|
+
if target_type is not None and target_type not in _VALID_TARGET_TYPES:
|
|
165
|
+
raise InteractionError(f"unknown target_type: {target_type!r}")
|
|
166
|
+
|
|
167
|
+
has_document = document_id is not None
|
|
168
|
+
has_target = target_type is not None or target_id is not None
|
|
169
|
+
if has_document and has_target:
|
|
170
|
+
raise InteractionError(
|
|
171
|
+
"interaction must target EITHER a document or a graph target, "
|
|
172
|
+
"not both (document_id is mutually exclusive with "
|
|
173
|
+
"target_type/target_id)"
|
|
174
|
+
)
|
|
175
|
+
if not has_document and not has_target:
|
|
176
|
+
raise InteractionError(
|
|
177
|
+
"interaction must target either a document (document_id) or a "
|
|
178
|
+
"graph target (target_type + target_id)"
|
|
179
|
+
)
|
|
180
|
+
if has_target and (target_type is None or target_id is None):
|
|
181
|
+
raise InteractionError(
|
|
182
|
+
"graph-target interaction requires BOTH target_type and target_id"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
row = conn.execute(
|
|
186
|
+
"""
|
|
187
|
+
INSERT INTO interactions
|
|
188
|
+
(document_id, query, action, source, session_id,
|
|
189
|
+
target_type, target_id, graph_retrieved)
|
|
190
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
191
|
+
RETURNING id::text
|
|
192
|
+
""",
|
|
193
|
+
(
|
|
194
|
+
document_id,
|
|
195
|
+
query,
|
|
196
|
+
action,
|
|
197
|
+
source,
|
|
198
|
+
str(session_id) if session_id is not None else None,
|
|
199
|
+
target_type,
|
|
200
|
+
target_id,
|
|
201
|
+
graph_retrieved,
|
|
202
|
+
),
|
|
203
|
+
).fetchone()
|
|
204
|
+
assert row is not None # RETURNING always yields one row
|
|
205
|
+
return str(row[0])
|