thread-archive 0.0.2__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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Searchable-content extraction: an event's payload → FTS shadow rows.
|
|
2
|
+
|
|
3
|
+
Reads the payload dict keys directly — the importer's ``DefaultEventBuilder``
|
|
4
|
+
produces exactly these payload shapes, so there's no dependency on a dataclass
|
|
5
|
+
event-type hierarchy.
|
|
6
|
+
|
|
7
|
+
Each extractor returns ``(content, content_type, tool_name)`` tuples; an event
|
|
8
|
+
with no searchable content returns ``[]``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# Event types that carry searchable content (must match the dispatch below).
|
|
17
|
+
#
|
|
18
|
+
# NB: ``api_request_completed`` is deliberately *not* indexed. thread-archive never
|
|
19
|
+
# live-streams — every assistant turn is *imported* via DefaultEventBuilder, which
|
|
20
|
+
# emits the granular text_complete / thinking_complete events alongside an
|
|
21
|
+
# api_request_completed summary that duplicates them. Indexing both double-counts
|
|
22
|
+
# the same text, so we index the granular events and drop the summary. (A
|
|
23
|
+
# live-streaming path would need the summary, since its stream emits text_delta
|
|
24
|
+
# rather than text_complete — but that path doesn't apply here.)
|
|
25
|
+
INDEXABLE_EVENT_TYPES = [
|
|
26
|
+
"user_message_sent",
|
|
27
|
+
"text_complete",
|
|
28
|
+
"thinking_complete",
|
|
29
|
+
"tool_use_started",
|
|
30
|
+
"tool_use_complete",
|
|
31
|
+
"tool_execution_completed",
|
|
32
|
+
"context_summary",
|
|
33
|
+
"tool_execution_error",
|
|
34
|
+
"thread_message_sent",
|
|
35
|
+
"ide_context",
|
|
36
|
+
"content_block",
|
|
37
|
+
"message",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_str(value) -> str:
|
|
42
|
+
return value if isinstance(value, str) else json.dumps(value, default=str)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _block_search_text(block) -> str:
|
|
46
|
+
"""Best-effort human-readable text from an arbitrary content block, skipping
|
|
47
|
+
binary/base64 payloads (the ``source`` blob on image/document blocks). Used to
|
|
48
|
+
make preserved-but-unmodeled blocks (``content_block``) searchable without
|
|
49
|
+
indexing megabytes of base64."""
|
|
50
|
+
if isinstance(block, str):
|
|
51
|
+
return block
|
|
52
|
+
if not isinstance(block, dict):
|
|
53
|
+
return _to_str(block)
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
for key, value in block.items():
|
|
56
|
+
if key in ("type", "source"): # ``source`` carries base64 image/doc data
|
|
57
|
+
continue
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
if len(value) > 1000 and " " not in value[:100]:
|
|
60
|
+
continue # looks like an opaque/base64 blob
|
|
61
|
+
parts.append(value)
|
|
62
|
+
elif isinstance(value, (dict, list)):
|
|
63
|
+
parts.append(_to_str(value))
|
|
64
|
+
return " ".join(parts).strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _strip_frontmatter(text: str) -> str:
|
|
68
|
+
"""Strip YAML frontmatter (---\\n...\\n---) from markdown content."""
|
|
69
|
+
if text.startswith("---"):
|
|
70
|
+
end = text.find("---", 3)
|
|
71
|
+
if end != -1:
|
|
72
|
+
return text[end + 3:].lstrip()
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fts_tool_use(payload: dict) -> list[tuple[str, str, Optional[str]]]:
|
|
77
|
+
tool_name = payload.get("tool_name") or ""
|
|
78
|
+
tool_input = payload.get("input")
|
|
79
|
+
if tool_name.startswith("mcp__") and isinstance(tool_input, dict) and "command" in tool_input:
|
|
80
|
+
# Heredoc bodies span many lines; index the first line, truncated.
|
|
81
|
+
tool_name = tool_input["command"].split("\n", 1)[0][:200]
|
|
82
|
+
|
|
83
|
+
# Prioritize the real content keys over stringified metadata.
|
|
84
|
+
content_parts = [payload.get("tool_name") or ""]
|
|
85
|
+
if isinstance(tool_input, dict):
|
|
86
|
+
for key in ("content", "text", "body", "message", "query", "prompt", "description", "entry"):
|
|
87
|
+
val = tool_input.get(key)
|
|
88
|
+
if val and isinstance(val, str):
|
|
89
|
+
content_parts.append(_strip_frontmatter(val))
|
|
90
|
+
break
|
|
91
|
+
else:
|
|
92
|
+
content_parts.append(" ".join(f"{k}={v}" for k, v in tool_input.items()))
|
|
93
|
+
elif tool_input is not None:
|
|
94
|
+
content_parts.append(str(tool_input))
|
|
95
|
+
|
|
96
|
+
content = " ".join(content_parts).strip()
|
|
97
|
+
return [(content[:2000], "tool", tool_name or None)] if content else []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fts_tool_completed(payload: dict) -> list[tuple[str, str, Optional[str]]]:
|
|
101
|
+
output = payload.get("output")
|
|
102
|
+
tool_name = payload.get("tool_name")
|
|
103
|
+
if payload.get("is_error"):
|
|
104
|
+
error_text = output or payload.get("error", "")
|
|
105
|
+
return [(_to_str(error_text), "tool_error", tool_name)] if error_text else []
|
|
106
|
+
if output:
|
|
107
|
+
return [(_to_str(output), "tool_result", tool_name)]
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_fts_content(event_type: str, payload: dict) -> list[tuple[str, str, Optional[str]]]:
|
|
112
|
+
"""Extract searchable ``(content, content_type, tool_name)`` tuples from an event."""
|
|
113
|
+
if not payload:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
if event_type == "user_message_sent":
|
|
117
|
+
content = payload.get("content", "")
|
|
118
|
+
if not content:
|
|
119
|
+
return []
|
|
120
|
+
if content.lstrip().startswith("This session is being continued from a previous conversation"):
|
|
121
|
+
return [(content, "continuation_summary", None)]
|
|
122
|
+
return [(content, "user", None)]
|
|
123
|
+
|
|
124
|
+
if event_type == "api_request_completed":
|
|
125
|
+
results: list[tuple[str, str, Optional[str]]] = []
|
|
126
|
+
for block in payload.get("content_blocks", []) or []:
|
|
127
|
+
bt = block.get("type")
|
|
128
|
+
if bt == "thinking" and block.get("thinking"):
|
|
129
|
+
results.append((block["thinking"], "thinking", None))
|
|
130
|
+
elif bt == "text" and block.get("text"):
|
|
131
|
+
results.append((block["text"], "text", None))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
if event_type == "text_complete":
|
|
135
|
+
text = payload.get("text", "")
|
|
136
|
+
return [(text, "text", None)] if text else []
|
|
137
|
+
|
|
138
|
+
if event_type == "thinking_complete":
|
|
139
|
+
text = payload.get("text", "")
|
|
140
|
+
return [(text, "thinking", None)] if text else []
|
|
141
|
+
|
|
142
|
+
if event_type in ("tool_use_started", "tool_use_complete"):
|
|
143
|
+
return _fts_tool_use(payload)
|
|
144
|
+
|
|
145
|
+
if event_type == "tool_execution_completed":
|
|
146
|
+
return _fts_tool_completed(payload)
|
|
147
|
+
|
|
148
|
+
if event_type == "tool_execution_error":
|
|
149
|
+
error = payload.get("error", "")
|
|
150
|
+
return [(_to_str(error), "tool_error", payload.get("tool_name"))] if error else []
|
|
151
|
+
|
|
152
|
+
if event_type == "context_summary":
|
|
153
|
+
content = payload.get("content", "")
|
|
154
|
+
return [(content[:2000], "context_summary", None)] if content else []
|
|
155
|
+
|
|
156
|
+
if event_type == "thread_message_sent":
|
|
157
|
+
content = payload.get("content", "")
|
|
158
|
+
return [(content, "user", None)] if content else []
|
|
159
|
+
|
|
160
|
+
if event_type == "ide_context":
|
|
161
|
+
# Index the opened-file path / selection body so "what was I looking at"
|
|
162
|
+
# is searchable. file_path (when present) leads so a path query matches.
|
|
163
|
+
content = payload.get("content", "")
|
|
164
|
+
file_path = payload.get("file_path")
|
|
165
|
+
text = f"{file_path}\n{content}" if file_path else content
|
|
166
|
+
return [(text[:2000], "ide_context", None)] if text.strip() else []
|
|
167
|
+
|
|
168
|
+
if event_type == "content_block":
|
|
169
|
+
# An unmodeled block preserved verbatim; index its human-readable text.
|
|
170
|
+
text = _block_search_text(payload.get("data"))
|
|
171
|
+
content_type = payload.get("block_type") or "content_block"
|
|
172
|
+
return [(text[:2000], content_type, None)] if text.strip() else []
|
|
173
|
+
|
|
174
|
+
if event_type == "message":
|
|
175
|
+
# A preserved non-standard-role turn. Prefer its text; fall back to blocks.
|
|
176
|
+
content = payload.get("content", "")
|
|
177
|
+
content_type = payload.get("role") or "message"
|
|
178
|
+
if content.strip():
|
|
179
|
+
return [(content, content_type, None)]
|
|
180
|
+
blocks = payload.get("content_blocks") or []
|
|
181
|
+
text = " ".join(t for t in (_block_search_text(b) for b in blocks) if t)
|
|
182
|
+
return [(text[:2000], content_type, None)] if text.strip() else []
|
|
183
|
+
|
|
184
|
+
return []
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""In-process nomic embeddings (sentence-transformers) — the `[embeddings]` extra.
|
|
2
|
+
|
|
3
|
+
The in-process embedding server: nomic model, ``search_query:`` /
|
|
4
|
+
``search_document:`` prefixes, 768-dim ``list[float]`` out, ``None`` on failure.
|
|
5
|
+
Heavy (torch); gated to the extra. The base install is lexical-only:
|
|
6
|
+
``is_available()`` is False and the vector arm degrades out cleanly.
|
|
7
|
+
|
|
8
|
+
Self-consistency is the contract that matters — vectors indexed with this provider
|
|
9
|
+
must be *queried* with it (llama.cpp-nomic ≠ torch-nomic are different spaces). The
|
|
10
|
+
``space_key`` tags a vector set by model so a cached set is only reused in-space.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Cap input length before embedding. On the in-process torch path a batch of many
|
|
24
|
+
# 6000-char docs (≈1500 tokens each) spikes MPS memory — it hangs and crawls
|
|
25
|
+
# (~9 docs/s). 2048 chars keeps the gist of all but the longest code/tool dumps
|
|
26
|
+
# (mean doc is ~300 chars) and runs ~7× faster with no hang.
|
|
27
|
+
EMBEDDING_CHAR_CAP = 2048
|
|
28
|
+
_MODEL_NAME = os.environ.get("THREAD_ARCHIVE_EMBED_MODEL", "nomic-ai/nomic-embed-text-v1.5")
|
|
29
|
+
|
|
30
|
+
_model = None
|
|
31
|
+
_load_failed = False
|
|
32
|
+
# Serializes _load() so a background warm (mcp.server) and a concurrent first query can't
|
|
33
|
+
# both construct the heavy model at once (a transient double-load / memory spike).
|
|
34
|
+
_load_lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def model_name() -> str:
|
|
38
|
+
return _MODEL_NAME
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def space_key() -> str:
|
|
42
|
+
"""Identity of the embedding space — vectors are only comparable within one."""
|
|
43
|
+
return "local:" + _MODEL_NAME
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _device() -> str:
|
|
47
|
+
"""``$THREAD_ARCHIVE_EMBED_DEVICE`` if set, else the best accelerator (Apple
|
|
48
|
+
``mps`` / CUDA), falling back to ``cpu``. On Apple silicon the GPU is ~20× the
|
|
49
|
+
CPU — the difference between an hour and a day for the corpus embed."""
|
|
50
|
+
dev = os.environ.get("THREAD_ARCHIVE_EMBED_DEVICE")
|
|
51
|
+
if dev:
|
|
52
|
+
return dev
|
|
53
|
+
try:
|
|
54
|
+
import torch
|
|
55
|
+
|
|
56
|
+
if torch.backends.mps.is_available():
|
|
57
|
+
return "mps"
|
|
58
|
+
if torch.cuda.is_available():
|
|
59
|
+
return "cuda"
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return "cpu"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_available() -> bool:
|
|
66
|
+
"""True when sentence-transformers is importable (the extra is installed). Cheap —
|
|
67
|
+
does not load the model; a load failure degrades at call time."""
|
|
68
|
+
try:
|
|
69
|
+
if importlib.util.find_spec("sentence_transformers") is None:
|
|
70
|
+
return False
|
|
71
|
+
except ImportError:
|
|
72
|
+
return False
|
|
73
|
+
return not _load_failed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _hub_cache_dir() -> str:
|
|
77
|
+
"""The HF hub cache directory, resolved the way huggingface_hub does — but as a pure
|
|
78
|
+
filesystem path, with no `huggingface_hub` import. Called *before* we set the offline
|
|
79
|
+
env, so it must not pull in the hub (which freezes ``HF_HUB_OFFLINE`` at import)."""
|
|
80
|
+
if os.environ.get("HUGGINGFACE_HUB_CACHE"):
|
|
81
|
+
return os.environ["HUGGINGFACE_HUB_CACHE"]
|
|
82
|
+
if os.environ.get("HF_HOME"):
|
|
83
|
+
return os.path.join(os.environ["HF_HOME"], "hub")
|
|
84
|
+
return os.path.expanduser("~/.cache/huggingface/hub")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _model_cached() -> bool:
|
|
88
|
+
"""True when ``_MODEL_NAME`` is already in the HF hub cache (a non-empty ``snapshots/``).
|
|
89
|
+
Pure filesystem probe — no hub import — so it's safe to consult before going offline."""
|
|
90
|
+
folder = "models--" + _MODEL_NAME.replace("/", "--")
|
|
91
|
+
snaps = os.path.join(_hub_cache_dir(), folder, "snapshots")
|
|
92
|
+
try:
|
|
93
|
+
return os.path.isdir(snaps) and any(os.scandir(snaps))
|
|
94
|
+
except OSError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pin_offline_if_cached() -> None:
|
|
99
|
+
"""Pin the model load to the local cache once it's downloaded, so the hot path makes
|
|
100
|
+
NO Hub request: no revision re-resolve, no ``trust_remote_code`` re-fetch, no
|
|
101
|
+
unauthenticated-HF-Hub warning, and it still works with the network down. The model is
|
|
102
|
+
pinned (one fixed ``_MODEL_NAME``) — a Hub round-trip on every search buys nothing.
|
|
103
|
+
|
|
104
|
+
Skipped when the model isn't cached yet (so a first install still downloads it) or when
|
|
105
|
+
``THREAD_ARCHIVE_EMBED_ONLINE=1`` forces an online load (first download / deliberate
|
|
106
|
+
refresh). ``huggingface_hub`` freezes ``HF_HUB_OFFLINE`` into a module constant at
|
|
107
|
+
import, so set the env *before* it's imported and, if it already is, flip the live
|
|
108
|
+
constant too."""
|
|
109
|
+
if os.environ.get("THREAD_ARCHIVE_EMBED_ONLINE") == "1" or not _model_cached():
|
|
110
|
+
return
|
|
111
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
112
|
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
113
|
+
const = sys.modules.get("huggingface_hub.constants")
|
|
114
|
+
if const is not None:
|
|
115
|
+
const.HF_HUB_OFFLINE = True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load():
|
|
119
|
+
"""Lazily construct the cached SentenceTransformer (nomic needs trust_remote_code).
|
|
120
|
+
Double-checked under ``_load_lock`` so a background warm and a first query race to a
|
|
121
|
+
single construction, not two concurrent heavy loads."""
|
|
122
|
+
global _model, _load_failed
|
|
123
|
+
if _model is not None:
|
|
124
|
+
return _model
|
|
125
|
+
if _load_failed:
|
|
126
|
+
return None
|
|
127
|
+
with _load_lock:
|
|
128
|
+
if _model is not None:
|
|
129
|
+
return _model
|
|
130
|
+
if _load_failed:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
_pin_offline_if_cached()
|
|
134
|
+
from sentence_transformers import SentenceTransformer
|
|
135
|
+
|
|
136
|
+
device = _device()
|
|
137
|
+
_model = SentenceTransformer(_MODEL_NAME, trust_remote_code=True, device=device)
|
|
138
|
+
dim = getattr(_model, "get_embedding_dimension", _model.get_sentence_embedding_dimension)()
|
|
139
|
+
logger.info("embed: loaded %s (dim=%s, device=%s)", _MODEL_NAME, dim, device)
|
|
140
|
+
return _model
|
|
141
|
+
except Exception as e: # noqa: BLE001
|
|
142
|
+
_load_failed = True
|
|
143
|
+
logger.warning("embed: model load failed (%s) — vector arm degrades", e)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def warm() -> bool:
|
|
148
|
+
"""Eagerly load the embedding model so it isn't cold-loaded inside the first query.
|
|
149
|
+
Fail-soft and idempotent: returns False when the ``[embeddings]`` extra is absent or the
|
|
150
|
+
load fails (search then stays lexical, exactly as it does without warming)."""
|
|
151
|
+
if not is_available():
|
|
152
|
+
return False
|
|
153
|
+
return _load() is not None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cap(text: str) -> str:
|
|
157
|
+
return text[:EMBEDDING_CHAR_CAP]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _encode(prefixed: list[str]):
|
|
161
|
+
model = _load()
|
|
162
|
+
if model is None:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
# Un-normalized to match the contract — the vector store normalizes on write.
|
|
166
|
+
vecs = model.encode(prefixed, normalize_embeddings=False, convert_to_numpy=True)
|
|
167
|
+
return [v.astype("float32").tolist() for v in vecs]
|
|
168
|
+
except Exception as e: # noqa: BLE001
|
|
169
|
+
logger.warning("embed: encode failed (%s)", e)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def embed_query(text: str):
|
|
174
|
+
"""Embed a search query (nomic ``search_query:`` prefix). None on any failure."""
|
|
175
|
+
if not text or not text.strip():
|
|
176
|
+
return None
|
|
177
|
+
vecs = _encode([f"search_query: {_cap(text)}"])
|
|
178
|
+
return vecs[0] if vecs else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def embed_documents(texts: list[str]):
|
|
182
|
+
"""Embed indexed content (nomic ``search_document:`` prefix), in input order. None
|
|
183
|
+
on any failure (the caller writes nothing — never a partial/wrong-space batch)."""
|
|
184
|
+
if not texts:
|
|
185
|
+
return None
|
|
186
|
+
return _encode([f"search_document: {_cap(t)}" for t in texts])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Render search hits for the CLI / MCP.
|
|
2
|
+
|
|
3
|
+
Default render is a compact text list, but it carries the **match-quality signal**:
|
|
4
|
+
a top-line ``quality=`` verdict (strong / partial / weak / semantic) plus a ``K/N``
|
|
5
|
+
per hit — how many of the N query terms actually landed — so a reader can tell a
|
|
6
|
+
solid keyword hit from a nearest-neighbour guess before trusting it. ``output``
|
|
7
|
+
switches to ``count`` (a per-thread tally over the whole match pool) or ``linkable``
|
|
8
|
+
(JSON of event/thread ids for batch linking)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections import Counter
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import text as sa_text
|
|
17
|
+
|
|
18
|
+
from .._store import use_session
|
|
19
|
+
from . import rank as _rank
|
|
20
|
+
|
|
21
|
+
# output='count' wants a true tally, so the pipeline over-fetches to this cap; a
|
|
22
|
+
# pool that reaches it was truncated and the tally renders as a floor ("N+").
|
|
23
|
+
COUNT_FETCH_CAP = 1000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hit_text(h: dict) -> str:
|
|
27
|
+
return h.get("full_content") or h.get("snippet") or ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _term_hit_count(content: str, terms: list[str]) -> int:
|
|
31
|
+
"""How many of ``terms`` literally appear in ``content``. Terms ≥4 chars match
|
|
32
|
+
by substring; shorter terms must hit a word boundary (so 'go' doesn't match
|
|
33
|
+
'good'). Each term counts at most once."""
|
|
34
|
+
if not terms or not content:
|
|
35
|
+
return 0
|
|
36
|
+
c = content.lower()
|
|
37
|
+
n = 0
|
|
38
|
+
for t in terms:
|
|
39
|
+
if len(t) >= 4:
|
|
40
|
+
if t in c:
|
|
41
|
+
n += 1
|
|
42
|
+
elif re.search(r"\b" + re.escape(t) + r"\b", c):
|
|
43
|
+
n += 1
|
|
44
|
+
return n
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _search_quality(top_hit_count: int, n_terms: int, did_rerank: bool):
|
|
48
|
+
"""Verdict for the top hit → ``(quality, note)`` or None. Rerank wins (the order
|
|
49
|
+
is by-meaning, not keyword overlap); else zero overlap is ``weak``, ≥⌈2/3·N⌉
|
|
50
|
+
terms is ``strong``, in-between is ``partial``."""
|
|
51
|
+
if n_terms <= 0:
|
|
52
|
+
return None
|
|
53
|
+
if did_rerank:
|
|
54
|
+
return ("semantic", "ranked by meaning, not keyword overlap — confirm the top hit "
|
|
55
|
+
"actually answers the query before trusting it")
|
|
56
|
+
if top_hit_count == 0:
|
|
57
|
+
return ("weak", "no query term appears in the top hit — these are nearest-neighbour "
|
|
58
|
+
"guesses and the log may simply not contain this. Rephrase the concept "
|
|
59
|
+
"or switch data store; piling on more synonyms won't help")
|
|
60
|
+
strong_at = max(1, -(-2 * n_terms // 3)) # ceil(2/3 · n_terms)
|
|
61
|
+
if top_hit_count >= strong_at:
|
|
62
|
+
return ("strong", None)
|
|
63
|
+
return ("partial", "only some query terms matched the top hit — scan before trusting")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _query_terms(query: str) -> list[str]:
|
|
67
|
+
"""Ranking terms minus the pipe-OR token (which isn't a content term)."""
|
|
68
|
+
return [t for t in _rank.search_terms(query) if t and t != "|"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_count(hits: list[dict], query: str) -> str:
|
|
72
|
+
thread_counts = Counter(h["thread_id"] for h in hits)
|
|
73
|
+
with use_session() as s:
|
|
74
|
+
corpus = s.execute(
|
|
75
|
+
sa_text("SELECT count(*), count(DISTINCT thread_id) FROM event_search")
|
|
76
|
+
).one()
|
|
77
|
+
capped = len(hits) >= COUNT_FETCH_CAP
|
|
78
|
+
total = f"{len(hits)}+ (tally capped)" if capped else str(len(hits))
|
|
79
|
+
lines = [
|
|
80
|
+
f"Total: {total} results across {len(thread_counts)} threads",
|
|
81
|
+
f" (corpus: {corpus[0]:,} indexed events, {corpus[1]:,} threads)",
|
|
82
|
+
]
|
|
83
|
+
for tid, count in thread_counts.most_common():
|
|
84
|
+
title = next((h.get("thread_title") for h in hits if h["thread_id"] == tid), None)
|
|
85
|
+
lines.append(f" [{tid}] {title or '(untitled)'}: {count}")
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _format_linkable(hits: list[dict]) -> str:
|
|
90
|
+
out = []
|
|
91
|
+
for h in hits:
|
|
92
|
+
entry = {
|
|
93
|
+
"event_id": h["event_id"],
|
|
94
|
+
"thread_id": h["thread_id"],
|
|
95
|
+
"preview": (h.get("snippet") or "")[:80],
|
|
96
|
+
}
|
|
97
|
+
if "context_events" in h:
|
|
98
|
+
entry["context_events"] = h["context_events"]
|
|
99
|
+
out.append(entry)
|
|
100
|
+
return json.dumps(out, indent=2)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_results(hits: list[dict], query: str, *, output: str | None = None) -> str:
|
|
104
|
+
if output == "count":
|
|
105
|
+
return _format_count(hits, query)
|
|
106
|
+
if output == "linkable":
|
|
107
|
+
return _format_linkable(hits)
|
|
108
|
+
if not hits:
|
|
109
|
+
return f'No results for "{query}".'
|
|
110
|
+
|
|
111
|
+
terms = _query_terms(query)
|
|
112
|
+
n_terms = len(terms)
|
|
113
|
+
did_rerank = bool(hits[0].get("_did_rerank"))
|
|
114
|
+
verdict = _search_quality(_term_hit_count(_hit_text(hits[0]), terms), n_terms, did_rerank) if n_terms else None
|
|
115
|
+
|
|
116
|
+
header = f'{len(hits)} result(s) for "{query}"'
|
|
117
|
+
if verdict:
|
|
118
|
+
header += f" · quality={verdict[0]}"
|
|
119
|
+
lines = [header]
|
|
120
|
+
if verdict and verdict[1]:
|
|
121
|
+
lines.append(f" note: {verdict[1]}")
|
|
122
|
+
lines.append("")
|
|
123
|
+
|
|
124
|
+
for h in hits:
|
|
125
|
+
title = h.get("thread_title") or f"thread {h['thread_id']}"
|
|
126
|
+
ct = h.get("content_type") or h["event_type"]
|
|
127
|
+
head = f"[{h['thread_id']}/{h['event_id']}] {title} · {ct}"
|
|
128
|
+
if n_terms:
|
|
129
|
+
k = _term_hit_count(_hit_text(h), terms)
|
|
130
|
+
head += f" · {k}/{n_terms}"
|
|
131
|
+
if k == 0:
|
|
132
|
+
head += " (semantic)"
|
|
133
|
+
lines.append(head)
|
|
134
|
+
|
|
135
|
+
context = h.get("context")
|
|
136
|
+
if context: # context_lines: a numbered multi-line block replaces the snippet
|
|
137
|
+
lines.extend(f" {ln}" for ln in context.split("\n"))
|
|
138
|
+
else:
|
|
139
|
+
snippet = " ".join((h.get("snippet") or "").split())
|
|
140
|
+
if snippet:
|
|
141
|
+
lines.append(f" {snippet}")
|
|
142
|
+
|
|
143
|
+
ctx_events = h.get("context_events") or {}
|
|
144
|
+
for direction in ("before", "after"):
|
|
145
|
+
for ev in ctx_events.get(direction, []):
|
|
146
|
+
body = " ".join((ev.get("content") or "")[:200].split())
|
|
147
|
+
lines.append(f" ({direction} · {ev.get('content_type')}) {body}")
|
|
148
|
+
|
|
149
|
+
return "\n".join(lines)
|