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/scans.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Contradiction + staleness scan orchestrators (Plan 03 — ``brain review scan``).
|
|
2
|
+
|
|
3
|
+
One reason to change: the scan algorithm. The SQL lives in
|
|
4
|
+
:mod:`brain.review.queries`; this module owns the pure-Python pipeline —
|
|
5
|
+
graph prefilter -> embedding prefilter -> LLM adjudication (conflicts only) ->
|
|
6
|
+
upsert. Logs entity names and doc ids at INFO level only; never summaries or
|
|
7
|
+
document bodies (privacy, CLAUDE.md security standards).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
|
|
19
|
+
from ..config import Config
|
|
20
|
+
from ..enrichment import ContradictionVerdict
|
|
21
|
+
from ..errors import OllamaUnavailable, ReviewError
|
|
22
|
+
from ..ingest import Embedder
|
|
23
|
+
from . import queries
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class ContradictionAssessor(Protocol):
|
|
30
|
+
"""The single enricher method the conflict scan depends on (DIP).
|
|
31
|
+
|
|
32
|
+
:class:`brain.enrichment.OllamaEnricher` satisfies this; the MCP layer wraps
|
|
33
|
+
it with a call-counter, and tests pass a fake — all without importing the
|
|
34
|
+
concrete enricher here.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def assess_contradiction(
|
|
38
|
+
self, *, subject: str, summaries: list[str]
|
|
39
|
+
) -> ContradictionVerdict: ...
|
|
40
|
+
|
|
41
|
+
# Embedding prefilter cap: at most this many of an entity's documents are
|
|
42
|
+
# pairwise-compared (Step 3). Bounds the pair count to C(10, 2) = 45 before the
|
|
43
|
+
# per-entity ``pairs_per_entity`` cap trims it further.
|
|
44
|
+
_MAX_DOCS_PER_ENTITY = 10
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ReviewFinding:
|
|
49
|
+
"""One contradiction or staleness finding produced by a scan.
|
|
50
|
+
|
|
51
|
+
``kind`` is the ``elicitation_gaps.signal_kind`` value
|
|
52
|
+
(``'contradiction'`` | ``'stale'``). ``target_type`` is the entity type for
|
|
53
|
+
conflicts and ``'doc'`` for staleness. ``target_id`` is the entity
|
|
54
|
+
``canonical_key`` (conflicts) or the stale document id (staleness).
|
|
55
|
+
``score`` is ``1.0`` for a confirmed conflict and the cosine similarity for
|
|
56
|
+
a stale finding. ``evidence_ids`` are the conflicting / superseded document
|
|
57
|
+
ids. Frozen — a finding is an immutable value object.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
kind: str
|
|
61
|
+
target_type: str
|
|
62
|
+
target_id: str
|
|
63
|
+
score: float
|
|
64
|
+
rationale: str
|
|
65
|
+
evidence_ids: list[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
69
|
+
"""Cosine similarity of two vectors in pure Python (no numpy).
|
|
70
|
+
|
|
71
|
+
Returns ``0.0`` when either vector is all-zero (degenerate, no direction) so
|
|
72
|
+
the caller never divides by zero. Identical vectors -> ``1.0``; orthogonal
|
|
73
|
+
-> ``0.0``.
|
|
74
|
+
"""
|
|
75
|
+
# strict=False: vectors are same-dim in practice; tolerate drift over the
|
|
76
|
+
# overlap rather than raising mid-scan.
|
|
77
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
78
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
79
|
+
norm_b = math.sqrt(sum(y * y for y in b))
|
|
80
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
81
|
+
return 0.0
|
|
82
|
+
return dot / (norm_a * norm_b)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _top_pairs(
|
|
86
|
+
doc_ids: Sequence[str],
|
|
87
|
+
embeddings: Mapping[str, Sequence[float]],
|
|
88
|
+
*,
|
|
89
|
+
sim_floor: float,
|
|
90
|
+
max_pairs: int,
|
|
91
|
+
) -> list[tuple[str, str, float]]:
|
|
92
|
+
"""Top ``max_pairs`` document pairs by cosine, keeping only pairs >= floor.
|
|
93
|
+
|
|
94
|
+
Only documents present in ``embeddings`` are paired (a missing embedding
|
|
95
|
+
can't be compared). Pairs below ``sim_floor`` are discarded — too topically
|
|
96
|
+
distant to possibly contradict. Returns ``(doc_a, doc_b, cosine)`` sorted by
|
|
97
|
+
cosine DESC. Pure logic; unit-tested without a DB.
|
|
98
|
+
"""
|
|
99
|
+
usable = [d for d in doc_ids if d in embeddings]
|
|
100
|
+
pairs: list[tuple[str, str, float]] = []
|
|
101
|
+
for i in range(len(usable)):
|
|
102
|
+
for j in range(i + 1, len(usable)):
|
|
103
|
+
doc_a, doc_b = usable[i], usable[j]
|
|
104
|
+
sim = _cosine(embeddings[doc_a], embeddings[doc_b])
|
|
105
|
+
if sim >= sim_floor:
|
|
106
|
+
pairs.append((doc_a, doc_b, sim))
|
|
107
|
+
pairs.sort(key=lambda p: p[2], reverse=True)
|
|
108
|
+
return pairs[:max_pairs]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_conflict_scan(
|
|
112
|
+
conn: psycopg.Connection[Any],
|
|
113
|
+
enricher: ContradictionAssessor,
|
|
114
|
+
embedder: Embedder,
|
|
115
|
+
cfg: Config,
|
|
116
|
+
*,
|
|
117
|
+
tenant_id: str,
|
|
118
|
+
dry_run: bool = False,
|
|
119
|
+
) -> list[ReviewFinding]:
|
|
120
|
+
"""Detect entities whose document summaries express contradictory positions.
|
|
121
|
+
|
|
122
|
+
Pipeline (spec §3): graph prefilter (entities with >= min_docs summarized
|
|
123
|
+
docs) -> idempotency skip (never re-adjudicate or overwrite an already
|
|
124
|
+
surfaced / snoozed / dismissed finding) -> embedding prefilter (top pairs
|
|
125
|
+
above the cosine floor) -> LLM adjudication via
|
|
126
|
+
:meth:`OllamaEnricher.assess_contradiction` -> upsert. At most one finding
|
|
127
|
+
per entity (the ``canonical_key`` is the unique target). When ``dry_run`` is
|
|
128
|
+
true the findings are computed and returned but never written.
|
|
129
|
+
|
|
130
|
+
``embedder`` is part of the documented scan signature for symmetry with the
|
|
131
|
+
ingest / search layer; the scan compares pre-stored lead-chunk embeddings,
|
|
132
|
+
so it never calls the embedder to vectorize new text.
|
|
133
|
+
|
|
134
|
+
Raises :class:`ReviewError` (carrying the findings written so far and the
|
|
135
|
+
processed / total counts) when Ollama becomes unreachable mid-scan — earlier
|
|
136
|
+
findings are committed before the error propagates.
|
|
137
|
+
"""
|
|
138
|
+
del embedder # documented-but-unused; see docstring.
|
|
139
|
+
candidates = queries.iter_entities_for_conflict_scan(
|
|
140
|
+
conn,
|
|
141
|
+
tenant_id=tenant_id,
|
|
142
|
+
min_docs=cfg.elicit_contradiction_min_docs,
|
|
143
|
+
limit=cfg.review_conflict_limit,
|
|
144
|
+
)
|
|
145
|
+
existing = queries.existing_finding_statuses(
|
|
146
|
+
conn, tenant_id=tenant_id, signal_kind="contradiction"
|
|
147
|
+
)
|
|
148
|
+
findings: list[ReviewFinding] = []
|
|
149
|
+
total = len(candidates)
|
|
150
|
+
processed = 0
|
|
151
|
+
for cand in candidates:
|
|
152
|
+
# Idempotency: any non-resolved row (surfaced / snoozed / dismissed)
|
|
153
|
+
# means we never re-adjudicate or overwrite. Only resolved / absent
|
|
154
|
+
# targets are rescanned.
|
|
155
|
+
if cand.canonical_key in existing:
|
|
156
|
+
continue
|
|
157
|
+
docs = cand.doc_ids[:_MAX_DOCS_PER_ENTITY]
|
|
158
|
+
embeddings = queries.fetch_best_chunk_embeddings(conn, document_ids=docs)
|
|
159
|
+
pairs = _top_pairs(
|
|
160
|
+
docs,
|
|
161
|
+
embeddings,
|
|
162
|
+
sim_floor=cfg.review_embed_sim_floor,
|
|
163
|
+
max_pairs=cfg.review_conflict_pairs_per_entity,
|
|
164
|
+
)
|
|
165
|
+
if pairs:
|
|
166
|
+
summaries = queries.fetch_doc_summaries(conn, document_ids=docs)
|
|
167
|
+
_logger.info(
|
|
168
|
+
"conflict scan: adjudicating entity %r (%d candidate pair(s))",
|
|
169
|
+
cand.name,
|
|
170
|
+
len(pairs),
|
|
171
|
+
)
|
|
172
|
+
finding = _adjudicate_entity(
|
|
173
|
+
conn,
|
|
174
|
+
enricher,
|
|
175
|
+
cand,
|
|
176
|
+
pairs,
|
|
177
|
+
summaries,
|
|
178
|
+
tenant_id=tenant_id,
|
|
179
|
+
dry_run=dry_run,
|
|
180
|
+
findings=findings,
|
|
181
|
+
processed=processed,
|
|
182
|
+
total=total,
|
|
183
|
+
)
|
|
184
|
+
if finding is not None:
|
|
185
|
+
findings.append(finding)
|
|
186
|
+
processed += 1
|
|
187
|
+
return findings
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _adjudicate_entity(
|
|
191
|
+
conn: psycopg.Connection[Any],
|
|
192
|
+
enricher: ContradictionAssessor,
|
|
193
|
+
cand: queries.EntityCandidate,
|
|
194
|
+
pairs: Sequence[tuple[str, str, float]],
|
|
195
|
+
summaries: Mapping[str, str],
|
|
196
|
+
*,
|
|
197
|
+
tenant_id: str,
|
|
198
|
+
dry_run: bool,
|
|
199
|
+
findings: list[ReviewFinding],
|
|
200
|
+
processed: int,
|
|
201
|
+
total: int,
|
|
202
|
+
) -> ReviewFinding | None:
|
|
203
|
+
"""Run the LLM on each surviving pair; return the first confirmed conflict.
|
|
204
|
+
|
|
205
|
+
Returns ``None`` when no pair is judged contradictory. On
|
|
206
|
+
:class:`OllamaUnavailable` commits the findings written so far (unless
|
|
207
|
+
``dry_run``) and raises :class:`ReviewError` with the partial result.
|
|
208
|
+
"""
|
|
209
|
+
for doc_a, doc_b, _sim in pairs:
|
|
210
|
+
summary_a = summaries.get(doc_a)
|
|
211
|
+
summary_b = summaries.get(doc_b)
|
|
212
|
+
if not summary_a or not summary_b:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
verdict = enricher.assess_contradiction(
|
|
216
|
+
subject=cand.name, summaries=[summary_a, summary_b]
|
|
217
|
+
)
|
|
218
|
+
except OllamaUnavailable as exc:
|
|
219
|
+
if not dry_run:
|
|
220
|
+
conn.commit()
|
|
221
|
+
raise ReviewError(
|
|
222
|
+
f"Ollama unavailable mid-scan: {exc}",
|
|
223
|
+
findings=findings,
|
|
224
|
+
processed=processed,
|
|
225
|
+
total=total,
|
|
226
|
+
) from exc
|
|
227
|
+
if verdict.contradicts:
|
|
228
|
+
if not dry_run:
|
|
229
|
+
queries.upsert_review_finding(
|
|
230
|
+
conn,
|
|
231
|
+
tenant_id=tenant_id,
|
|
232
|
+
signal_kind="contradiction",
|
|
233
|
+
target_type=cand.entity_type,
|
|
234
|
+
target_id=cand.canonical_key,
|
|
235
|
+
score=1.0,
|
|
236
|
+
evidence_ids=[doc_a, doc_b],
|
|
237
|
+
rationale=verdict.rationale,
|
|
238
|
+
)
|
|
239
|
+
return ReviewFinding(
|
|
240
|
+
kind="contradiction",
|
|
241
|
+
target_type=cand.entity_type,
|
|
242
|
+
target_id=cand.canonical_key,
|
|
243
|
+
score=1.0,
|
|
244
|
+
rationale=verdict.rationale,
|
|
245
|
+
evidence_ids=[doc_a, doc_b],
|
|
246
|
+
)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_staleness_scan(
|
|
251
|
+
conn: psycopg.Connection[Any],
|
|
252
|
+
embedder: Embedder,
|
|
253
|
+
cfg: Config,
|
|
254
|
+
*,
|
|
255
|
+
tenant_id: str,
|
|
256
|
+
dry_run: bool = False,
|
|
257
|
+
) -> list[ReviewFinding]:
|
|
258
|
+
"""Flag aged docs superseded by a newer doc sharing an entity (no LLM calls).
|
|
259
|
+
|
|
260
|
+
Pipeline (spec §3): age candidates (older than ``stale_age_days``,
|
|
261
|
+
summarized, non-transcript, non-draft) -> newer docs sharing an entity
|
|
262
|
+
within ``stale_supersede_window_days`` -> embedding similarity filter (keep
|
|
263
|
+
the best superseding doc with cosine >= ``stale_sim_floor``) -> idempotency
|
|
264
|
+
skip -> upsert. The score is the cosine similarity. When ``dry_run`` is true
|
|
265
|
+
the findings are computed and returned but never written.
|
|
266
|
+
|
|
267
|
+
``embedder`` is part of the documented scan signature for symmetry; the scan
|
|
268
|
+
compares pre-stored lead-chunk embeddings, so it never vectorizes new text.
|
|
269
|
+
"""
|
|
270
|
+
del embedder # documented-but-unused; see docstring.
|
|
271
|
+
candidates = queries.iter_docs_for_staleness_scan(
|
|
272
|
+
conn,
|
|
273
|
+
tenant_id=tenant_id,
|
|
274
|
+
stale_age_days=cfg.review_stale_age_days,
|
|
275
|
+
limit=cfg.review_stale_limit,
|
|
276
|
+
)
|
|
277
|
+
existing = queries.existing_finding_statuses(
|
|
278
|
+
conn, tenant_id=tenant_id, signal_kind="stale"
|
|
279
|
+
)
|
|
280
|
+
findings: list[ReviewFinding] = []
|
|
281
|
+
for cand in candidates:
|
|
282
|
+
if cand.doc_id in existing:
|
|
283
|
+
continue
|
|
284
|
+
newer = queries.fetch_superseding_docs(
|
|
285
|
+
conn,
|
|
286
|
+
tenant_id=tenant_id,
|
|
287
|
+
doc_id=cand.doc_id,
|
|
288
|
+
window_days=cfg.review_stale_supersede_window_days,
|
|
289
|
+
)
|
|
290
|
+
if not newer:
|
|
291
|
+
continue
|
|
292
|
+
best = _best_superseding(conn, cand, newer, sim_floor=cfg.review_stale_sim_floor)
|
|
293
|
+
if best is None:
|
|
294
|
+
continue
|
|
295
|
+
superseding, sim = best
|
|
296
|
+
rationale = (
|
|
297
|
+
f"Age: {cand.age_days} days. "
|
|
298
|
+
f"Superseded by: '{superseding.title}' (similarity {sim:.2f})"
|
|
299
|
+
)
|
|
300
|
+
_logger.info(
|
|
301
|
+
"stale scan: doc %s superseded by %s (cosine %.2f)",
|
|
302
|
+
cand.doc_id,
|
|
303
|
+
superseding.doc_id,
|
|
304
|
+
sim,
|
|
305
|
+
)
|
|
306
|
+
if not dry_run:
|
|
307
|
+
queries.upsert_review_finding(
|
|
308
|
+
conn,
|
|
309
|
+
tenant_id=tenant_id,
|
|
310
|
+
signal_kind="stale",
|
|
311
|
+
target_type="doc",
|
|
312
|
+
target_id=cand.doc_id,
|
|
313
|
+
score=sim,
|
|
314
|
+
evidence_ids=[cand.doc_id, superseding.doc_id],
|
|
315
|
+
rationale=rationale,
|
|
316
|
+
)
|
|
317
|
+
findings.append(
|
|
318
|
+
ReviewFinding(
|
|
319
|
+
kind="stale",
|
|
320
|
+
target_type="doc",
|
|
321
|
+
target_id=cand.doc_id,
|
|
322
|
+
score=sim,
|
|
323
|
+
rationale=rationale,
|
|
324
|
+
evidence_ids=[cand.doc_id, superseding.doc_id],
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
return findings
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _best_superseding(
|
|
331
|
+
conn: psycopg.Connection[Any],
|
|
332
|
+
cand: queries.StaleCandidate,
|
|
333
|
+
newer: Sequence[queries.SupersedingDoc],
|
|
334
|
+
*,
|
|
335
|
+
sim_floor: float,
|
|
336
|
+
) -> tuple[queries.SupersedingDoc, float] | None:
|
|
337
|
+
"""Most-similar superseding doc with cosine >= ``sim_floor`` (or ``None``).
|
|
338
|
+
|
|
339
|
+
Fetches the stale doc's lead-chunk embedding alongside every newer doc's in
|
|
340
|
+
one batched query, then ranks by cosine.
|
|
341
|
+
"""
|
|
342
|
+
ids = [cand.doc_id, *(n.doc_id for n in newer)]
|
|
343
|
+
embeddings = queries.fetch_best_chunk_embeddings(conn, document_ids=ids)
|
|
344
|
+
old_emb = embeddings.get(cand.doc_id)
|
|
345
|
+
if old_emb is None:
|
|
346
|
+
return None
|
|
347
|
+
best: tuple[queries.SupersedingDoc, float] | None = None
|
|
348
|
+
for superseding in newer:
|
|
349
|
+
new_emb = embeddings.get(superseding.doc_id)
|
|
350
|
+
if new_emb is None:
|
|
351
|
+
continue
|
|
352
|
+
sim = _cosine(old_emb, new_emb)
|
|
353
|
+
if sim >= sim_floor and (best is None or sim > best[1]):
|
|
354
|
+
best = (superseding, sim)
|
|
355
|
+
return best
|