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/gaps.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Search-query logging and search-failure-driven knowledge-gap detection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import psycopg
|
|
12
|
+
|
|
13
|
+
from .set_similarity import jaccard
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .elicit.schema import Gap
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Strip punctuation to whitespace before tokenizing so "q3-hiring" and
|
|
21
|
+
# "q3 hiring" normalize identically.
|
|
22
|
+
_PUNCT_RE = re.compile(r"[^\w\s]")
|
|
23
|
+
|
|
24
|
+
# Two SQL passes mined by both the read view (``top_search_failures``) and the
|
|
25
|
+
# detector (``SearchFailureDetector.detect``). Both are parameterized; the
|
|
26
|
+
# lookback window is bound as an integer day-count via ``make_interval``.
|
|
27
|
+
# Lexical-miss pass. The headline knowledge-gap signal.
|
|
28
|
+
#
|
|
29
|
+
# Design bug found in live QA: hybrid search's VECTOR leg always returns
|
|
30
|
+
# nearest neighbours, so an off-corpus query logs ``result_count > 0`` (filler)
|
|
31
|
+
# and a ``result_count = 0`` predicate NEVER fires on the CLI path. The real
|
|
32
|
+
# signal is the FTS (lexical) leg matching ZERO chunks (``fts_count = 0``): the
|
|
33
|
+
# corpus has no lexical trace of the query. We keep the ``'zero_results'`` kind
|
|
34
|
+
# label (see ``top_search_failures`` / the detector) for backward compatibility
|
|
35
|
+
# — the *meaning* ("the corpus had no useful answer") is unchanged; only the
|
|
36
|
+
# detection mechanism moved from ``result_count`` to ``fts_count``.
|
|
37
|
+
#
|
|
38
|
+
# Old rows predate migration 023 and carry ``fts_count IS NULL``. For them we
|
|
39
|
+
# fall back to the historical ``result_count = 0`` semantics so no past
|
|
40
|
+
# zero-result evidence is lost; the new signal applies to rows written after
|
|
41
|
+
# the fix. Both branches are index-backed (migration 019's result_count=0
|
|
42
|
+
# partial index + migration 023's fts_count=0 partial index → a BitmapOr plan).
|
|
43
|
+
_ZERO_RESULT_SQL = """
|
|
44
|
+
SELECT query, COUNT(*) AS n
|
|
45
|
+
FROM search_queries
|
|
46
|
+
WHERE tenant_id = %s
|
|
47
|
+
AND (fts_count = 0 OR (fts_count IS NULL AND result_count = 0))
|
|
48
|
+
AND at >= NOW() - make_interval(days => %s)
|
|
49
|
+
GROUP BY query
|
|
50
|
+
ORDER BY n DESC, query
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# No-click: a non-empty result set that the user never opened/clicked within
|
|
54
|
+
# the same search session. MCP-only — CLI searches carry ``session_id IS NULL``
|
|
55
|
+
# (no session to join), so the predicate excludes them automatically.
|
|
56
|
+
#
|
|
57
|
+
# Mutual exclusivity with the lexical-miss pass (matters since migration 023):
|
|
58
|
+
# a lexical miss now keys off ``fts_count = 0``, but such a row can still have
|
|
59
|
+
# ``result_count > 0`` (vector filler). Without the ``fts_count`` guard below,
|
|
60
|
+
# an MCP lexical-miss that was never opened would match BOTH passes and get
|
|
61
|
+
# counted twice (and emit two ``SearchFailure`` rows for one event). We exclude
|
|
62
|
+
# ``fts_count = 0`` rows here so each event is attributed to exactly one pass —
|
|
63
|
+
# the lexical miss is the stronger signal. ``fts_count IS NULL`` (legacy /
|
|
64
|
+
# pre-023 rows) stays eligible for no-click, preserving the prior behavior
|
|
65
|
+
# where ``result_count = 0`` and ``result_count > 0`` were already exclusive.
|
|
66
|
+
_NO_CLICK_SQL = """
|
|
67
|
+
SELECT sq.query, COUNT(*) AS n
|
|
68
|
+
FROM search_queries sq
|
|
69
|
+
WHERE sq.tenant_id = %s
|
|
70
|
+
AND sq.result_count > 0
|
|
71
|
+
AND (sq.fts_count IS NULL OR sq.fts_count > 0)
|
|
72
|
+
AND sq.session_id IS NOT NULL
|
|
73
|
+
AND sq.at >= NOW() - make_interval(days => %s)
|
|
74
|
+
AND NOT EXISTS (
|
|
75
|
+
SELECT 1 FROM interactions i
|
|
76
|
+
WHERE i.session_id = sq.session_id
|
|
77
|
+
AND i.action IN ('clicked', 'opened')
|
|
78
|
+
)
|
|
79
|
+
GROUP BY sq.query
|
|
80
|
+
ORDER BY n DESC, sq.query
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class SearchFailure:
|
|
86
|
+
"""One ranked failed-query row for the ``brain gaps`` read view.
|
|
87
|
+
|
|
88
|
+
``kind`` is ``'zero_results'`` (the search returned nothing) or
|
|
89
|
+
``'no_click'`` (results were returned but never opened in-session).
|
|
90
|
+
``query`` is the raw stored string for the CLI surface, or the derived
|
|
91
|
+
normalized canonical label when ``top_search_failures(normalize=True)``
|
|
92
|
+
(the MCP surface — raw query strings stay server-side; see Plan 08 §6).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
query: str
|
|
96
|
+
count: int
|
|
97
|
+
kind: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_search_query(
|
|
101
|
+
conn: psycopg.Connection[Any],
|
|
102
|
+
*,
|
|
103
|
+
query: str,
|
|
104
|
+
result_count: int,
|
|
105
|
+
session_id: uuid.UUID | None,
|
|
106
|
+
source: str,
|
|
107
|
+
fts_count: int | None = None,
|
|
108
|
+
tenant_id: str = "default",
|
|
109
|
+
) -> None:
|
|
110
|
+
"""INSERT one row into ``search_queries``. Best-effort on a transient blip.
|
|
111
|
+
|
|
112
|
+
A single parameterized INSERT. Callers run with ``autocommit=True`` so this
|
|
113
|
+
is one round-trip; logging must never slow or break the search response.
|
|
114
|
+
|
|
115
|
+
Error contract (Plan 08 §3 / §3e):
|
|
116
|
+
|
|
117
|
+
- :class:`psycopg.OperationalError` (a transient connection blip) is
|
|
118
|
+
**swallowed** — a DB hiccup must not break a search the user already got
|
|
119
|
+
results for. Logged at WARNING with the exception *type* only.
|
|
120
|
+
- :class:`psycopg.errors.UndefinedTable` (migration 019 not applied) is
|
|
121
|
+
**swallowed with a loud, actionable WARNING** naming ``brain init``.
|
|
122
|
+
Search is the daily-driver command; a binary upgrade that lands before
|
|
123
|
+
the operator re-runs ``brain init`` must never break search itself
|
|
124
|
+
(observed live against a pre-019 prod DB). The operator still sees the
|
|
125
|
+
warning on every search until the migration is applied, and the
|
|
126
|
+
``brain gaps`` surfaces fail loudly with the same hint. The INSERT runs
|
|
127
|
+
inside its own ``conn.transaction()`` (savepoint when nested) so the
|
|
128
|
+
failure never poisons or rolls back the caller's transaction.
|
|
129
|
+
- :class:`psycopg.errors.UndefinedColumn` naming ``fts_count`` (migration
|
|
130
|
+
023 not applied — a pre-023 DB that has the table but lacks the new
|
|
131
|
+
column) gets the **same swallow-with-hint** treatment, for the same
|
|
132
|
+
daily-driver reason: a binary that writes ``fts_count`` must not break
|
|
133
|
+
search on a DB that hasn't run ``brain init`` since the upgrade. The
|
|
134
|
+
guard is narrowed to the ``fts_count`` column so a genuinely-unknown
|
|
135
|
+
column still propagates as a real bug.
|
|
136
|
+
- Any other schema/programming error **propagates** — those are real bugs
|
|
137
|
+
that must surface visibly, never be silently eaten.
|
|
138
|
+
|
|
139
|
+
Privacy (Plan 08 §6 — firm contract): the raw ``query`` string MUST NOT
|
|
140
|
+
appear at INFO level or above. It may only be logged at DEBUG (the blip
|
|
141
|
+
path below) where local-only debugging is the explicit opt-in.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
# The inner transaction() scopes the best-effort INSERT: a savepoint
|
|
145
|
+
# when the caller is already in a transaction, a plain transaction
|
|
146
|
+
# under autocommit. On failure only THIS insert rolls back — the
|
|
147
|
+
# caller's prior work and open transaction state are untouched (a
|
|
148
|
+
# bare conn.rollback() here would clobber both, and is forbidden
|
|
149
|
+
# inside an explicit conn.transaction() block).
|
|
150
|
+
with conn.transaction():
|
|
151
|
+
conn.execute(
|
|
152
|
+
"""
|
|
153
|
+
INSERT INTO search_queries
|
|
154
|
+
(tenant_id, query, result_count, fts_count, session_id, source)
|
|
155
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
156
|
+
""",
|
|
157
|
+
(
|
|
158
|
+
tenant_id,
|
|
159
|
+
query,
|
|
160
|
+
result_count,
|
|
161
|
+
fts_count,
|
|
162
|
+
str(session_id) if session_id is not None else None,
|
|
163
|
+
source,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
except psycopg.errors.UndefinedTable:
|
|
167
|
+
# Migration 019 not applied yet (e.g. binary upgraded before `brain
|
|
168
|
+
# init` re-ran). Search must keep working; nag until the operator
|
|
169
|
+
# migrates.
|
|
170
|
+
logger.warning(
|
|
171
|
+
"search-query logging skipped: search_queries table missing "
|
|
172
|
+
"(migration 019 not applied) — run `brain init` to enable "
|
|
173
|
+
"`brain gaps`"
|
|
174
|
+
)
|
|
175
|
+
except psycopg.errors.UndefinedColumn as exc:
|
|
176
|
+
# Pre-023 DB: the table exists but lacks the additive ``fts_count``
|
|
177
|
+
# column (migration 023 not applied — e.g. binary upgraded before
|
|
178
|
+
# `brain init` re-ran). Same daily-driver contract as the missing-table
|
|
179
|
+
# case above: search must keep working; nag until the operator
|
|
180
|
+
# migrates. Narrowed to the fts_count column so a genuinely-unknown
|
|
181
|
+
# column still propagates as a real bug.
|
|
182
|
+
if "fts_count" not in str(exc):
|
|
183
|
+
raise
|
|
184
|
+
logger.warning(
|
|
185
|
+
"search-query logging skipped: search_queries.fts_count column "
|
|
186
|
+
"missing (migration 023 not applied) — run `brain init` to enable "
|
|
187
|
+
"lexical-gap detection in `brain gaps`"
|
|
188
|
+
)
|
|
189
|
+
except psycopg.OperationalError as exc:
|
|
190
|
+
# Transient blip only — schema/programming errors other than the
|
|
191
|
+
# missing-table case above propagate as real bugs.
|
|
192
|
+
logger.warning(
|
|
193
|
+
"search-query logging skipped (DB blip): %s", type(exc).__name__
|
|
194
|
+
)
|
|
195
|
+
logger.debug("search-query logging failed for query=%r: %s", query, exc)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def search_queries_schema_hint(exc: psycopg.Error) -> str | None:
|
|
199
|
+
"""Map a missing ``search_queries`` schema object to a `brain init` hint.
|
|
200
|
+
|
|
201
|
+
The ``brain gaps`` read path (:func:`top_search_failures`,
|
|
202
|
+
:class:`SearchFailureDetector`) reads the ``search_queries`` table and,
|
|
203
|
+
since migration 023, its ``fts_count`` column. On a DB that hasn't applied
|
|
204
|
+
migration 019 (no table) or 023 (no column) the query raises, and the
|
|
205
|
+
surfaces (CLI / MCP) must fail loudly-but-cleanly with an actionable hint
|
|
206
|
+
instead of a traceback — mirroring the swallow-with-hint contract the
|
|
207
|
+
search write path uses in :func:`record_search_query`.
|
|
208
|
+
|
|
209
|
+
Returns the hint string for the two known migration gaps, or ``None`` for
|
|
210
|
+
any other error (a genuinely-unknown column / real bug) so the caller
|
|
211
|
+
re-raises it.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(exc, psycopg.errors.UndefinedTable):
|
|
214
|
+
return (
|
|
215
|
+
"search_queries table missing (migration 019 not applied) — "
|
|
216
|
+
"run `brain init` first"
|
|
217
|
+
)
|
|
218
|
+
if isinstance(exc, psycopg.errors.UndefinedColumn) and "fts_count" in str(exc):
|
|
219
|
+
return (
|
|
220
|
+
"search_queries.fts_count column missing (migration 023 not "
|
|
221
|
+
"applied) — run `brain init` first"
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _normalize_tokens(query: str) -> frozenset[str]:
|
|
227
|
+
"""Casefold, strip punctuation, split on whitespace → distinct token set."""
|
|
228
|
+
cleaned = _PUNCT_RE.sub(" ", query.casefold())
|
|
229
|
+
return frozenset(cleaned.split())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _canonical_key(query: str) -> str:
|
|
233
|
+
"""Collision-resistant normalized label: sorted distinct tokens, space-joined.
|
|
234
|
+
|
|
235
|
+
Aggressive normalization (casefold + dedup + sort) so two queries that
|
|
236
|
+
differ only in token order or punctuation collapse to the same
|
|
237
|
+
``elicitation_gaps.target_id`` — a residual collision is a harmless
|
|
238
|
+
score-update upsert (Plan 08 §6).
|
|
239
|
+
"""
|
|
240
|
+
return " ".join(sorted(_normalize_tokens(query)))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cluster_failed_queries(
|
|
244
|
+
queries: list[str], *, threshold: float = 0.5
|
|
245
|
+
) -> list[list[str]]:
|
|
246
|
+
"""Greedily group queries by token-Jaccard similarity ≥ ``threshold``.
|
|
247
|
+
|
|
248
|
+
Pure Python — no DB, no LLM. Each query is normalized to a distinct token
|
|
249
|
+
set (:func:`_normalize_tokens`); a query joins the first existing cluster
|
|
250
|
+
whose seed token set it overlaps by Jaccard ≥ ``threshold``, else it seeds a
|
|
251
|
+
new cluster. Returned clusters preserve the original query strings (so the
|
|
252
|
+
caller can count frequencies) and are ordered by descending size.
|
|
253
|
+
|
|
254
|
+
Example at the default ``threshold=0.5``: ``"q3 hiring"`` and
|
|
255
|
+
``"q3 hiring plan"`` share 2 of 3 distinct tokens (Jaccard ≈ 0.67) → one
|
|
256
|
+
cluster; ``"benefits policy"`` and ``"benefits plan"`` share 1 of 3
|
|
257
|
+
(Jaccard ≈ 0.33) → separate clusters.
|
|
258
|
+
"""
|
|
259
|
+
clusters: list[list[str]] = []
|
|
260
|
+
seeds: list[frozenset[str]] = []
|
|
261
|
+
for query in queries:
|
|
262
|
+
tokens = _normalize_tokens(query)
|
|
263
|
+
for idx, seed in enumerate(seeds):
|
|
264
|
+
if jaccard(tokens, seed) >= threshold:
|
|
265
|
+
clusters[idx].append(query)
|
|
266
|
+
break
|
|
267
|
+
else:
|
|
268
|
+
clusters.append([query])
|
|
269
|
+
seeds.append(tokens)
|
|
270
|
+
order = sorted(
|
|
271
|
+
range(len(clusters)), key=lambda i: (-len(clusters[i]), clusters[i][0])
|
|
272
|
+
)
|
|
273
|
+
return [clusters[i] for i in order]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _failed_query_counts(
|
|
277
|
+
conn: psycopg.Connection[Any], *, tenant_id: str, lookback_days: int
|
|
278
|
+
) -> dict[str, int]:
|
|
279
|
+
"""Merge zero-result + no-click occurrence counts per raw query string."""
|
|
280
|
+
counts: dict[str, int] = {}
|
|
281
|
+
for sql in (_ZERO_RESULT_SQL, _NO_CLICK_SQL):
|
|
282
|
+
for query, n in conn.execute(sql, (tenant_id, lookback_days)).fetchall():
|
|
283
|
+
counts[query] = counts.get(query, 0) + int(n)
|
|
284
|
+
return counts
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SearchFailureDetector:
|
|
288
|
+
"""Surface repeated search failures as ``search_failure`` knowledge gaps.
|
|
289
|
+
|
|
290
|
+
Implements the :class:`brain.elicit.detectors.GapDetector` protocol so it
|
|
291
|
+
plugs into ``build_queue`` / ``DETECTOR_REGISTRY`` unmodified. Mines the
|
|
292
|
+
``search_queries`` log over ``lookback_days``, clusters near-duplicate
|
|
293
|
+
failed queries, and emits one :class:`Gap` per cluster whose total
|
|
294
|
+
occurrence count is ≥ ``min_cluster_size``.
|
|
295
|
+
|
|
296
|
+
Each gap carries ``evidence_ids=[]`` — intentionally empty, since the
|
|
297
|
+
defining property of a search-failure gap is that *no* document answers it.
|
|
298
|
+
``build_queue`` exempts ``search_failure`` from the evidence-docs guard
|
|
299
|
+
exactly as it does ``user_flagged``.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
signal_kind = "search_failure"
|
|
303
|
+
|
|
304
|
+
def __init__(self, *, lookback_days: int, min_cluster_size: int) -> None:
|
|
305
|
+
self._lookback_days = lookback_days
|
|
306
|
+
self._min_cluster_size = min_cluster_size
|
|
307
|
+
|
|
308
|
+
def detect(
|
|
309
|
+
self, conn: psycopg.Connection[Any], *, tenant_id: str, limit: int
|
|
310
|
+
) -> list[Gap]:
|
|
311
|
+
# Local import keeps ``brain.gaps`` import-light: ``elicit.schema`` (and
|
|
312
|
+
# the ``brain.elicit`` package __init__ it triggers) is only loaded when
|
|
313
|
+
# the detector actually runs, breaking the
|
|
314
|
+
# detectors → gaps → elicit.schema → detectors cycle at module load.
|
|
315
|
+
from .elicit.schema import Gap
|
|
316
|
+
|
|
317
|
+
counts = _failed_query_counts(
|
|
318
|
+
conn, tenant_id=tenant_id, lookback_days=self._lookback_days
|
|
319
|
+
)
|
|
320
|
+
if not counts:
|
|
321
|
+
return []
|
|
322
|
+
# Expand to a flat occurrence list so cluster size == raw frequency and
|
|
323
|
+
# the canonical label is the most-frequent member of its cluster.
|
|
324
|
+
expanded = [q for q, n in counts.items() for _ in range(n)]
|
|
325
|
+
gaps: list[Gap] = []
|
|
326
|
+
for cluster in cluster_failed_queries(expanded):
|
|
327
|
+
size = len(cluster)
|
|
328
|
+
if size < self._min_cluster_size:
|
|
329
|
+
continue
|
|
330
|
+
most_common = Counter(cluster).most_common(1)[0][0]
|
|
331
|
+
canonical_label = _canonical_key(most_common)
|
|
332
|
+
gaps.append(
|
|
333
|
+
Gap(
|
|
334
|
+
gap_id=str(uuid.uuid4()),
|
|
335
|
+
signal_kind="search_failure",
|
|
336
|
+
target_type="topic",
|
|
337
|
+
target_id=canonical_label,
|
|
338
|
+
score=float(size),
|
|
339
|
+
evidence_ids=[],
|
|
340
|
+
rationale=(
|
|
341
|
+
f"Asked {size} time(s) with no useful result: "
|
|
342
|
+
f"'{canonical_label}'."
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
if len(gaps) >= limit:
|
|
347
|
+
break
|
|
348
|
+
return gaps
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def top_search_failures(
|
|
352
|
+
conn: psycopg.Connection[Any],
|
|
353
|
+
*,
|
|
354
|
+
tenant_id: str = "default",
|
|
355
|
+
since_days: int,
|
|
356
|
+
limit: int,
|
|
357
|
+
normalize: bool = False,
|
|
358
|
+
) -> list[SearchFailure]:
|
|
359
|
+
"""Rank failed queries (zero-result + no-click) over the lookback window.
|
|
360
|
+
|
|
361
|
+
Read-only view backing ``brain gaps`` / MCP ``brain_gaps`` — never writes to
|
|
362
|
+
``elicitation_gaps``. When ``normalize=True`` (the MCP surface) each query is
|
|
363
|
+
collapsed to its normalized canonical label and counts are summed per
|
|
364
|
+
(label, kind), so only derived strings — never raw stored query text — leave
|
|
365
|
+
the server (Plan 08 §6). When ``normalize=False`` (the local CLI surface)
|
|
366
|
+
raw query strings are returned as-is.
|
|
367
|
+
"""
|
|
368
|
+
rows: list[tuple[str, int, str]] = []
|
|
369
|
+
limited = " LIMIT %s"
|
|
370
|
+
for query, n in conn.execute(
|
|
371
|
+
_ZERO_RESULT_SQL + limited, (tenant_id, since_days, limit)
|
|
372
|
+
).fetchall():
|
|
373
|
+
rows.append((query, int(n), "zero_results"))
|
|
374
|
+
for query, n in conn.execute(
|
|
375
|
+
_NO_CLICK_SQL + limited, (tenant_id, since_days, limit)
|
|
376
|
+
).fetchall():
|
|
377
|
+
rows.append((query, int(n), "no_click"))
|
|
378
|
+
|
|
379
|
+
if normalize:
|
|
380
|
+
agg: dict[tuple[str, str], int] = {}
|
|
381
|
+
for query, n, kind in rows:
|
|
382
|
+
key = (_canonical_key(query), kind)
|
|
383
|
+
agg[key] = agg.get(key, 0) + n
|
|
384
|
+
rows = [(label, n, kind) for (label, kind), n in agg.items()]
|
|
385
|
+
|
|
386
|
+
rows.sort(key=lambda t: (-t[1], t[0]))
|
|
387
|
+
return [
|
|
388
|
+
SearchFailure(query=query, count=n, kind=kind)
|
|
389
|
+
for query, n, kind in rows[:limit]
|
|
390
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""GraphRAG: entity-centric graph retrieval alongside the vector/hybrid RAG.
|
|
2
|
+
|
|
3
|
+
The public API grows per wave. Value objects live in
|
|
4
|
+
:mod:`brain.graph_rag.schema`; the storage/traversal backends (``GraphBackend``
|
|
5
|
+
Protocol + the default Apache ``AgeBackend``) live in
|
|
6
|
+
:mod:`brain.graph_rag.backends`; the incremental reconcile (person aspect, wave
|
|
7
|
+
G1-b) lives in :mod:`brain.graph_rag.reconcile`. Concept extraction +
|
|
8
|
+
``graph_rag_search`` arrive in G2.
|
|
9
|
+
"""
|
|
10
|
+
from .backends import AgeBackend, GraphBackend, PersonScope, TraversalHit
|
|
11
|
+
from .build import BuildResult, build_graph
|
|
12
|
+
from .communities import (
|
|
13
|
+
BUILD_VERSION,
|
|
14
|
+
CommunityBuildResult,
|
|
15
|
+
DetectedCommunity,
|
|
16
|
+
build_communities,
|
|
17
|
+
compute_members_hash,
|
|
18
|
+
compute_source_graph_hash,
|
|
19
|
+
detect_communities,
|
|
20
|
+
list_communities,
|
|
21
|
+
match_communities,
|
|
22
|
+
)
|
|
23
|
+
from .communities_summary import (
|
|
24
|
+
CommunitySummaryResult,
|
|
25
|
+
summarize_communities,
|
|
26
|
+
)
|
|
27
|
+
from .extract import (
|
|
28
|
+
EntityExtractor,
|
|
29
|
+
ExtractedEntity,
|
|
30
|
+
OllamaExtractor,
|
|
31
|
+
make_extractor,
|
|
32
|
+
)
|
|
33
|
+
from .grouping import group_themes
|
|
34
|
+
from .reconcile import (
|
|
35
|
+
CONCEPTS_ASPECT,
|
|
36
|
+
PersonResolver,
|
|
37
|
+
ReconcileConfig,
|
|
38
|
+
ReconcileResult,
|
|
39
|
+
RefreshResult,
|
|
40
|
+
ResolvedPerson,
|
|
41
|
+
default_person_resolver,
|
|
42
|
+
reconcile_document,
|
|
43
|
+
refresh_aggregates,
|
|
44
|
+
remove_document,
|
|
45
|
+
)
|
|
46
|
+
from .retrieve import (
|
|
47
|
+
AUTO_MODE,
|
|
48
|
+
FUSE_MODE,
|
|
49
|
+
GLOBAL_MODE,
|
|
50
|
+
LOCAL_MODE,
|
|
51
|
+
THEMES_MODE,
|
|
52
|
+
graph_rag_search,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AUTO_MODE",
|
|
57
|
+
"BUILD_VERSION",
|
|
58
|
+
"CONCEPTS_ASPECT",
|
|
59
|
+
"FUSE_MODE",
|
|
60
|
+
"GLOBAL_MODE",
|
|
61
|
+
"LOCAL_MODE",
|
|
62
|
+
"THEMES_MODE",
|
|
63
|
+
"AgeBackend",
|
|
64
|
+
"BuildResult",
|
|
65
|
+
"CommunityBuildResult",
|
|
66
|
+
"CommunitySummaryResult",
|
|
67
|
+
"DetectedCommunity",
|
|
68
|
+
"EntityExtractor",
|
|
69
|
+
"ExtractedEntity",
|
|
70
|
+
"GraphBackend",
|
|
71
|
+
"OllamaExtractor",
|
|
72
|
+
"PersonResolver",
|
|
73
|
+
"PersonScope",
|
|
74
|
+
"ReconcileConfig",
|
|
75
|
+
"ReconcileResult",
|
|
76
|
+
"RefreshResult",
|
|
77
|
+
"ResolvedPerson",
|
|
78
|
+
"TraversalHit",
|
|
79
|
+
"build_communities",
|
|
80
|
+
"build_graph",
|
|
81
|
+
"compute_members_hash",
|
|
82
|
+
"compute_source_graph_hash",
|
|
83
|
+
"default_person_resolver",
|
|
84
|
+
"detect_communities",
|
|
85
|
+
"graph_rag_search",
|
|
86
|
+
"group_themes",
|
|
87
|
+
"list_communities",
|
|
88
|
+
"make_extractor",
|
|
89
|
+
"match_communities",
|
|
90
|
+
"reconcile_document",
|
|
91
|
+
"refresh_aggregates",
|
|
92
|
+
"remove_document",
|
|
93
|
+
"summarize_communities",
|
|
94
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared retrieval helpers for the local + themes graph paths (G2 file-size split).
|
|
2
|
+
|
|
3
|
+
Extracted from :mod:`brain.graph_rag.retrieve` (the G2 wave-boundary file-size
|
|
4
|
+
split, mirroring the G1 :mod:`brain.graph_rag.aggregates` / G2-c
|
|
5
|
+
:mod:`brain.graph_rag.relational` extractions) so the local-path/dispatch module
|
|
6
|
+
(:mod:`brain.graph_rag.retrieve`) and the themes module
|
|
7
|
+
(:mod:`brain.graph_rag.themes`) share ONE copy of the entity-row mapping +
|
|
8
|
+
ranked-document-to-:class:`~brain.search.SearchResult` snippet shaping. This is a
|
|
9
|
+
pure move — no behavior change.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
|
|
17
|
+
from .schema import GraphEntity
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..search import SearchResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fetch_entities(
|
|
24
|
+
conn: psycopg.Connection[Any], tenant_id: str, entity_ids: list[str]
|
|
25
|
+
) -> list[GraphEntity]:
|
|
26
|
+
"""Load ``graph_entities`` rows for the reached entity ids (tenant-scoped)."""
|
|
27
|
+
if not entity_ids:
|
|
28
|
+
return []
|
|
29
|
+
rows = conn.execute(
|
|
30
|
+
"SELECT id::text, entity_type, name, canonical_key, description, doc_count "
|
|
31
|
+
"FROM graph_entities WHERE tenant_id = %s AND id = ANY(%s)",
|
|
32
|
+
(tenant_id, entity_ids),
|
|
33
|
+
).fetchall()
|
|
34
|
+
return [_row_to_entity(row, tenant_id) for row in rows]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _row_to_entity(row: tuple[Any, ...], tenant_id: str) -> GraphEntity:
|
|
38
|
+
"""Map a ``(id, entity_type, name, canonical_key, description, doc_count)``
|
|
39
|
+
row to a :class:`GraphEntity` value object."""
|
|
40
|
+
return GraphEntity(
|
|
41
|
+
id=str(row[0]),
|
|
42
|
+
entity_type=str(row[1]),
|
|
43
|
+
name=str(row[2]),
|
|
44
|
+
canonical_key=str(row[3]),
|
|
45
|
+
tenant_id=tenant_id,
|
|
46
|
+
description=row[4],
|
|
47
|
+
doc_count=int(row[5]),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_doc_results(
|
|
52
|
+
conn: psycopg.Connection[Any],
|
|
53
|
+
query: str,
|
|
54
|
+
ranked: list[tuple[str, float]],
|
|
55
|
+
) -> list[SearchResult]:
|
|
56
|
+
"""Shape ranked documents into ``SearchResult``s, reusing the snippet path.
|
|
57
|
+
|
|
58
|
+
Reuses :data:`brain.search.SearchResult` (spec §4 D8 — graph docs may reuse
|
|
59
|
+
the search hit shape), :data:`brain.search.SNIPPET_LENGTH`, and
|
|
60
|
+
:func:`brain.search._build_tsquery` so snippet selection matches hybrid
|
|
61
|
+
search rather than reinventing it: per document, the best chunk by ``ts_rank``
|
|
62
|
+
for the query, falling back to the leading chunk (lowest ``chunk_index``)
|
|
63
|
+
when the query matches nothing. ``score`` carries the *graph* document score
|
|
64
|
+
(not an RRF score). Document order preserves the graph ranking.
|
|
65
|
+
"""
|
|
66
|
+
# Late import keeps :mod:`brain.graph_rag` import-cheap and free of any
|
|
67
|
+
# import cycle with the ingest pipeline that :mod:`brain.search` pulls in
|
|
68
|
+
# (mirrors the TYPE_CHECKING-only SearchResult reference in schema.py).
|
|
69
|
+
from ..search import SNIPPET_LENGTH, SearchResult, _build_tsquery
|
|
70
|
+
|
|
71
|
+
if not ranked:
|
|
72
|
+
return []
|
|
73
|
+
doc_ids = [doc_id for doc_id, _ in ranked]
|
|
74
|
+
scores = dict(ranked)
|
|
75
|
+
|
|
76
|
+
meta_rows = conn.execute(
|
|
77
|
+
"SELECT d.id::text, d.title, d.content_type, d.tags, s.kind "
|
|
78
|
+
"FROM documents d LEFT JOIN sources s ON s.id = d.source_id "
|
|
79
|
+
"WHERE d.id = ANY(%s)",
|
|
80
|
+
(doc_ids,),
|
|
81
|
+
).fetchall()
|
|
82
|
+
meta = {str(row[0]): row for row in meta_rows}
|
|
83
|
+
|
|
84
|
+
tsquery = _build_tsquery(conn, query)
|
|
85
|
+
snippet_rows = conn.execute(
|
|
86
|
+
"SELECT DISTINCT ON (c.document_id) c.document_id::text, c.content "
|
|
87
|
+
"FROM chunks c WHERE c.document_id = ANY(%s) "
|
|
88
|
+
"ORDER BY c.document_id, "
|
|
89
|
+
"ts_rank(c.tsv, to_tsquery('english', %s)) DESC, c.chunk_index ASC",
|
|
90
|
+
(doc_ids, tsquery),
|
|
91
|
+
).fetchall()
|
|
92
|
+
snippet_by_doc = {str(row[0]): str(row[1]) for row in snippet_rows}
|
|
93
|
+
|
|
94
|
+
results: list[SearchResult] = []
|
|
95
|
+
for doc_id in doc_ids: # preserve graph ranking order
|
|
96
|
+
row = meta.get(doc_id)
|
|
97
|
+
if row is None:
|
|
98
|
+
# A mention referenced a document with no surviving ``documents`` row.
|
|
99
|
+
# Defensive: ``ON DELETE CASCADE`` normally clears mentions first.
|
|
100
|
+
continue
|
|
101
|
+
snippet = snippet_by_doc.get(doc_id, "")[:SNIPPET_LENGTH]
|
|
102
|
+
results.append(
|
|
103
|
+
SearchResult(
|
|
104
|
+
document_id=doc_id,
|
|
105
|
+
title=row[1],
|
|
106
|
+
content_type=row[2],
|
|
107
|
+
tags=list(row[3] or []),
|
|
108
|
+
source_kind=row[4],
|
|
109
|
+
snippet=snippet,
|
|
110
|
+
score=scores[doc_id],
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return results
|