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/elicit/queue.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Build the ranked, de-duplicated elicitation gap queue."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
|
|
10
|
+
from ..config import Config
|
|
11
|
+
from ..rank_fusion import rrf_contribution
|
|
12
|
+
from .detectors import GapDetector
|
|
13
|
+
from .schema import ENTITY_TARGET_TYPES, Gap
|
|
14
|
+
|
|
15
|
+
# A UUID-shaped ``target_id`` (entity-typed gaps reference a ``graph_entities``
|
|
16
|
+
# row by id). Guards the F2 purge so a ``user_flagged`` gap carrying a raw-string
|
|
17
|
+
# target (not a UUID) is never treated as an orphaned entity reference.
|
|
18
|
+
_UUID_RE = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize(gaps: list[Gap]) -> list[Gap]:
|
|
22
|
+
"""Rescale gap scores so the maximum is 1.0; identity on empty list."""
|
|
23
|
+
if not gaps:
|
|
24
|
+
return []
|
|
25
|
+
top = max(g.score for g in gaps) or 1.0
|
|
26
|
+
return [replace(g, score=g.score / top) for g in gaps]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _rank_gaps(by_signal: dict[str, list[Gap]]) -> list[Gap]:
|
|
30
|
+
"""Merge per-signal gap lists into one ranked list using Reciprocal Rank Fusion.
|
|
31
|
+
|
|
32
|
+
Each signal list is independently score-normalised; then RRF contributions
|
|
33
|
+
are accumulated per ``target_id`` across all signals. Targets that appear in
|
|
34
|
+
multiple signal lists receive additive boosts. The returned list is ordered
|
|
35
|
+
descending by the accumulated RRF score (also normalised to [0, 1]).
|
|
36
|
+
"""
|
|
37
|
+
contrib: dict[str, float] = {}
|
|
38
|
+
chosen: dict[str, Gap] = {}
|
|
39
|
+
for gaps in by_signal.values():
|
|
40
|
+
for rank, g in enumerate(
|
|
41
|
+
sorted(_normalize(gaps), key=lambda x: x.score, reverse=True)
|
|
42
|
+
):
|
|
43
|
+
contrib[g.target_id] = contrib.get(g.target_id, 0.0) + rrf_contribution(rank)
|
|
44
|
+
if g.target_id not in chosen or len(g.evidence_ids) > len(
|
|
45
|
+
chosen[g.target_id].evidence_ids
|
|
46
|
+
):
|
|
47
|
+
chosen[g.target_id] = g
|
|
48
|
+
ranked = [replace(chosen[t], score=c) for t, c in contrib.items()]
|
|
49
|
+
top = max((g.score for g in ranked), default=1.0) or 1.0
|
|
50
|
+
ranked = [replace(g, score=g.score / top) for g in ranked]
|
|
51
|
+
return sorted(ranked, key=lambda g: g.score, reverse=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _purge_orphaned_entity_gaps(
|
|
55
|
+
conn: psycopg.Connection[Any], tenant_id: str
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Delete open entity-typed gaps whose ``graph_entities`` target is gone (F2).
|
|
58
|
+
|
|
59
|
+
The cross-type collapse (:func:`brain.graph_rag.cross_type.collapse_cross_type_concepts`)
|
|
60
|
+
merges duplicate entities and GCs the losers, leaving ``elicitation_gaps`` rows
|
|
61
|
+
whose UUID ``target_id`` no longer resolves. This drops those phantom gaps so a
|
|
62
|
+
stale entry never lingers in the queue. Scoped to:
|
|
63
|
+
|
|
64
|
+
* ``status IN ('surfaced', 'snoozed')`` — the two active states the queue
|
|
65
|
+
read-back surfaces; never touches user-``resolved`` rows (a snoozed gap whose
|
|
66
|
+
entity was merged away would otherwise resurface as a phantom on expiry);
|
|
67
|
+
* entity target types only (:data:`ENTITY_TARGET_TYPES`);
|
|
68
|
+
* a UUID-shaped ``target_id`` (:data:`_UUID_RE`) — a ``user_flagged`` gap with a
|
|
69
|
+
raw-string target is never mistaken for an orphaned entity reference;
|
|
70
|
+
* ``NOT EXISTS`` a matching tenant-scoped ``graph_entities`` row.
|
|
71
|
+
|
|
72
|
+
Returns the number of rows purged. Parameterized SQL.
|
|
73
|
+
"""
|
|
74
|
+
cur = conn.execute(
|
|
75
|
+
"""
|
|
76
|
+
DELETE FROM elicitation_gaps eg
|
|
77
|
+
WHERE eg.tenant_id = %s
|
|
78
|
+
AND eg.status IN ('surfaced', 'snoozed')
|
|
79
|
+
AND eg.target_type = ANY(%s)
|
|
80
|
+
AND eg.target_id ~ %s
|
|
81
|
+
AND NOT EXISTS (
|
|
82
|
+
SELECT 1 FROM graph_entities ge
|
|
83
|
+
WHERE ge.tenant_id = eg.tenant_id AND ge.id::text = eg.target_id
|
|
84
|
+
)
|
|
85
|
+
""",
|
|
86
|
+
(tenant_id, list(ENTITY_TARGET_TYPES), _UUID_RE),
|
|
87
|
+
)
|
|
88
|
+
return cur.rowcount
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_queue(
|
|
92
|
+
conn: psycopg.Connection[Any],
|
|
93
|
+
*,
|
|
94
|
+
cfg: Config,
|
|
95
|
+
tenant_id: str,
|
|
96
|
+
detectors: list[GapDetector],
|
|
97
|
+
limit: int,
|
|
98
|
+
include_low_confidence: bool = False,
|
|
99
|
+
signal_kinds: Sequence[str] | None = None,
|
|
100
|
+
target_ids: Sequence[str] | None = None,
|
|
101
|
+
target_types: Sequence[str] | None = None,
|
|
102
|
+
) -> list[Gap]:
|
|
103
|
+
"""Run ``detectors``, upsert results, then return the filtered open queue.
|
|
104
|
+
|
|
105
|
+
Detection results are RRF-merged across signal kinds, upserted into
|
|
106
|
+
``elicitation_gaps`` (with the partial-index conflict clause so resolved gaps
|
|
107
|
+
are not overwritten), and then read back in score order with evidence and
|
|
108
|
+
confidence filters applied.
|
|
109
|
+
|
|
110
|
+
``signal_kinds`` / ``target_ids`` / ``target_types`` optionally scope the
|
|
111
|
+
read-back so the caller sees only the gaps it asked about. They restrict the
|
|
112
|
+
SELECT, not the detectors: ``brain elicit --signal delta`` runs only the
|
|
113
|
+
delta detector AND
|
|
114
|
+
filters the read-back to ``signal_kind = 'delta'`` so unrelated pre-existing
|
|
115
|
+
open gaps from prior runs don't leak into the session. ``None`` (the
|
|
116
|
+
default) adds no predicate — ``brain elicit list`` and the unfiltered
|
|
117
|
+
``brain elicit`` still see the whole open queue.
|
|
118
|
+
"""
|
|
119
|
+
# Discovery cap: honour an explicit caller ``limit`` larger than the config
|
|
120
|
+
# default so ``brain elicit list --limit 100`` can surface up to 100 gaps,
|
|
121
|
+
# while the unfiltered default still discovers ``cfg.elicit_queue_limit``.
|
|
122
|
+
# The read-back ``LIMIT`` below stays the caller's ``limit`` exactly.
|
|
123
|
+
discovery_limit = max(limit, cfg.elicit_queue_limit)
|
|
124
|
+
by_signal: dict[str, list[Gap]] = {}
|
|
125
|
+
for det in detectors:
|
|
126
|
+
by_signal[det.signal_kind] = det.detect(
|
|
127
|
+
conn, tenant_id=tenant_id, limit=discovery_limit
|
|
128
|
+
)
|
|
129
|
+
ranked = _rank_gaps(by_signal)
|
|
130
|
+
# Evidence guard: skip gaps with too few supporting docs. ``user_flagged``
|
|
131
|
+
# (an explicit user request) and ``search_failure`` (a repeated failed
|
|
132
|
+
# search — by definition NO doc answers it, so ``evidence_ids`` is always
|
|
133
|
+
# empty) are both exempt; the signal itself is the evidence.
|
|
134
|
+
ranked = [
|
|
135
|
+
g
|
|
136
|
+
for g in ranked
|
|
137
|
+
if len(g.evidence_ids) >= cfg.elicit_min_evidence_docs
|
|
138
|
+
or g.signal_kind in ("user_flagged", "search_failure")
|
|
139
|
+
]
|
|
140
|
+
# Upsert passing gaps; the partial index prevents overwriting resolved rows.
|
|
141
|
+
for g in ranked:
|
|
142
|
+
conn.execute(
|
|
143
|
+
"""
|
|
144
|
+
INSERT INTO elicitation_gaps
|
|
145
|
+
(tenant_id, signal_kind, target_type, target_id,
|
|
146
|
+
score, evidence_ids, rationale)
|
|
147
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
148
|
+
ON CONFLICT (tenant_id, signal_kind, target_id) WHERE status <> 'resolved'
|
|
149
|
+
DO UPDATE SET
|
|
150
|
+
score = EXCLUDED.score,
|
|
151
|
+
evidence_ids = EXCLUDED.evidence_ids,
|
|
152
|
+
rationale = EXCLUDED.rationale,
|
|
153
|
+
updated_at = now()
|
|
154
|
+
""",
|
|
155
|
+
(
|
|
156
|
+
tenant_id,
|
|
157
|
+
g.signal_kind,
|
|
158
|
+
g.target_type,
|
|
159
|
+
g.target_id,
|
|
160
|
+
g.score,
|
|
161
|
+
g.evidence_ids,
|
|
162
|
+
g.rationale,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
# F2 self-heal: drop open gaps whose entity target was merged/deleted (e.g. by
|
|
166
|
+
# the cross-type collapse) so a phantom gap never lingers in the queue.
|
|
167
|
+
_purge_orphaned_entity_gaps(conn, tenant_id)
|
|
168
|
+
# Read back the open queue with score and confidence floors applied, plus
|
|
169
|
+
# the optional signal/target scoping (parameterized; static clause strings
|
|
170
|
+
# only — no user values are interpolated into the SQL).
|
|
171
|
+
floor = 0.0 if include_low_confidence else cfg.elicit_min_gap_score
|
|
172
|
+
clauses = [
|
|
173
|
+
"eg.tenant_id = %s",
|
|
174
|
+
"eg.status IN ('surfaced', 'snoozed')",
|
|
175
|
+
"(eg.snoozed_until IS NULL OR eg.snoozed_until < now())",
|
|
176
|
+
"eg.score >= %s",
|
|
177
|
+
# Mirror the pre-upsert evidence guard on read-back so previously
|
|
178
|
+
# persisted sparse gaps can't resurface (user_flagged and
|
|
179
|
+
# search_failure are exempt, exactly as in the upsert filter above).
|
|
180
|
+
"(cardinality(eg.evidence_ids) >= %s "
|
|
181
|
+
"OR eg.signal_kind IN ('user_flagged', 'search_failure'))",
|
|
182
|
+
]
|
|
183
|
+
params: list[Any] = [tenant_id, floor, cfg.elicit_min_evidence_docs]
|
|
184
|
+
if signal_kinds is not None:
|
|
185
|
+
clauses.append("eg.signal_kind = ANY(%s)")
|
|
186
|
+
params.append(list(signal_kinds))
|
|
187
|
+
if target_ids is not None:
|
|
188
|
+
clauses.append("eg.target_id = ANY(%s)")
|
|
189
|
+
params.append(list(target_ids))
|
|
190
|
+
if target_types is not None:
|
|
191
|
+
clauses.append("eg.target_type = ANY(%s)")
|
|
192
|
+
params.append(list(target_types))
|
|
193
|
+
params.append(limit)
|
|
194
|
+
# LEFT JOIN graph_entities on TEXT equality so a raw-string target_id
|
|
195
|
+
# (e.g. a user_flagged gap whose target is not a UUID) never triggers a
|
|
196
|
+
# uuid-cast error — it simply finds no match and target_name stays "".
|
|
197
|
+
sql = (
|
|
198
|
+
"SELECT eg.id::text, eg.signal_kind, eg.target_type, eg.target_id, "
|
|
199
|
+
"eg.score, eg.evidence_ids, eg.rationale, ge.name "
|
|
200
|
+
"FROM elicitation_gaps eg "
|
|
201
|
+
"LEFT JOIN graph_entities ge "
|
|
202
|
+
"ON ge.tenant_id = eg.tenant_id AND ge.id::text = eg.target_id "
|
|
203
|
+
f"WHERE {' AND '.join(clauses)} "
|
|
204
|
+
"ORDER BY eg.score DESC LIMIT %s"
|
|
205
|
+
)
|
|
206
|
+
rows = conn.execute(sql, tuple(params)).fetchall()
|
|
207
|
+
return [
|
|
208
|
+
Gap(
|
|
209
|
+
gap_id=r[0],
|
|
210
|
+
signal_kind=r[1],
|
|
211
|
+
target_type=r[2],
|
|
212
|
+
target_id=r[3],
|
|
213
|
+
score=float(r[4]),
|
|
214
|
+
evidence_ids=list(r[5]),
|
|
215
|
+
evidence_texts=[],
|
|
216
|
+
rationale=r[6],
|
|
217
|
+
target_name=r[7] or "",
|
|
218
|
+
)
|
|
219
|
+
for r in rows
|
|
220
|
+
]
|
brain/elicit/schema.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Frozen value objects for tacit-knowledge elicitation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
SignalKind = Literal[
|
|
8
|
+
"delta", "orphan", "contradiction", "user_flagged", "search_failure"
|
|
9
|
+
]
|
|
10
|
+
TargetType = Literal["person", "org", "project", "topic", "tool", "doc"]
|
|
11
|
+
OutcomeAction = Literal["accepted", "skipped", "snoozed", "dismissed"]
|
|
12
|
+
|
|
13
|
+
# Target types whose ``target_id`` is a real ``graph_entities`` UUID (so the gap
|
|
14
|
+
# can be orphaned when its entity is merged/deleted — used by the F2 self-heal in
|
|
15
|
+
# :mod:`brain.elicit.queue` and the name resolution in :mod:`brain.elicit.session`).
|
|
16
|
+
ENTITY_TARGET_TYPES: frozenset[str] = frozenset(
|
|
17
|
+
{"person", "org", "project", "topic", "tool"}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Gap:
|
|
23
|
+
gap_id: str
|
|
24
|
+
signal_kind: SignalKind
|
|
25
|
+
target_type: TargetType
|
|
26
|
+
target_id: str
|
|
27
|
+
score: float
|
|
28
|
+
evidence_ids: list[str] = field(default_factory=list)
|
|
29
|
+
evidence_texts: list[str] = field(default_factory=list)
|
|
30
|
+
rationale: str = ""
|
|
31
|
+
target_name: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ElicitDraft:
|
|
36
|
+
gap_id: str
|
|
37
|
+
title: str
|
|
38
|
+
draft_text: str
|
|
39
|
+
evidence_ids: list[str] = field(default_factory=list)
|
|
40
|
+
evidence_texts: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ElicitOutcome:
|
|
45
|
+
gap_id: str
|
|
46
|
+
action: OutcomeAction
|
|
47
|
+
note_id: str | None = None
|
|
48
|
+
snoozed_days: int | None = None
|