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/rank_fusion.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Pure Reciprocal Rank Fusion (RRF) scoring helper shared across rankers."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["rrf_contribution"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def rrf_contribution(rank: int, *, k: int = 60) -> float:
|
|
7
|
+
"""RRF contribution ``1 / (k + rank + 1)`` for a 0-indexed ``rank`` (default ``k=60``)."""
|
|
8
|
+
return 1.0 / (k + rank + 1)
|
brain/resurface.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Spaced-repetition resurfacing: score older, unreviewed docs for revisit.
|
|
2
|
+
|
|
3
|
+
Pure on-the-fly scoring over existing signals (Plan 02) -- no new table, no
|
|
4
|
+
state drift. Each document is scored from three signals already in the schema:
|
|
5
|
+
|
|
6
|
+
1. **Age** -- ``coalesce(documents.sent_at, documents.ingested_at)``.
|
|
7
|
+
2. **Last access** -- ``MAX(interactions.at)`` over ``opened`` / ``clicked``
|
|
8
|
+
rows (``NULL`` => never reviewed).
|
|
9
|
+
3. **Importance** -- tag count + summary presence.
|
|
10
|
+
|
|
11
|
+
``resurface_docs`` runs one read-only SQL query, computes ages in the DB (so
|
|
12
|
+
doc timestamps, last-access timestamps, and "now" share one clock), then scores
|
|
13
|
+
and sorts in Python. ``score_document`` is the pure-logic core, isolated so it
|
|
14
|
+
can be unit-tested without a database.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import psycopg
|
|
22
|
+
from psycopg import sql
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
|
|
26
|
+
# Content types never worth resurfacing: action-item digests are operational,
|
|
27
|
+
# not knowledge to revisit. Excluded by the SQL guard.
|
|
28
|
+
_EXCLUDED_CONTENT_TYPES: tuple[str, ...] = ("krisp_action_items",)
|
|
29
|
+
|
|
30
|
+
# Length of the body preview returned with each item. Matches the search
|
|
31
|
+
# snippet convention; never emits the full body (privacy -- spec §6).
|
|
32
|
+
_SNIPPET_CHARS = 200
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ResurfaceItem:
|
|
37
|
+
"""One scored document due for review.
|
|
38
|
+
|
|
39
|
+
``last_access_days`` is ``None`` when the document has never been opened or
|
|
40
|
+
clicked (treated as "never reviewed" by the scorer). ``snippet`` is the
|
|
41
|
+
first :data:`_SNIPPET_CHARS` characters of the body -- the full ``content``
|
|
42
|
+
column is never emitted.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
title: str
|
|
47
|
+
source_kind: str | None
|
|
48
|
+
content_type: str
|
|
49
|
+
tags: list[str]
|
|
50
|
+
age_days: float
|
|
51
|
+
last_access_days: float | None
|
|
52
|
+
score: float
|
|
53
|
+
snippet: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def score_document(
|
|
57
|
+
*,
|
|
58
|
+
age_days: float,
|
|
59
|
+
last_access_days: float | None,
|
|
60
|
+
tag_count: int | None,
|
|
61
|
+
has_summary: bool,
|
|
62
|
+
age_halflife_days: float,
|
|
63
|
+
access_halflife_days: float,
|
|
64
|
+
) -> float:
|
|
65
|
+
"""Compute the resurface score for one document.
|
|
66
|
+
|
|
67
|
+
``resurface_score = age_factor * access_staleness * importance_factor`` where
|
|
68
|
+
|
|
69
|
+
- ``age_factor = 1 - 0.5^(age_days / age_halflife_days)`` -- the inverted
|
|
70
|
+
``search.py`` recency decay, so older docs score higher (saturates toward
|
|
71
|
+
1.0 well beyond the half-life).
|
|
72
|
+
- ``access_staleness = 1 - 0.5^(effective_access / access_halflife_days)``
|
|
73
|
+
where ``effective_access`` is ``last_access_days`` when the doc has been
|
|
74
|
+
opened, else ``age_days`` (a never-opened doc is treated as stale for its
|
|
75
|
+
whole life). A doc opened today scores near 0; never-opened scores near 1.
|
|
76
|
+
- ``importance_factor = 1.0 + 0.1*tag_count + 0.2*has_summary`` -- a mild
|
|
77
|
+
additive boost for richer docs. No LLM call.
|
|
78
|
+
|
|
79
|
+
All time inputs are in days. ``tag_count`` may be ``None`` (treated as 0).
|
|
80
|
+
The half-lives are required positive floats (validated upstream by
|
|
81
|
+
:class:`brain.config.Config`); this function does not re-validate them.
|
|
82
|
+
"""
|
|
83
|
+
effective_access = last_access_days if last_access_days is not None else age_days
|
|
84
|
+
age_factor = 1.0 - 0.5 ** (age_days / age_halflife_days)
|
|
85
|
+
access_staleness = 1.0 - 0.5 ** (effective_access / access_halflife_days)
|
|
86
|
+
importance_factor = 1.0 + 0.1 * (tag_count or 0) + (0.2 if has_summary else 0.0)
|
|
87
|
+
return float(age_factor * access_staleness * importance_factor)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resurface_docs(
|
|
91
|
+
conn: psycopg.Connection[Any],
|
|
92
|
+
*,
|
|
93
|
+
cfg: Config,
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
min_age_days: int | None = None,
|
|
96
|
+
source_kind: str | None = None,
|
|
97
|
+
) -> list[ResurfaceItem]:
|
|
98
|
+
"""Return the top scored documents due for review, highest score first.
|
|
99
|
+
|
|
100
|
+
Pulls every non-draft, non-action-item document older than
|
|
101
|
+
``min_age_days`` (default :attr:`Config.resurface_min_age_days`), computes
|
|
102
|
+
each document's age and last-access age in the DB, scores them with
|
|
103
|
+
:func:`score_document`, and returns the top ``limit``
|
|
104
|
+
(default :attr:`Config.resurface_limit`) by descending score. Ties break on
|
|
105
|
+
older-first then id, for a deterministic order.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
conn: Live Postgres connection (read-only query).
|
|
109
|
+
cfg: Active config -- supplies the limit / min-age / half-life defaults.
|
|
110
|
+
limit: Max items to return; ``None`` uses ``cfg.resurface_limit``.
|
|
111
|
+
min_age_days: Exclude docs younger than this; ``None`` uses
|
|
112
|
+
``cfg.resurface_min_age_days``.
|
|
113
|
+
source_kind: Optional ``sources.kind`` filter (e.g. ``"manual"``).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Up to ``limit`` :class:`ResurfaceItem` rows, descending score. Empty
|
|
117
|
+
when the corpus is empty or every doc is younger than ``min_age_days``.
|
|
118
|
+
"""
|
|
119
|
+
effective_limit = cfg.resurface_limit if limit is None else limit
|
|
120
|
+
effective_min_age = (
|
|
121
|
+
cfg.resurface_min_age_days if min_age_days is None else min_age_days
|
|
122
|
+
)
|
|
123
|
+
# Validate the explicit override path. ``cfg`` values are already validated
|
|
124
|
+
# by Config.load(), but a direct CLI/MCP/API caller can still pass a bad
|
|
125
|
+
# value — and a bad one is silently wrong, not loud: limit=0 returns
|
|
126
|
+
# nothing, limit<0 slices "all but the last N" via ``items[:limit]``, and a
|
|
127
|
+
# negative min_age shifts the cutoff into the future (admitting brand-new /
|
|
128
|
+
# future-dated docs). Reject both with a clear ValueError; the CLI maps it
|
|
129
|
+
# to BadParameter and the MCP tool to INVALID_PARAMS.
|
|
130
|
+
if effective_limit < 1:
|
|
131
|
+
raise ValueError(f"limit must be an integer >= 1 (got {effective_limit})")
|
|
132
|
+
if effective_min_age < 0:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"min_age_days must be a non-negative integer (got {effective_min_age})"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# WHERE composition mirrors queries.list_documents: static SQL fragments
|
|
138
|
+
# joined with " AND ", every user value bound via a %s placeholder.
|
|
139
|
+
where = [
|
|
140
|
+
"d.draft IS NOT TRUE",
|
|
141
|
+
"d.content_type <> ALL(%s)",
|
|
142
|
+
"coalesce(d.sent_at, d.ingested_at) IS NOT NULL",
|
|
143
|
+
"coalesce(d.sent_at, d.ingested_at) < now() - make_interval(days => %s)",
|
|
144
|
+
]
|
|
145
|
+
params: list[Any] = [list(_EXCLUDED_CONTENT_TYPES), effective_min_age]
|
|
146
|
+
if source_kind:
|
|
147
|
+
where.append("s.kind = %s")
|
|
148
|
+
params.append(source_kind)
|
|
149
|
+
|
|
150
|
+
query = sql.SQL(
|
|
151
|
+
"""
|
|
152
|
+
SELECT
|
|
153
|
+
d.id::text,
|
|
154
|
+
d.title,
|
|
155
|
+
d.content_type,
|
|
156
|
+
d.tags,
|
|
157
|
+
s.kind AS source_kind,
|
|
158
|
+
left(d.content, {snippet_chars}) AS snippet,
|
|
159
|
+
array_length(d.tags, 1) AS tag_count,
|
|
160
|
+
(d.summary IS NOT NULL) AS has_summary,
|
|
161
|
+
EXTRACT(
|
|
162
|
+
EPOCH FROM (now() - coalesce(d.sent_at, d.ingested_at))
|
|
163
|
+
) / 86400.0 AS age_days,
|
|
164
|
+
EXTRACT(EPOCH FROM (now() - MAX(i.at))) / 86400.0 AS last_access_days
|
|
165
|
+
FROM documents d
|
|
166
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
167
|
+
LEFT JOIN interactions i
|
|
168
|
+
ON i.document_id = d.id
|
|
169
|
+
AND i.action IN ('opened', 'clicked')
|
|
170
|
+
WHERE {where}
|
|
171
|
+
GROUP BY d.id, s.kind
|
|
172
|
+
"""
|
|
173
|
+
).format(
|
|
174
|
+
snippet_chars=sql.Literal(_SNIPPET_CHARS),
|
|
175
|
+
where=sql.SQL(" AND ").join(sql.SQL(w) for w in where),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
rows = conn.execute(query, params).fetchall()
|
|
179
|
+
|
|
180
|
+
items: list[ResurfaceItem] = []
|
|
181
|
+
for r in rows:
|
|
182
|
+
age_days = float(r[8])
|
|
183
|
+
last_access_days = float(r[9]) if r[9] is not None else None
|
|
184
|
+
tag_count = int(r[6]) if r[6] is not None else None
|
|
185
|
+
has_summary = bool(r[7])
|
|
186
|
+
score = score_document(
|
|
187
|
+
age_days=age_days,
|
|
188
|
+
last_access_days=last_access_days,
|
|
189
|
+
tag_count=tag_count,
|
|
190
|
+
has_summary=has_summary,
|
|
191
|
+
age_halflife_days=cfg.resurface_age_halflife_days,
|
|
192
|
+
access_halflife_days=cfg.resurface_access_halflife_days,
|
|
193
|
+
)
|
|
194
|
+
items.append(
|
|
195
|
+
ResurfaceItem(
|
|
196
|
+
id=r[0],
|
|
197
|
+
title=r[1],
|
|
198
|
+
source_kind=r[4],
|
|
199
|
+
content_type=r[2],
|
|
200
|
+
tags=list(r[3] or []),
|
|
201
|
+
age_days=age_days,
|
|
202
|
+
last_access_days=last_access_days,
|
|
203
|
+
score=score,
|
|
204
|
+
snippet=r[5] or "",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Highest score first; ties resolve to older-first then id for determinism.
|
|
209
|
+
items.sort(key=lambda it: (-it.score, -it.age_days, it.id))
|
|
210
|
+
return items[:effective_limit]
|
brain/review/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""``brain review`` — periodic synthesis over the corpus.
|
|
2
|
+
|
|
3
|
+
Plan 10 contributes the ``weekly`` leg (this package's :mod:`weekly` /
|
|
4
|
+
:mod:`render` / :mod:`emit`). Plan 03 adds the ``scan`` / ``list`` / ``dismiss``
|
|
5
|
+
leg (:mod:`scans` + :mod:`queries`) for contradiction + staleness detection.
|
|
6
|
+
The package is the shared home for the unified ``brain review`` subcommand tree.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .emit import emit_weekly_page
|
|
11
|
+
from .render import render_weekly_json, render_weekly_md, render_weekly_rich
|
|
12
|
+
from .scans import ReviewFinding, run_conflict_scan, run_staleness_scan
|
|
13
|
+
from .weekly import ThemeBlock, WeeklyReport, build_weekly_report
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ReviewFinding",
|
|
17
|
+
"ThemeBlock",
|
|
18
|
+
"WeeklyReport",
|
|
19
|
+
"build_weekly_report",
|
|
20
|
+
"emit_weekly_page",
|
|
21
|
+
"render_weekly_json",
|
|
22
|
+
"render_weekly_md",
|
|
23
|
+
"render_weekly_rich",
|
|
24
|
+
"run_conflict_scan",
|
|
25
|
+
"run_staleness_scan",
|
|
26
|
+
]
|
brain/review/emit.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Vault I/O for weekly review pages.
|
|
2
|
+
|
|
3
|
+
One responsibility: where the page lands on disk and how it is written. The
|
|
4
|
+
page is rendered by :func:`brain.review.render.render_weekly_md` and written
|
|
5
|
+
atomically (sibling tempfile + ``os.replace``), so a re-run for the same week
|
|
6
|
+
overwrites byte-for-byte — idempotent.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..vault._atomic import atomic_write_text
|
|
13
|
+
from .render import render_weekly_md
|
|
14
|
+
from .weekly import WeeklyReport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_weekly_page(vault_root: Path, report: WeeklyReport) -> Path:
|
|
18
|
+
"""Write the weekly review page to ``<vault_root>/reviews/<week>.md``.
|
|
19
|
+
|
|
20
|
+
Creates the ``reviews/`` directory if absent. Returns the written path.
|
|
21
|
+
Re-running with an equal report overwrites atomically with identical bytes
|
|
22
|
+
(idempotent), because :func:`render_weekly_md` is deterministic.
|
|
23
|
+
"""
|
|
24
|
+
target = vault_root / "reviews" / f"{report.week}.md"
|
|
25
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
atomic_write_text(target, render_weekly_md(report))
|
|
27
|
+
return target
|