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/queries.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""SQL helpers for ``brain review scan`` (Plan 03 — contradiction + staleness).
|
|
2
|
+
|
|
3
|
+
One reason to change: the shape of ``elicitation_gaps`` or the GraphRAG tables
|
|
4
|
+
this module reads (``graph_entities`` / ``graph_entity_mentions`` /
|
|
5
|
+
``documents`` / ``chunks``). All queries are parameterized and tenant-scoped
|
|
6
|
+
where a tenant column exists. The pure scan logic lives in :mod:`brain.review.scans`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
# Review-finding signal kinds owned by this surface. ``brain review list`` /
|
|
17
|
+
# ``dismiss`` only ever touch these two; the other ``elicitation_gaps`` kinds
|
|
18
|
+
# (delta / orphan / contradiction-from-elicit / user_flagged) belong to
|
|
19
|
+
# ``brain elicit``.
|
|
20
|
+
REVIEW_SIGNAL_KINDS: tuple[str, ...] = ("contradiction", "stale")
|
|
21
|
+
|
|
22
|
+
# Document content types that are historical records, not living claims — never
|
|
23
|
+
# eligible as staleness candidates (a transcript of a past meeting does not go
|
|
24
|
+
# "stale" the way a compensation-range note does).
|
|
25
|
+
_STALE_EXCLUDED_CONTENT_TYPES: tuple[str, ...] = ("transcript", "krisp_action_items")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class EntityCandidate:
|
|
30
|
+
"""One entity surviving the conflict-scan graph prefilter (Step 1)."""
|
|
31
|
+
|
|
32
|
+
canonical_key: str
|
|
33
|
+
name: str
|
|
34
|
+
entity_type: str
|
|
35
|
+
doc_ids: list[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class StaleCandidate:
|
|
40
|
+
"""One aged document eligible for the staleness scan (Step 1)."""
|
|
41
|
+
|
|
42
|
+
doc_id: str
|
|
43
|
+
title: str
|
|
44
|
+
age_days: int
|
|
45
|
+
content_type: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SupersedingDoc:
|
|
50
|
+
"""A newer document sharing an entity with a stale candidate (Step 2)."""
|
|
51
|
+
|
|
52
|
+
doc_id: str
|
|
53
|
+
title: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class QueueRow:
|
|
58
|
+
"""One row of the ``brain review list`` queue read-back."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
signal_kind: str
|
|
62
|
+
target_type: str
|
|
63
|
+
target_id: str
|
|
64
|
+
score: float
|
|
65
|
+
evidence_ids: list[str]
|
|
66
|
+
rationale: str
|
|
67
|
+
status: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def iter_entities_for_conflict_scan(
|
|
71
|
+
conn: psycopg.Connection[Any],
|
|
72
|
+
*,
|
|
73
|
+
tenant_id: str,
|
|
74
|
+
min_docs: int,
|
|
75
|
+
limit: int,
|
|
76
|
+
) -> list[EntityCandidate]:
|
|
77
|
+
"""Graph prefilter: entities mentioned in >= ``min_docs`` summarized docs.
|
|
78
|
+
|
|
79
|
+
Only non-draft documents with a non-null ``summary`` count toward the
|
|
80
|
+
threshold (the summary is the evidence text fed to the LLM). Ordered by
|
|
81
|
+
distinct-doc count DESC and capped at ``limit`` so the LLM-call budget is
|
|
82
|
+
bounded. Pure SQL — no LLM, no embedder.
|
|
83
|
+
"""
|
|
84
|
+
rows = conn.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT ge.canonical_key, ge.name, ge.entity_type,
|
|
87
|
+
array_agg(DISTINCT gem.document_id::text) AS doc_ids
|
|
88
|
+
FROM graph_entity_mentions gem
|
|
89
|
+
JOIN graph_entities ge
|
|
90
|
+
ON ge.id = gem.entity_id AND ge.tenant_id = gem.tenant_id
|
|
91
|
+
JOIN documents d ON d.id = gem.document_id
|
|
92
|
+
WHERE gem.tenant_id = %s
|
|
93
|
+
AND d.summary IS NOT NULL
|
|
94
|
+
AND d.draft IS NOT TRUE
|
|
95
|
+
GROUP BY ge.id, ge.canonical_key, ge.name, ge.entity_type
|
|
96
|
+
HAVING count(DISTINCT gem.document_id) >= %s
|
|
97
|
+
ORDER BY count(DISTINCT gem.document_id) DESC
|
|
98
|
+
LIMIT %s
|
|
99
|
+
""",
|
|
100
|
+
(tenant_id, min_docs, limit),
|
|
101
|
+
).fetchall()
|
|
102
|
+
return [
|
|
103
|
+
EntityCandidate(
|
|
104
|
+
canonical_key=ckey,
|
|
105
|
+
name=name,
|
|
106
|
+
entity_type=etype,
|
|
107
|
+
doc_ids=list(doc_ids),
|
|
108
|
+
)
|
|
109
|
+
for (ckey, name, etype, doc_ids) in rows
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def count_conflict_docs_missing_summary(
|
|
114
|
+
conn: psycopg.Connection[Any], *, tenant_id: str
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Count non-draft docs referenced by an entity that still lack a summary.
|
|
117
|
+
|
|
118
|
+
Surfaced by ``brain review scan --conflicts --dry-run`` to nudge the user
|
|
119
|
+
toward ``brain enrich --backfill`` — these documents are silently excluded
|
|
120
|
+
from the graph prefilter above.
|
|
121
|
+
"""
|
|
122
|
+
row = conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
SELECT count(DISTINCT d.id)
|
|
125
|
+
FROM graph_entity_mentions gem
|
|
126
|
+
JOIN documents d ON d.id = gem.document_id
|
|
127
|
+
WHERE gem.tenant_id = %s
|
|
128
|
+
AND d.draft IS NOT TRUE
|
|
129
|
+
AND d.summary IS NULL
|
|
130
|
+
""",
|
|
131
|
+
(tenant_id,),
|
|
132
|
+
).fetchone()
|
|
133
|
+
return int(row[0]) if row else 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def iter_docs_for_staleness_scan(
|
|
137
|
+
conn: psycopg.Connection[Any],
|
|
138
|
+
*,
|
|
139
|
+
tenant_id: str,
|
|
140
|
+
stale_age_days: int,
|
|
141
|
+
limit: int,
|
|
142
|
+
) -> list[StaleCandidate]:
|
|
143
|
+
"""Age candidates: non-draft, summarized, non-transcript docs older than N days.
|
|
144
|
+
|
|
145
|
+
``documents`` has no tenant column, so the scan is tenant-scoped via an
|
|
146
|
+
``EXISTS`` on ``graph_entity_mentions``: only docs that participate in this
|
|
147
|
+
tenant's entity graph are candidates. That is exactly the set that could
|
|
148
|
+
ever yield a finding — the superseding-doc lookup (Step 2) requires a
|
|
149
|
+
tenant-shared entity — so the ``EXISTS`` both enforces tenant isolation and
|
|
150
|
+
skips docs that can never supersede. Oldest first; capped at ``limit``.
|
|
151
|
+
"""
|
|
152
|
+
rows = conn.execute(
|
|
153
|
+
"""
|
|
154
|
+
SELECT d.id::text, d.title,
|
|
155
|
+
(now()::date - d.ingested_at::date) AS age_days,
|
|
156
|
+
d.content_type
|
|
157
|
+
FROM documents d
|
|
158
|
+
WHERE d.ingested_at < now() - make_interval(days => %s)
|
|
159
|
+
AND d.content_type <> ALL(%s)
|
|
160
|
+
AND d.draft IS NOT TRUE
|
|
161
|
+
AND d.summary IS NOT NULL
|
|
162
|
+
AND EXISTS (
|
|
163
|
+
SELECT 1 FROM graph_entity_mentions gem
|
|
164
|
+
WHERE gem.document_id = d.id AND gem.tenant_id = %s
|
|
165
|
+
)
|
|
166
|
+
ORDER BY d.ingested_at ASC
|
|
167
|
+
LIMIT %s
|
|
168
|
+
""",
|
|
169
|
+
(stale_age_days, list(_STALE_EXCLUDED_CONTENT_TYPES), tenant_id, limit),
|
|
170
|
+
).fetchall()
|
|
171
|
+
return [
|
|
172
|
+
StaleCandidate(
|
|
173
|
+
doc_id=doc_id,
|
|
174
|
+
title=title,
|
|
175
|
+
age_days=int(age_days),
|
|
176
|
+
content_type=content_type,
|
|
177
|
+
)
|
|
178
|
+
for (doc_id, title, age_days, content_type) in rows
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def count_stale_docs_missing_summary(
|
|
183
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, stale_age_days: int
|
|
184
|
+
) -> int:
|
|
185
|
+
"""Count aged non-draft, non-transcript docs that still lack a summary.
|
|
186
|
+
|
|
187
|
+
Tenant-scoped via the same ``graph_entity_mentions`` ``EXISTS`` as
|
|
188
|
+
:func:`iter_docs_for_staleness_scan`, so the
|
|
189
|
+
``brain review scan --stale --dry-run`` nudge counts only docs in the active
|
|
190
|
+
tenant's graph that never enter the staleness pipeline for want of a summary.
|
|
191
|
+
"""
|
|
192
|
+
row = conn.execute(
|
|
193
|
+
"""
|
|
194
|
+
SELECT count(*)
|
|
195
|
+
FROM documents d
|
|
196
|
+
WHERE d.ingested_at < now() - make_interval(days => %s)
|
|
197
|
+
AND d.content_type <> ALL(%s)
|
|
198
|
+
AND d.draft IS NOT TRUE
|
|
199
|
+
AND d.summary IS NULL
|
|
200
|
+
AND EXISTS (
|
|
201
|
+
SELECT 1 FROM graph_entity_mentions gem
|
|
202
|
+
WHERE gem.document_id = d.id AND gem.tenant_id = %s
|
|
203
|
+
)
|
|
204
|
+
""",
|
|
205
|
+
(stale_age_days, list(_STALE_EXCLUDED_CONTENT_TYPES), tenant_id),
|
|
206
|
+
).fetchone()
|
|
207
|
+
return int(row[0]) if row else 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fetch_superseding_docs(
|
|
211
|
+
conn: psycopg.Connection[Any],
|
|
212
|
+
*,
|
|
213
|
+
tenant_id: str,
|
|
214
|
+
doc_id: str,
|
|
215
|
+
window_days: int,
|
|
216
|
+
) -> list[SupersedingDoc]:
|
|
217
|
+
"""Newer non-draft docs sharing >= 1 entity with ``doc_id``, ingested recently.
|
|
218
|
+
|
|
219
|
+
A superseding candidate must (a) share at least one graph entity with the
|
|
220
|
+
stale doc, (b) be ingested within ``window_days`` of now, and (c) not be the
|
|
221
|
+
stale doc itself. Tenant-scoped via ``graph_entity_mentions``.
|
|
222
|
+
"""
|
|
223
|
+
rows = conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
SELECT DISTINCT d2.id::text, d2.title
|
|
226
|
+
FROM graph_entity_mentions m1
|
|
227
|
+
JOIN graph_entity_mentions m2
|
|
228
|
+
ON m2.tenant_id = m1.tenant_id AND m2.entity_id = m1.entity_id
|
|
229
|
+
JOIN documents d2 ON d2.id = m2.document_id
|
|
230
|
+
WHERE m1.tenant_id = %s
|
|
231
|
+
AND m1.document_id = %s
|
|
232
|
+
AND m2.document_id <> %s
|
|
233
|
+
AND d2.ingested_at >= now() - make_interval(days => %s)
|
|
234
|
+
AND d2.draft IS NOT TRUE
|
|
235
|
+
""",
|
|
236
|
+
(tenant_id, doc_id, doc_id, window_days),
|
|
237
|
+
).fetchall()
|
|
238
|
+
return [SupersedingDoc(doc_id=did, title=title) for (did, title) in rows]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def fetch_best_chunk_embeddings(
|
|
242
|
+
conn: psycopg.Connection[Any], *, document_ids: Sequence[str]
|
|
243
|
+
) -> dict[str, list[float]]:
|
|
244
|
+
"""Map each document id to its lead-chunk embedding (``chunk_index`` 0).
|
|
245
|
+
|
|
246
|
+
``chunks`` has no ``created_at`` column, so the lead chunk is selected by
|
|
247
|
+
``ORDER BY document_id, chunk_index`` under ``DISTINCT ON (document_id)``.
|
|
248
|
+
Documents whose chunks are all NULL-embedded are absent from the result.
|
|
249
|
+
Vectors are returned as plain ``list[float]`` (the ``pgvector`` adapter
|
|
250
|
+
yields a numpy array; we copy it out so the pure-Python cosine never depends
|
|
251
|
+
on numpy).
|
|
252
|
+
"""
|
|
253
|
+
if not document_ids:
|
|
254
|
+
return {}
|
|
255
|
+
rows = conn.execute(
|
|
256
|
+
"""
|
|
257
|
+
SELECT DISTINCT ON (document_id) document_id::text, embedding
|
|
258
|
+
FROM chunks
|
|
259
|
+
WHERE document_id = ANY(%s::uuid[])
|
|
260
|
+
AND embedding IS NOT NULL
|
|
261
|
+
ORDER BY document_id, chunk_index
|
|
262
|
+
""",
|
|
263
|
+
(list(document_ids),),
|
|
264
|
+
).fetchall()
|
|
265
|
+
return {doc_id: [float(x) for x in vec] for (doc_id, vec) in rows}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def fetch_doc_summaries(
|
|
269
|
+
conn: psycopg.Connection[Any], *, document_ids: Sequence[str]
|
|
270
|
+
) -> dict[str, str]:
|
|
271
|
+
"""Map each document id to its non-null ``summary`` text.
|
|
272
|
+
|
|
273
|
+
Documents with a NULL summary are omitted, so a caller can treat "absent"
|
|
274
|
+
and "no summary" identically. Used to assemble the LLM evidence pair.
|
|
275
|
+
"""
|
|
276
|
+
if not document_ids:
|
|
277
|
+
return {}
|
|
278
|
+
rows = conn.execute(
|
|
279
|
+
"""
|
|
280
|
+
SELECT id::text, summary
|
|
281
|
+
FROM documents
|
|
282
|
+
WHERE id = ANY(%s::uuid[]) AND summary IS NOT NULL
|
|
283
|
+
""",
|
|
284
|
+
(list(document_ids),),
|
|
285
|
+
).fetchall()
|
|
286
|
+
return {doc_id: summary for (doc_id, summary) in rows}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def existing_finding_statuses(
|
|
290
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, signal_kind: str
|
|
291
|
+
) -> dict[str, str]:
|
|
292
|
+
"""Map ``target_id`` -> ``status`` for every non-resolved finding of a kind.
|
|
293
|
+
|
|
294
|
+
Backs the idempotency check (Step 2 of both scans): a target already
|
|
295
|
+
surfaced / snoozed / dismissed is skipped (never re-adjudicated); only a
|
|
296
|
+
``resolved`` target (absent here) or a brand-new target is rescanned. The
|
|
297
|
+
partial unique index on ``elicitation_gaps`` guarantees at most one
|
|
298
|
+
non-resolved row per ``(tenant_id, signal_kind, target_id)``.
|
|
299
|
+
"""
|
|
300
|
+
rows = conn.execute(
|
|
301
|
+
"""
|
|
302
|
+
SELECT target_id, status
|
|
303
|
+
FROM elicitation_gaps
|
|
304
|
+
WHERE tenant_id = %s AND signal_kind = %s AND status <> 'resolved'
|
|
305
|
+
""",
|
|
306
|
+
(tenant_id, signal_kind),
|
|
307
|
+
).fetchall()
|
|
308
|
+
return {target_id: status for (target_id, status) in rows}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def upsert_review_finding(
|
|
312
|
+
conn: psycopg.Connection[Any],
|
|
313
|
+
*,
|
|
314
|
+
tenant_id: str,
|
|
315
|
+
signal_kind: str,
|
|
316
|
+
target_type: str,
|
|
317
|
+
target_id: str,
|
|
318
|
+
score: float,
|
|
319
|
+
evidence_ids: Sequence[str],
|
|
320
|
+
rationale: str,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Upsert one finding, never overwriting a user-dismissed row.
|
|
323
|
+
|
|
324
|
+
The partial unique index ``WHERE status <> 'resolved'`` covers dismissed
|
|
325
|
+
rows, so a naive upsert would clobber them. The ``DO UPDATE ... WHERE
|
|
326
|
+
elicitation_gaps.status IN ('surfaced','snoozed')`` guard detects the
|
|
327
|
+
conflict (dismissed != resolved → in the index) but skips the UPDATE for a
|
|
328
|
+
dismissed row, leaving the user's dismissal intact.
|
|
329
|
+
"""
|
|
330
|
+
conn.execute(
|
|
331
|
+
"""
|
|
332
|
+
INSERT INTO elicitation_gaps
|
|
333
|
+
(tenant_id, signal_kind, target_type, target_id,
|
|
334
|
+
score, evidence_ids, rationale)
|
|
335
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
336
|
+
ON CONFLICT (tenant_id, signal_kind, target_id) WHERE status <> 'resolved'
|
|
337
|
+
DO UPDATE SET
|
|
338
|
+
score = EXCLUDED.score,
|
|
339
|
+
evidence_ids = EXCLUDED.evidence_ids,
|
|
340
|
+
rationale = EXCLUDED.rationale,
|
|
341
|
+
updated_at = now()
|
|
342
|
+
WHERE elicitation_gaps.status IN ('surfaced', 'snoozed')
|
|
343
|
+
""",
|
|
344
|
+
(
|
|
345
|
+
tenant_id,
|
|
346
|
+
signal_kind,
|
|
347
|
+
target_type,
|
|
348
|
+
target_id,
|
|
349
|
+
score,
|
|
350
|
+
list(evidence_ids),
|
|
351
|
+
rationale,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def list_review_queue(
|
|
357
|
+
conn: psycopg.Connection[Any],
|
|
358
|
+
*,
|
|
359
|
+
tenant_id: str,
|
|
360
|
+
signal_kinds: Sequence[str],
|
|
361
|
+
limit: int,
|
|
362
|
+
) -> list[QueueRow]:
|
|
363
|
+
"""Read the open review queue (surfaced / snoozed) for the given kinds.
|
|
364
|
+
|
|
365
|
+
Scoped to ``signal_kinds`` (a subset of :data:`REVIEW_SIGNAL_KINDS`) so the
|
|
366
|
+
review queue stays independent of the ``brain elicit`` gap queue. Ordered by
|
|
367
|
+
score DESC. Snoozed rows whose ``snoozed_until`` is still in the future are
|
|
368
|
+
held back, mirroring ``brain elicit list``.
|
|
369
|
+
"""
|
|
370
|
+
rows = conn.execute(
|
|
371
|
+
"""
|
|
372
|
+
SELECT id::text, signal_kind, target_type, target_id,
|
|
373
|
+
score, evidence_ids, rationale, status
|
|
374
|
+
FROM elicitation_gaps
|
|
375
|
+
WHERE tenant_id = %s
|
|
376
|
+
AND signal_kind = ANY(%s)
|
|
377
|
+
AND status IN ('surfaced', 'snoozed')
|
|
378
|
+
AND (snoozed_until IS NULL OR snoozed_until < now())
|
|
379
|
+
ORDER BY score DESC
|
|
380
|
+
LIMIT %s
|
|
381
|
+
""",
|
|
382
|
+
(tenant_id, list(signal_kinds), limit),
|
|
383
|
+
).fetchall()
|
|
384
|
+
return [
|
|
385
|
+
QueueRow(
|
|
386
|
+
id=r[0],
|
|
387
|
+
signal_kind=r[1],
|
|
388
|
+
target_type=r[2],
|
|
389
|
+
target_id=r[3],
|
|
390
|
+
score=float(r[4]),
|
|
391
|
+
evidence_ids=list(r[5]),
|
|
392
|
+
rationale=r[6],
|
|
393
|
+
status=r[7],
|
|
394
|
+
)
|
|
395
|
+
for r in rows
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def dismiss_review_finding(
|
|
400
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, id_prefix: str
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Set ``status='dismissed'`` on the review finding matching ``id_prefix``.
|
|
403
|
+
|
|
404
|
+
Resolves ``id_prefix`` against open / dismissed review findings only
|
|
405
|
+
(``signal_kind`` in :data:`REVIEW_SIGNAL_KINDS`, ``status <> 'resolved'``).
|
|
406
|
+
Idempotent: dismissing an already-dismissed finding is a no-op that returns
|
|
407
|
+
its id. Returns the full finding id. Raises :class:`ValueError` when the
|
|
408
|
+
prefix matches no finding or is ambiguous — the CLI / MCP layer maps that to
|
|
409
|
+
a user-facing error.
|
|
410
|
+
"""
|
|
411
|
+
matches = conn.execute(
|
|
412
|
+
"""
|
|
413
|
+
SELECT id::text
|
|
414
|
+
FROM elicitation_gaps
|
|
415
|
+
WHERE tenant_id = %s
|
|
416
|
+
AND signal_kind = ANY(%s)
|
|
417
|
+
AND status <> 'resolved'
|
|
418
|
+
AND id::text LIKE %s
|
|
419
|
+
LIMIT 2
|
|
420
|
+
""",
|
|
421
|
+
(tenant_id, list(REVIEW_SIGNAL_KINDS), f"{id_prefix}%"),
|
|
422
|
+
).fetchall()
|
|
423
|
+
if not matches:
|
|
424
|
+
raise ValueError(f"no review finding matches prefix {id_prefix!r}")
|
|
425
|
+
if len(matches) > 1:
|
|
426
|
+
raise ValueError(f"review finding prefix {id_prefix!r} is ambiguous")
|
|
427
|
+
finding_id = str(matches[0][0])
|
|
428
|
+
conn.execute(
|
|
429
|
+
"""
|
|
430
|
+
UPDATE elicitation_gaps
|
|
431
|
+
SET status = 'dismissed', updated_at = now()
|
|
432
|
+
WHERE id = %s::uuid
|
|
433
|
+
""",
|
|
434
|
+
(finding_id,),
|
|
435
|
+
)
|
|
436
|
+
return finding_id
|
brain/review/render.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Pure renderers for a :class:`~brain.review.weekly.WeeklyReport`.
|
|
2
|
+
|
|
3
|
+
``render_weekly_md`` produces the vault page (YAML frontmatter + Markdown body);
|
|
4
|
+
``render_weekly_rich`` produces a compact terminal summary. Both are pure: the
|
|
5
|
+
same ``WeeklyReport`` always yields byte-identical output, which is what makes
|
|
6
|
+
the vault write idempotent (re-running a week overwrites with identical bytes).
|
|
7
|
+
The only collaborators are the deterministic
|
|
8
|
+
:func:`brain.vault.frontmatter.dump_frontmatter` and the wiki-link path helpers.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..vault.frontmatter import dump_frontmatter
|
|
15
|
+
from ..vault.paths import safe_wikilink_alias, strip_md_extension
|
|
16
|
+
from .weekly import ThemeBlock, WeeklyReport
|
|
17
|
+
|
|
18
|
+
_EMPTY_THEMES = "_No themes detected this week._"
|
|
19
|
+
_EMPTY_ACTIVITY = "_No documents interacted with this week._"
|
|
20
|
+
_EMPTY_LOOPS = "_No open loops._"
|
|
21
|
+
_EMPTY_INGESTED = "_No new captures this week._"
|
|
22
|
+
_EMPTY_PEOPLE = "_No key people identified._"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _doc_link(doc_id: str, title: str, vault_paths: dict[str, str]) -> str:
|
|
26
|
+
"""Render a doc reference as a wiki-link when it has a vault path, else plain.
|
|
27
|
+
|
|
28
|
+
``[[<vault-path-no-md>|<alias>]]`` for vault-mirrored docs (alias has its
|
|
29
|
+
``[`` / ``]`` neutralized so Quartz's wiki-link regex matches); the bare
|
|
30
|
+
title otherwise. Honors the "never guess wiki-link targets" rule — the
|
|
31
|
+
target is always the doc's stored ``vault_path``, never a derived slug.
|
|
32
|
+
"""
|
|
33
|
+
vault_path = vault_paths.get(doc_id)
|
|
34
|
+
if vault_path:
|
|
35
|
+
target = strip_md_extension(vault_path)
|
|
36
|
+
return f"[[{target}|{safe_wikilink_alias(title)}]]"
|
|
37
|
+
return title
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render_theme(block: ThemeBlock, vault_paths: dict[str, str]) -> list[str]:
|
|
41
|
+
"""Render one theme block: heading, optional synthesis quote, doc bullets."""
|
|
42
|
+
heading = " & ".join(block.entity_names) if block.entity_names else block.key
|
|
43
|
+
lines = [f"### {heading}"]
|
|
44
|
+
if block.synthesis:
|
|
45
|
+
lines.append(f"> {block.synthesis}")
|
|
46
|
+
for doc_id, title in block.docs:
|
|
47
|
+
lines.append(f"- {_doc_link(doc_id, title, vault_paths)}")
|
|
48
|
+
return lines
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render_body(report: WeeklyReport) -> str:
|
|
52
|
+
"""Build the Markdown body (everything after the frontmatter fence)."""
|
|
53
|
+
lines: list[str] = [
|
|
54
|
+
f"# Weekly review · {report.week}",
|
|
55
|
+
f"_{report.start_date.isoformat()} → {report.end_date.isoformat()}_",
|
|
56
|
+
"",
|
|
57
|
+
"## Themes this week",
|
|
58
|
+
"",
|
|
59
|
+
]
|
|
60
|
+
if report.themes:
|
|
61
|
+
for i, block in enumerate(report.themes):
|
|
62
|
+
if i > 0:
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.extend(_render_theme(block, report.vault_paths))
|
|
65
|
+
else:
|
|
66
|
+
lines.append(_EMPTY_THEMES)
|
|
67
|
+
|
|
68
|
+
lines += ["", f"## Activity ({len(report.activity)} docs interacted with)", ""]
|
|
69
|
+
if report.activity:
|
|
70
|
+
lines.append("| Doc | Interactions |")
|
|
71
|
+
lines.append("|-----|-------------|")
|
|
72
|
+
for doc in report.activity:
|
|
73
|
+
link = _doc_link(doc.document_id, doc.title, report.vault_paths)
|
|
74
|
+
lines.append(f"| {link} | {doc.interaction_count} |")
|
|
75
|
+
else:
|
|
76
|
+
lines.append(_EMPTY_ACTIVITY)
|
|
77
|
+
|
|
78
|
+
lines += ["", "## Open loops", ""]
|
|
79
|
+
if report.open_loops:
|
|
80
|
+
for row in report.open_loops:
|
|
81
|
+
source = _doc_link(
|
|
82
|
+
row.document_id, row.document_title, report.vault_paths
|
|
83
|
+
)
|
|
84
|
+
lines.append(f"- [ ] {row.text}")
|
|
85
|
+
lines.append(f" _from: {source}_")
|
|
86
|
+
else:
|
|
87
|
+
lines.append(_EMPTY_LOOPS)
|
|
88
|
+
|
|
89
|
+
lines += ["", f"## New captures ({len(report.ingested)} docs)", ""]
|
|
90
|
+
if report.ingested:
|
|
91
|
+
for ing in report.ingested:
|
|
92
|
+
link = _doc_link(ing.document_id, ing.title, report.vault_paths)
|
|
93
|
+
kind = ing.source_kind or "manual"
|
|
94
|
+
lines.append(f"- {link} ({kind})")
|
|
95
|
+
else:
|
|
96
|
+
lines.append(_EMPTY_INGESTED)
|
|
97
|
+
|
|
98
|
+
lines += ["", "## Key people", ""]
|
|
99
|
+
if report.key_people:
|
|
100
|
+
lines.append(" · ".join(report.key_people))
|
|
101
|
+
else:
|
|
102
|
+
lines.append(_EMPTY_PEOPLE)
|
|
103
|
+
|
|
104
|
+
return "\n".join(lines) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_weekly_md(report: WeeklyReport) -> str:
|
|
108
|
+
"""Render the full vault page: YAML frontmatter + Markdown body.
|
|
109
|
+
|
|
110
|
+
Deterministic — two calls on the same report return byte-identical strings.
|
|
111
|
+
Frontmatter fields are emitted in a fixed order (``dump_frontmatter`` keeps
|
|
112
|
+
insertion order) so the page round-trips through
|
|
113
|
+
:func:`brain.vault.frontmatter.parse_frontmatter`.
|
|
114
|
+
"""
|
|
115
|
+
fields = {
|
|
116
|
+
"kind": "review",
|
|
117
|
+
"week": report.week,
|
|
118
|
+
"date": report.generated_date.isoformat(),
|
|
119
|
+
"tags": ["review", "weekly"],
|
|
120
|
+
"window_start": report.start_date.isoformat(),
|
|
121
|
+
"window_end": report.end_date.isoformat(),
|
|
122
|
+
}
|
|
123
|
+
return dump_frontmatter(fields, _render_body(report))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_weekly_json(report: WeeklyReport) -> dict[str, Any]:
|
|
127
|
+
"""Render the machine-readable wire shape shared by CLI ``--json`` + MCP.
|
|
128
|
+
|
|
129
|
+
``vault_path`` is the relative page slug (``reviews/<week>``) — present
|
|
130
|
+
whether or not the page was actually emitted, mirroring the MCP contract in
|
|
131
|
+
Plan 10 §3. Theme blocks expose ``community_key`` (the graph community key,
|
|
132
|
+
or the tag name on the fallback path) and ``doc_titles`` (not full doc refs).
|
|
133
|
+
"""
|
|
134
|
+
return {
|
|
135
|
+
"week": report.week,
|
|
136
|
+
"start_date": report.start_date.isoformat(),
|
|
137
|
+
"end_date": report.end_date.isoformat(),
|
|
138
|
+
"vault_path": f"reviews/{report.week}",
|
|
139
|
+
"graph_used": report.graph_used,
|
|
140
|
+
"sections": {
|
|
141
|
+
"themes": [
|
|
142
|
+
{
|
|
143
|
+
"community_key": block.key,
|
|
144
|
+
"entity_names": block.entity_names,
|
|
145
|
+
"doc_titles": [title for _id, title in block.docs],
|
|
146
|
+
"synthesis": block.synthesis,
|
|
147
|
+
}
|
|
148
|
+
for block in report.themes
|
|
149
|
+
],
|
|
150
|
+
"activity": [
|
|
151
|
+
{
|
|
152
|
+
"document_id": doc.document_id,
|
|
153
|
+
"title": doc.title,
|
|
154
|
+
"interaction_count": doc.interaction_count,
|
|
155
|
+
}
|
|
156
|
+
for doc in report.activity
|
|
157
|
+
],
|
|
158
|
+
"open_loops": [
|
|
159
|
+
{"document_title": row.document_title, "text": row.text}
|
|
160
|
+
for row in report.open_loops
|
|
161
|
+
],
|
|
162
|
+
"ingested": [
|
|
163
|
+
{
|
|
164
|
+
"document_id": ing.document_id,
|
|
165
|
+
"title": ing.title,
|
|
166
|
+
"source_kind": ing.source_kind,
|
|
167
|
+
}
|
|
168
|
+
for ing in report.ingested
|
|
169
|
+
],
|
|
170
|
+
"key_people": report.key_people,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_weekly_rich(report: WeeklyReport) -> str:
|
|
176
|
+
"""Render a compact terminal summary (plain text, no Rich markup).
|
|
177
|
+
|
|
178
|
+
Used by ``brain review weekly --no-emit``. Leads with the report title so
|
|
179
|
+
callers / tests can assert on "Weekly review".
|
|
180
|
+
"""
|
|
181
|
+
lines = [
|
|
182
|
+
f"Weekly review · {report.week}",
|
|
183
|
+
f" {report.start_date.isoformat()} → {report.end_date.isoformat()}",
|
|
184
|
+
f" themes: {len(report.themes)} "
|
|
185
|
+
f"activity: {len(report.activity)} "
|
|
186
|
+
f"open loops: {len(report.open_loops)} "
|
|
187
|
+
f"captures: {len(report.ingested)}",
|
|
188
|
+
]
|
|
189
|
+
for block in report.themes:
|
|
190
|
+
heading = " & ".join(block.entity_names) if block.entity_names else block.key
|
|
191
|
+
lines.append(f" • {heading}")
|
|
192
|
+
if block.synthesis:
|
|
193
|
+
lines.append(f" {block.synthesis}")
|
|
194
|
+
if report.key_people:
|
|
195
|
+
lines.append(f" key people: {' · '.join(report.key_people)}")
|
|
196
|
+
return "\n".join(lines)
|