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/review/weekly.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Weekly-review orchestrator: assemble a :class:`WeeklyReport` from the DB.
|
|
2
|
+
|
|
3
|
+
This module owns *which* sections are assembled and *how* themes are ranked —
|
|
4
|
+
nothing about rendering or vault I/O (those live in ``render`` / ``emit``). It
|
|
5
|
+
reads only existing tables (no new migration): ``interactions`` / ``documents``
|
|
6
|
+
for activity + ingest signal (via :mod:`brain.activity`), ``krisp_action_items``
|
|
7
|
+
docs for open loops (via :mod:`brain.todo`), and the relational graph tables
|
|
8
|
+
(``graph_communities`` / ``graph_community_members`` / ``graph_edge_contributions``
|
|
9
|
+
/ ``graph_entities`` / ``graph_entity_mentions``) for the theme clusters.
|
|
10
|
+
|
|
11
|
+
The theme leg has two paths:
|
|
12
|
+
|
|
13
|
+
* **Graph path** (``cfg.graph_enabled and not no_graph``): rank the tenant's
|
|
14
|
+
communities by in-window co-occurrence weight, then attach top entity names,
|
|
15
|
+
representative in-window doc titles, and a best-effort LLM synthesis. These
|
|
16
|
+
reads hit the relational source-of-truth tables (always present after the
|
|
17
|
+
graph migrations) — NOT Apache AGE — so the path is safe on stock pgvector and
|
|
18
|
+
simply yields no themes when the graph has not been built.
|
|
19
|
+
* **Fallback path** (graph disabled, ``--no-graph``, or zero active communities):
|
|
20
|
+
cluster the activity docs by their most-frequent tag. No LLM synthesis.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import date, datetime
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import psycopg
|
|
29
|
+
|
|
30
|
+
from ..activity import (
|
|
31
|
+
ActivityDoc,
|
|
32
|
+
IngestedDoc,
|
|
33
|
+
iter_activity_docs,
|
|
34
|
+
iter_ingested_docs,
|
|
35
|
+
week_bounds,
|
|
36
|
+
)
|
|
37
|
+
from ..config import Config
|
|
38
|
+
from ..enrichment import OllamaEnricher
|
|
39
|
+
from ..todo import TodoRow, iter_action_item_docs
|
|
40
|
+
from ..wiki.build_people import _doc_participant_keys
|
|
41
|
+
|
|
42
|
+
# Top entity names / representative doc titles attached per graph theme block.
|
|
43
|
+
_THEME_ENTITY_CAP = 3
|
|
44
|
+
_THEME_DOC_CAP = 3
|
|
45
|
+
# Canonical key-people count surfaced in a weekly report.
|
|
46
|
+
_KEY_PEOPLE_CAP = 5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ThemeBlock:
|
|
51
|
+
"""One synthesized theme cluster for the week.
|
|
52
|
+
|
|
53
|
+
``key`` is the ``community_key`` (graph path) or the tag name (fallback).
|
|
54
|
+
``entity_names`` are the cluster's top entities (graph) or ``[tag]``
|
|
55
|
+
(fallback). ``docs`` are ``(document_id, title)`` pairs for representative
|
|
56
|
+
in-window documents. ``synthesis`` is the best-effort LLM one-liner (graph
|
|
57
|
+
path only; ``None`` on the fallback path or when Ollama is unavailable).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
key: str
|
|
61
|
+
entity_names: list[str]
|
|
62
|
+
docs: list[tuple[str, str]]
|
|
63
|
+
synthesis: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class WeeklyReport:
|
|
68
|
+
"""Everything the renderer needs for one weekly review page.
|
|
69
|
+
|
|
70
|
+
``vault_paths`` maps ``document_id`` → ``documents.vault_path`` for every
|
|
71
|
+
referenced doc that has one, so the renderer can emit wiki-links without
|
|
72
|
+
re-querying (docs without a vault path render as plain titles).
|
|
73
|
+
``graph_used`` records whether the theme leg took the graph path.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
week: str
|
|
77
|
+
start_date: date
|
|
78
|
+
end_date: date
|
|
79
|
+
generated_date: date
|
|
80
|
+
themes: list[ThemeBlock]
|
|
81
|
+
activity: list[ActivityDoc]
|
|
82
|
+
open_loops: list[TodoRow]
|
|
83
|
+
ingested: list[IngestedDoc]
|
|
84
|
+
key_people: list[str]
|
|
85
|
+
graph_used: bool
|
|
86
|
+
vault_paths: dict[str, str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_weekly_report(
|
|
90
|
+
conn: psycopg.Connection[Any],
|
|
91
|
+
cfg: Config,
|
|
92
|
+
*,
|
|
93
|
+
week: str,
|
|
94
|
+
generated_on: date,
|
|
95
|
+
no_graph: bool = False,
|
|
96
|
+
enricher: OllamaEnricher | None = None,
|
|
97
|
+
) -> WeeklyReport:
|
|
98
|
+
"""Assemble the :class:`WeeklyReport` for ``week`` (``"YYYY-Www"``).
|
|
99
|
+
|
|
100
|
+
``generated_on`` is the page's ``date:`` stamp (the caller passes today so
|
|
101
|
+
this function stays free of wall-clock reads and is deterministic under
|
|
102
|
+
test). ``no_graph`` forces the tag-cluster fallback. ``enricher`` enables
|
|
103
|
+
best-effort theme synthesis on the graph path; when ``None`` the theme
|
|
104
|
+
blocks carry entity/doc names without a synthesis sentence.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: ``week`` is not a valid ``"YYYY-Www"`` string (from
|
|
108
|
+
:func:`brain.activity.week_bounds`).
|
|
109
|
+
"""
|
|
110
|
+
after, before = week_bounds(week)
|
|
111
|
+
|
|
112
|
+
activity = iter_activity_docs(
|
|
113
|
+
conn, after=after, before=before, limit=cfg.review_activity_limit
|
|
114
|
+
)
|
|
115
|
+
ingested = iter_ingested_docs(
|
|
116
|
+
conn, after=after, before=before, limit=cfg.review_activity_limit
|
|
117
|
+
)
|
|
118
|
+
# Open loops are scoped to the TARGET week, not the last 7 days from NOW():
|
|
119
|
+
# ``iter_action_item_docs(since_days=...)`` is NOW()-relative (todo.py), which
|
|
120
|
+
# would surface the current week's loops for a past --week. Pull all open
|
|
121
|
+
# action items and filter by the requested window so a past-week retrospective
|
|
122
|
+
# shows that week's loops.
|
|
123
|
+
open_loops = [
|
|
124
|
+
row
|
|
125
|
+
for row in iter_action_item_docs(conn, include_closed=False)
|
|
126
|
+
if row.ingested_at is not None and after <= row.ingested_at <= before
|
|
127
|
+
][: cfg.review_open_loop_limit]
|
|
128
|
+
|
|
129
|
+
use_graph = cfg.graph_enabled and not no_graph
|
|
130
|
+
themes: list[ThemeBlock] = []
|
|
131
|
+
if use_graph:
|
|
132
|
+
themes = _graph_themes(
|
|
133
|
+
conn,
|
|
134
|
+
tenant_id=cfg.graph_tenant_id,
|
|
135
|
+
after=after,
|
|
136
|
+
before=before,
|
|
137
|
+
theme_limit=cfg.review_theme_limit,
|
|
138
|
+
enricher=enricher,
|
|
139
|
+
)
|
|
140
|
+
graph_used = use_graph and bool(themes)
|
|
141
|
+
if not themes:
|
|
142
|
+
themes = _tag_cluster_themes(activity, theme_limit=cfg.review_theme_limit)
|
|
143
|
+
|
|
144
|
+
key_people = _key_people(conn, activity, cap=_KEY_PEOPLE_CAP)
|
|
145
|
+
|
|
146
|
+
vault_paths = _collect_vault_paths(conn, activity, ingested, open_loops, themes)
|
|
147
|
+
|
|
148
|
+
return WeeklyReport(
|
|
149
|
+
week=week,
|
|
150
|
+
start_date=after.date(),
|
|
151
|
+
end_date=before.date(),
|
|
152
|
+
generated_date=generated_on,
|
|
153
|
+
themes=themes,
|
|
154
|
+
activity=activity,
|
|
155
|
+
open_loops=open_loops,
|
|
156
|
+
ingested=ingested,
|
|
157
|
+
key_people=key_people,
|
|
158
|
+
graph_used=graph_used,
|
|
159
|
+
vault_paths=vault_paths,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def weekly_active_communities(
|
|
164
|
+
conn: psycopg.Connection[Any],
|
|
165
|
+
*,
|
|
166
|
+
tenant_id: str,
|
|
167
|
+
after: datetime,
|
|
168
|
+
before: datetime,
|
|
169
|
+
theme_limit: int,
|
|
170
|
+
) -> list[tuple[str, int]]:
|
|
171
|
+
"""Return ``(community_key, weekly_weight)`` for in-window-active communities.
|
|
172
|
+
|
|
173
|
+
Sums ``graph_edge_contributions.cooccur_count`` for edges whose source
|
|
174
|
+
document was ingested in ``[after, before]``, grouped by the community each
|
|
175
|
+
edge endpoint belongs to. Tenant-scoped on both the edge and the membership
|
|
176
|
+
join. Returns ``[]`` when the graph has no in-window activity (no crash) —
|
|
177
|
+
the caller then falls back to tag clusters.
|
|
178
|
+
|
|
179
|
+
The membership join matches an edge once per endpoint that belongs to a
|
|
180
|
+
community, so an **intra-community edge** (both endpoints in the same
|
|
181
|
+
community) matches that community *twice*. Summing directly would
|
|
182
|
+
double-count its weight. The inner ``SELECT DISTINCT`` collapses each
|
|
183
|
+
``(community, edge)`` pair to one row before the ``SUM`` so an intra-community
|
|
184
|
+
edge contributes its weight once; a cross-community edge still attributes its
|
|
185
|
+
weight to each endpoint's distinct community exactly once.
|
|
186
|
+
"""
|
|
187
|
+
rows = conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
SELECT community_key, SUM(cooccur_count) AS weekly_weight
|
|
190
|
+
FROM (
|
|
191
|
+
SELECT DISTINCT
|
|
192
|
+
gcm.community_key::text AS community_key,
|
|
193
|
+
gec.document_id,
|
|
194
|
+
gec.src_id,
|
|
195
|
+
gec.dst_id,
|
|
196
|
+
gec.cooccur_count
|
|
197
|
+
FROM graph_edge_contributions gec
|
|
198
|
+
JOIN graph_community_members gcm
|
|
199
|
+
ON gcm.tenant_id = gec.tenant_id
|
|
200
|
+
AND (gcm.entity_id = gec.src_id OR gcm.entity_id = gec.dst_id)
|
|
201
|
+
JOIN documents d ON d.id = gec.document_id
|
|
202
|
+
WHERE gec.tenant_id = %s
|
|
203
|
+
AND d.ingested_at BETWEEN %s AND %s
|
|
204
|
+
) AS edge_community
|
|
205
|
+
GROUP BY community_key
|
|
206
|
+
ORDER BY weekly_weight DESC, community_key
|
|
207
|
+
LIMIT %s
|
|
208
|
+
""",
|
|
209
|
+
(tenant_id, after, before, theme_limit),
|
|
210
|
+
).fetchall()
|
|
211
|
+
return [(str(r[0]), int(r[1])) for r in rows]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _graph_themes(
|
|
215
|
+
conn: psycopg.Connection[Any],
|
|
216
|
+
*,
|
|
217
|
+
tenant_id: str,
|
|
218
|
+
after: datetime,
|
|
219
|
+
before: datetime,
|
|
220
|
+
theme_limit: int,
|
|
221
|
+
enricher: OllamaEnricher | None,
|
|
222
|
+
) -> list[ThemeBlock]:
|
|
223
|
+
"""Build theme blocks from the in-window-active communities (graph path)."""
|
|
224
|
+
blocks: list[ThemeBlock] = []
|
|
225
|
+
for community_key, _weight in weekly_active_communities(
|
|
226
|
+
conn,
|
|
227
|
+
tenant_id=tenant_id,
|
|
228
|
+
after=after,
|
|
229
|
+
before=before,
|
|
230
|
+
theme_limit=theme_limit,
|
|
231
|
+
):
|
|
232
|
+
entity_names = _community_entity_names(
|
|
233
|
+
conn, tenant_id=tenant_id, community_key=community_key
|
|
234
|
+
)
|
|
235
|
+
docs = _community_window_docs(
|
|
236
|
+
conn,
|
|
237
|
+
tenant_id=tenant_id,
|
|
238
|
+
community_key=community_key,
|
|
239
|
+
after=after,
|
|
240
|
+
before=before,
|
|
241
|
+
)
|
|
242
|
+
synthesis = _community_summary(
|
|
243
|
+
conn, tenant_id=tenant_id, community_key=community_key
|
|
244
|
+
)
|
|
245
|
+
if synthesis is None and enricher is not None and entity_names:
|
|
246
|
+
# Best-effort — summarize_group never raises (logs + returns None
|
|
247
|
+
# when Ollama is unavailable).
|
|
248
|
+
synthesis = enricher.summarize_group(
|
|
249
|
+
person=None,
|
|
250
|
+
entity_names=entity_names,
|
|
251
|
+
doc_titles=[title for _id, title in docs],
|
|
252
|
+
)
|
|
253
|
+
blocks.append(
|
|
254
|
+
ThemeBlock(
|
|
255
|
+
key=community_key,
|
|
256
|
+
entity_names=entity_names,
|
|
257
|
+
docs=docs,
|
|
258
|
+
synthesis=synthesis,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
return blocks
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _community_entity_names(
|
|
265
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, community_key: str
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""Top entity names for a community (by member rank then weight)."""
|
|
268
|
+
rows = conn.execute(
|
|
269
|
+
"""
|
|
270
|
+
SELECT ge.name
|
|
271
|
+
FROM graph_community_members gcm
|
|
272
|
+
JOIN graph_entities ge
|
|
273
|
+
ON ge.tenant_id = gcm.tenant_id AND ge.id = gcm.entity_id
|
|
274
|
+
WHERE gcm.tenant_id = %s AND gcm.community_key = %s
|
|
275
|
+
ORDER BY gcm.member_rank, gcm.member_weight DESC, ge.name
|
|
276
|
+
LIMIT %s
|
|
277
|
+
""",
|
|
278
|
+
(tenant_id, community_key, _THEME_ENTITY_CAP),
|
|
279
|
+
).fetchall()
|
|
280
|
+
return [str(r[0]) for r in rows]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _community_window_docs(
|
|
284
|
+
conn: psycopg.Connection[Any],
|
|
285
|
+
*,
|
|
286
|
+
tenant_id: str,
|
|
287
|
+
community_key: str,
|
|
288
|
+
after: datetime,
|
|
289
|
+
before: datetime,
|
|
290
|
+
) -> list[tuple[str, str]]:
|
|
291
|
+
"""Representative ``(id, title)`` docs in-window for a community's entities."""
|
|
292
|
+
rows = conn.execute(
|
|
293
|
+
"""
|
|
294
|
+
SELECT d.id::text, d.title
|
|
295
|
+
FROM documents d
|
|
296
|
+
WHERE d.ingested_at BETWEEN %s AND %s
|
|
297
|
+
AND d.id IN (
|
|
298
|
+
SELECT gem.document_id
|
|
299
|
+
FROM graph_entity_mentions gem
|
|
300
|
+
JOIN graph_community_members gcm
|
|
301
|
+
ON gcm.tenant_id = gem.tenant_id
|
|
302
|
+
AND gcm.entity_id = gem.entity_id
|
|
303
|
+
WHERE gcm.tenant_id = %s AND gcm.community_key = %s
|
|
304
|
+
)
|
|
305
|
+
ORDER BY d.ingested_at DESC, d.id
|
|
306
|
+
LIMIT %s
|
|
307
|
+
""",
|
|
308
|
+
(after, before, tenant_id, community_key, _THEME_DOC_CAP),
|
|
309
|
+
).fetchall()
|
|
310
|
+
return [(str(r[0]), str(r[1])) for r in rows]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _community_summary(
|
|
314
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, community_key: str
|
|
315
|
+
) -> str | None:
|
|
316
|
+
"""Return the stored community summary, or ``None`` if not yet materialized."""
|
|
317
|
+
row = conn.execute(
|
|
318
|
+
"SELECT summary FROM graph_communities "
|
|
319
|
+
"WHERE tenant_id = %s AND community_key = %s",
|
|
320
|
+
(tenant_id, community_key),
|
|
321
|
+
).fetchone()
|
|
322
|
+
if row is None or row[0] is None:
|
|
323
|
+
return None
|
|
324
|
+
summary = str(row[0]).strip()
|
|
325
|
+
return summary or None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _tag_cluster_themes(
|
|
329
|
+
activity: list[ActivityDoc], *, theme_limit: int
|
|
330
|
+
) -> list[ThemeBlock]:
|
|
331
|
+
"""Fallback theme leg: cluster activity docs by their most-frequent tag.
|
|
332
|
+
|
|
333
|
+
Each doc is bucketed under its first tag (docs with no tags are skipped).
|
|
334
|
+
Buckets are ranked by document count (then tag name) and capped at
|
|
335
|
+
``theme_limit``. No LLM synthesis on this path.
|
|
336
|
+
"""
|
|
337
|
+
buckets: dict[str, list[tuple[str, str]]] = {}
|
|
338
|
+
for doc in activity:
|
|
339
|
+
if not doc.tags:
|
|
340
|
+
continue
|
|
341
|
+
tag = doc.tags[0]
|
|
342
|
+
buckets.setdefault(tag, []).append((doc.document_id, doc.title))
|
|
343
|
+
ranked = sorted(buckets.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
|
344
|
+
return [
|
|
345
|
+
ThemeBlock(
|
|
346
|
+
key=tag,
|
|
347
|
+
entity_names=[tag],
|
|
348
|
+
docs=docs[:_THEME_DOC_CAP],
|
|
349
|
+
synthesis=None,
|
|
350
|
+
)
|
|
351
|
+
for tag, docs in ranked[:theme_limit]
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _key_people(
|
|
356
|
+
conn: psycopg.Connection[Any], activity: list[ActivityDoc], *, cap: int
|
|
357
|
+
) -> list[str]:
|
|
358
|
+
"""Tally participant keys across the activity docs; return the top ``cap``.
|
|
359
|
+
|
|
360
|
+
Reuses :func:`brain.wiki.build_people._doc_participant_keys` (imported, not
|
|
361
|
+
copy-pasted) to extract each doc's raw participant keys from its
|
|
362
|
+
``metadata`` + joined ``sources.kind``. Keys are tallied by frequency
|
|
363
|
+
(ties broken alphabetically) and de-duplicated.
|
|
364
|
+
"""
|
|
365
|
+
if not activity:
|
|
366
|
+
return []
|
|
367
|
+
doc_ids = [doc.document_id for doc in activity]
|
|
368
|
+
rows = conn.execute(
|
|
369
|
+
"""
|
|
370
|
+
SELECT d.id::text, s.kind, d.metadata
|
|
371
|
+
FROM documents d
|
|
372
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
373
|
+
WHERE d.id = ANY(%s)
|
|
374
|
+
""",
|
|
375
|
+
(doc_ids,),
|
|
376
|
+
).fetchall()
|
|
377
|
+
counts: dict[str, int] = {}
|
|
378
|
+
for _doc_id, source_kind, metadata in rows:
|
|
379
|
+
meta: dict[str, Any] = dict(metadata) if metadata else {}
|
|
380
|
+
keys = _doc_participant_keys(source_kind=source_kind or "", metadata=meta)
|
|
381
|
+
for key in keys:
|
|
382
|
+
counts[key] = counts.get(key, 0) + 1
|
|
383
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
384
|
+
return [key for key, _count in ranked[:cap]]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _collect_vault_paths(
|
|
388
|
+
conn: psycopg.Connection[Any],
|
|
389
|
+
activity: list[ActivityDoc],
|
|
390
|
+
ingested: list[IngestedDoc],
|
|
391
|
+
open_loops: list[TodoRow],
|
|
392
|
+
themes: list[ThemeBlock],
|
|
393
|
+
) -> dict[str, str]:
|
|
394
|
+
"""Map ``document_id`` → ``vault_path`` for every referenced doc that has one.
|
|
395
|
+
|
|
396
|
+
A single batched lookup over the union of all referenced ids so the renderer
|
|
397
|
+
can emit wiki-links (``[[<vault-path>|<title>]]``) for the docs that are
|
|
398
|
+
mirrored to the vault, and plain titles for those that are not.
|
|
399
|
+
"""
|
|
400
|
+
ids: set[str] = set()
|
|
401
|
+
ids.update(doc.document_id for doc in activity)
|
|
402
|
+
ids.update(doc.document_id for doc in ingested)
|
|
403
|
+
ids.update(row.document_id for row in open_loops)
|
|
404
|
+
for block in themes:
|
|
405
|
+
ids.update(doc_id for doc_id, _title in block.docs)
|
|
406
|
+
if not ids:
|
|
407
|
+
return {}
|
|
408
|
+
rows = conn.execute(
|
|
409
|
+
"SELECT id::text, vault_path FROM documents "
|
|
410
|
+
"WHERE id = ANY(%s) AND vault_path IS NOT NULL",
|
|
411
|
+
(list(ids),),
|
|
412
|
+
).fetchall()
|
|
413
|
+
return {str(r[0]): str(r[1]) for r in rows}
|