@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0
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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +251 -0
- package/install_runtime.py +249 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +35 -6
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
package/indexer.py
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Indexador hash-manifest do Smart Tool (stdlib only). Não faz nenhuma chamada de rede —
|
|
3
|
+
`reindex()` recebe `embed_batch` injetado pelo chamador (o daemon, que sabe qual modelo/
|
|
4
|
+
credencial usar). Vetores ficam como BLOB (`array` de float32), sem numpy; a busca faz
|
|
5
|
+
similaridade de cosseno em Python puro.
|
|
6
|
+
"""
|
|
7
|
+
import array
|
|
8
|
+
import collections
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import operator
|
|
13
|
+
import os
|
|
14
|
+
import paths
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import atomic_io
|
|
23
|
+
import project_identity
|
|
24
|
+
import index_views
|
|
25
|
+
import embedding_cache
|
|
26
|
+
import document_text
|
|
27
|
+
|
|
28
|
+
INDEX_DIR = paths.DATA_DIR
|
|
29
|
+
CHUNK_LINES = 200
|
|
30
|
+
CHUNK_MAX_CHARS = 8000
|
|
31
|
+
CHUNK_OVERLAP_LINES = 12
|
|
32
|
+
CHUNKER_VERSION = 3
|
|
33
|
+
SCHEMA_VERSION = 2
|
|
34
|
+
REINDEX_BATCH_MAX_CHARS = 32_000
|
|
35
|
+
REINDEX_BATCH_MAX_ITEMS = 32
|
|
36
|
+
# Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
|
|
37
|
+
# 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
|
|
38
|
+
EMBED_WORKERS = 4
|
|
39
|
+
# Weaviate's relativeScoreFusion (default since v1.24, DefaultAlpha 0.75). Measured 2026-10-05 on the rerank gold set
|
|
40
|
+
# (42 code targets, docs/spikes/fusion_spike.py) against the previous RRF k=60: target in the 20 candidates 41 -> 42,
|
|
41
|
+
# first place without rerank 31 -> 35, final MRR after rerank 0.946 -> 0.969.
|
|
42
|
+
FUSION_ALPHA = 0.75
|
|
43
|
+
|
|
44
|
+
# `read_indexable` centraliza os filtros entre o escopo e hash → chunk → embed →
|
|
45
|
+
# sqlite. `compute_manifest`, prévia e reindexação usam a mesma leitura; arquivos
|
|
46
|
+
# inalterados reaproveitam a decisão por stat. As regras são mecânicas de
|
|
47
|
+
# propósito — o escopo por LLM decide *diretórios* (convenção varia por repo), mas
|
|
48
|
+
# "isto é texto que vale embeddar" e "isto é material de credencial" não variam.
|
|
49
|
+
MAX_INDEXABLE_BYTES = 1024 * 1024
|
|
50
|
+
MAX_SVG_INDEXABLE_BYTES = 64 * 1024
|
|
51
|
+
_SNIFF_BYTES = 8192
|
|
52
|
+
# Linha média muito longa é bundle/JSON minificado: passa o sniff de binário (é ASCII),
|
|
53
|
+
# mas viraria dezenas de chunks todos com start_line=1, inúteis como citação.
|
|
54
|
+
MAX_AVG_LINE_CHARS = 2000
|
|
55
|
+
|
|
56
|
+
_UTF16_BOMS = (b"\xff\xfe", b"\xfe\xff")
|
|
57
|
+
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
58
|
+
|
|
59
|
+
# Nunca indexado: o conteúdo vai para um endpoint externo de embeddings e fica em texto
|
|
60
|
+
# puro no sqlite3, e nenhuma das duas coisas se desfaz. Comparação sempre em minúsculas
|
|
61
|
+
# — o sistema de arquivos do Windows não distingue caixa, e um `set` distinguiria.
|
|
62
|
+
_SECRET_BASENAMES = frozenset({
|
|
63
|
+
"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "identity", "credentials",
|
|
64
|
+
"credentials.json", "service-account.json", "kubeconfig", "netrc", "_netrc",
|
|
65
|
+
"npmrc", "pgpass", "htpasswd", "master.key", "secrets.json", "secrets.yaml",
|
|
66
|
+
"secrets.yml", "local.settings.json",
|
|
67
|
+
})
|
|
68
|
+
_SECRET_EXTS = frozenset({
|
|
69
|
+
".pem", ".key", ".pfx", ".p12", ".jks", ".keystore", ".ppk", ".kdbx", ".asc",
|
|
70
|
+
".gpg", ".env", ".tfvars", ".har",
|
|
71
|
+
})
|
|
72
|
+
# Sobrevive a renome — é a única regra que não depende de adivinhar convenção alheia.
|
|
73
|
+
# Só vale no cabeçalho: arquivo de credencial ABRE com o marcador, enquanto código que
|
|
74
|
+
# trata desses formatos (este módulo, um scanner de segredo, um teste) menciona o mesmo
|
|
75
|
+
# literal no meio — e recusar esse código seria tirar fonte legítima do índice.
|
|
76
|
+
_SECRET_CONTENT_MARKERS = (
|
|
77
|
+
b"PRIVATE KEY-----", b"PuTTY-User-Key-File", b"aws_secret_access_key",
|
|
78
|
+
)
|
|
79
|
+
_SECRET_MARKER_WINDOW = 512
|
|
80
|
+
|
|
81
|
+
SKIP_DOTFILE = "dotfile"
|
|
82
|
+
SKIP_SECRET = "credential"
|
|
83
|
+
SKIP_TOO_LARGE = "over size limit"
|
|
84
|
+
SKIP_GRAPHIC = "large svg graphic"
|
|
85
|
+
SKIP_BINARY = "binary"
|
|
86
|
+
SKIP_MINIFIED = "minified"
|
|
87
|
+
SKIP_UNREADABLE = "unreadable"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
LEXICAL_MODEL = "lexical"
|
|
91
|
+
|
|
92
|
+
def _secret_name(name):
|
|
93
|
+
if name in _SECRET_BASENAMES:
|
|
94
|
+
return True
|
|
95
|
+
if os.path.splitext(name)[1] in _SECRET_EXTS:
|
|
96
|
+
return True
|
|
97
|
+
# `local.env`/`prod.env` caem na extensão; `env`/`env.local` não têm extensão nenhuma.
|
|
98
|
+
return name == "env" or name.startswith("env.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _text_encoding(prefix):
|
|
102
|
+
"""Encoding de leitura deduzido do BOM. UTF-16 é o default de `Out-File` no
|
|
103
|
+
PowerShell 5.1: decodificar como UTF-8 entregaria texto intercalado de `\\x00`."""
|
|
104
|
+
if prefix.startswith(_UTF16_BOMS):
|
|
105
|
+
return "utf-16"
|
|
106
|
+
if prefix.startswith(_UTF8_BOM):
|
|
107
|
+
return "utf-8-sig"
|
|
108
|
+
return "utf-8"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_indexable(root, rel_path):
|
|
112
|
+
"""`(bytes, encoding)` do arquivo, ou `(None, motivo)` quando ele não pode ser
|
|
113
|
+
indexado. Uma única leitura serve o hash do manifest e o chunking; arquivo grande e
|
|
114
|
+
binário são recusados antes de qualquer `read` integral."""
|
|
115
|
+
full = os.path.join(root, rel_path)
|
|
116
|
+
if not project_identity.within_root(root, full):
|
|
117
|
+
return None, SKIP_SECRET
|
|
118
|
+
parts = str(rel_path).replace("\\", "/").split("/")
|
|
119
|
+
if any(part.startswith(".") for part in parts if part not in (".", "..")):
|
|
120
|
+
return None, SKIP_DOTFILE
|
|
121
|
+
name = os.path.basename(rel_path).lower()
|
|
122
|
+
# Nome iniciado por ponto: mesma regra que `index_scope` aplica a diretório, e é
|
|
123
|
+
# onde vive metade dos arquivos de credencial (.env, .npmrc, .netrc).
|
|
124
|
+
if name.startswith("."):
|
|
125
|
+
return None, SKIP_DOTFILE
|
|
126
|
+
if _secret_name(name):
|
|
127
|
+
return None, SKIP_SECRET
|
|
128
|
+
try:
|
|
129
|
+
size = os.path.getsize(full)
|
|
130
|
+
if name.endswith(".svg") and size > MAX_SVG_INDEXABLE_BYTES:
|
|
131
|
+
return None, SKIP_GRAPHIC
|
|
132
|
+
limit = document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES
|
|
133
|
+
if size > limit:
|
|
134
|
+
return None, SKIP_TOO_LARGE
|
|
135
|
+
with open(full, "rb") as f:
|
|
136
|
+
data = f.read(limit + 1)
|
|
137
|
+
except OSError:
|
|
138
|
+
return None, SKIP_UNREADABLE
|
|
139
|
+
if len(data) > limit:
|
|
140
|
+
return None, SKIP_TOO_LARGE
|
|
141
|
+
if name.endswith('.docx'):
|
|
142
|
+
try:
|
|
143
|
+
data = document_text.extract_docx(data)
|
|
144
|
+
except document_text.DocumentError as exc:
|
|
145
|
+
return None, str(exc)
|
|
146
|
+
prefix = data[:_SNIFF_BYTES]
|
|
147
|
+
encoding = _text_encoding(prefix)
|
|
148
|
+
if encoding == "utf-16":
|
|
149
|
+
try:
|
|
150
|
+
prefix = data.decode("utf-16", "ignore")[:_SNIFF_BYTES].encode("utf-8")
|
|
151
|
+
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
152
|
+
return None, SKIP_BINARY
|
|
153
|
+
elif b"\0" in prefix:
|
|
154
|
+
return None, SKIP_BINARY
|
|
155
|
+
header = prefix[:_SECRET_MARKER_WINDOW]
|
|
156
|
+
if any(marker in header for marker in _SECRET_CONTENT_MARKERS):
|
|
157
|
+
return None, SKIP_SECRET
|
|
158
|
+
newlines = prefix.count(b"\n")
|
|
159
|
+
if newlines == 0 and len(prefix) >= _SNIFF_BYTES:
|
|
160
|
+
return None, SKIP_MINIFIED
|
|
161
|
+
if newlines and len(prefix) / (newlines + 1) > MAX_AVG_LINE_CHARS:
|
|
162
|
+
return None, SKIP_MINIFIED
|
|
163
|
+
return data, encoding
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_indexable(root, rel_path):
|
|
167
|
+
return read_indexable(root, rel_path)[0] is not None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _project_hash(root):
|
|
171
|
+
return index_views.storage_key(root)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def db_path(root):
|
|
175
|
+
os.makedirs(INDEX_DIR, exist_ok=True)
|
|
176
|
+
return os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_MIGRATION_LOCK = threading.RLock()
|
|
180
|
+
_VECTOR_CACHE_LOCK = threading.RLock()
|
|
181
|
+
_VECTOR_CACHE = collections.OrderedDict()
|
|
182
|
+
VECTOR_CACHE_MAX_BYTES = 64 * 1024 * 1024
|
|
183
|
+
VECTOR_CACHE_MAX_PROJECTS = 3
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class IndexCompatibilityError(RuntimeError):
|
|
187
|
+
"""Modelo/dimensão do índice não correspondem à consulta."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class IndexVectorError(RuntimeError):
|
|
191
|
+
"""Resposta de embeddings fora do contrato; lote não pode ser confirmado."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def existing_db_path(root):
|
|
195
|
+
canonical = os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
|
|
196
|
+
if os.path.isfile(canonical):
|
|
197
|
+
return canonical
|
|
198
|
+
if index_views.describe(root).get('git'):
|
|
199
|
+
return None # Um banco sem visão conhecida não pode responder por uma branch.
|
|
200
|
+
for key in project_identity.legacy_ids(root):
|
|
201
|
+
old = os.path.join(INDEX_DIR, key + ".sqlite3")
|
|
202
|
+
if os.path.isfile(old):
|
|
203
|
+
return old
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _readonly(path):
|
|
208
|
+
return sqlite3.connect(Path(path).absolute().as_uri() + "?mode=ro", uri=True, timeout=10)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _backup(source, target):
|
|
212
|
+
target = project_identity.state_path(target, INDEX_DIR)
|
|
213
|
+
temp = atomic_io.tmp_path_for(target)
|
|
214
|
+
src, dst = _readonly(source), sqlite3.connect(temp)
|
|
215
|
+
try:
|
|
216
|
+
src.backup(dst)
|
|
217
|
+
dst.commit()
|
|
218
|
+
finally:
|
|
219
|
+
src.close()
|
|
220
|
+
dst.close()
|
|
221
|
+
atomic_io.atomic_replace(temp, target)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def migrate_legacy(root):
|
|
225
|
+
"""Cópia consistente para a chave canônica; o banco legado permanece intacto."""
|
|
226
|
+
target = db_path(root)
|
|
227
|
+
with _MIGRATION_LOCK:
|
|
228
|
+
if not os.path.isfile(target):
|
|
229
|
+
old = existing_db_path(root)
|
|
230
|
+
if not old and index_views.describe(root).get('git'):
|
|
231
|
+
old = next((os.path.join(INDEX_DIR, key + '.sqlite3') for key in
|
|
232
|
+
[project_identity.project_id(root), *project_identity.legacy_ids(root)]
|
|
233
|
+
if os.path.isfile(os.path.join(INDEX_DIR, key + '.sqlite3'))), None)
|
|
234
|
+
if old:
|
|
235
|
+
_backup(old, target)
|
|
236
|
+
return target
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _open_path(path):
|
|
240
|
+
conn = sqlite3.connect(path, timeout=10)
|
|
241
|
+
# `chunks.text` é conteúdo de arquivo em texto puro: sem isto, um DELETE só desliga a
|
|
242
|
+
# página e o texto continua legível nas free pages do arquivo até um VACUUM.
|
|
243
|
+
conn.execute("PRAGMA secure_delete=ON")
|
|
244
|
+
conn.execute(
|
|
245
|
+
"CREATE TABLE IF NOT EXISTS manifest (path TEXT PRIMARY KEY, hash TEXT NOT NULL, "
|
|
246
|
+
"mtime_ns INTEGER NOT NULL DEFAULT 0, size INTEGER NOT NULL DEFAULT -1)"
|
|
247
|
+
)
|
|
248
|
+
conn.execute(
|
|
249
|
+
"CREATE TABLE IF NOT EXISTS chunks ("
|
|
250
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL, "
|
|
251
|
+
"start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, "
|
|
252
|
+
"text TEXT NOT NULL, embedding BLOB)"
|
|
253
|
+
)
|
|
254
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
|
|
255
|
+
conn.execute("CREATE TABLE IF NOT EXISTS file_state(path TEXT PRIMARY KEY, mtime_ns INTEGER NOT NULL, size INTEGER NOT NULL, reason TEXT NOT NULL)")
|
|
256
|
+
columns = {row[1] for row in conn.execute("PRAGMA table_info(manifest)")}
|
|
257
|
+
for column, sql_type, default in (("mtime_ns", "INTEGER", "0"), ("size", "INTEGER", "-1")):
|
|
258
|
+
if column not in columns:
|
|
259
|
+
conn.execute(f"ALTER TABLE manifest ADD COLUMN {column} {sql_type} NOT NULL DEFAULT {default}")
|
|
260
|
+
conn.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
261
|
+
try:
|
|
262
|
+
existed = conn.execute("SELECT 1 FROM sqlite_master WHERE name='chunks_fts'").fetchone()
|
|
263
|
+
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(path, text, content='chunks', content_rowid='id')")
|
|
264
|
+
conn.executescript("""
|
|
265
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
266
|
+
INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
|
|
267
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
268
|
+
INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text); END;
|
|
269
|
+
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
|
|
270
|
+
INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text);
|
|
271
|
+
INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
|
|
272
|
+
""")
|
|
273
|
+
if not existed:
|
|
274
|
+
conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
|
|
275
|
+
_set_metadata(conn, {"fts": True})
|
|
276
|
+
except sqlite3.OperationalError:
|
|
277
|
+
_set_metadata(conn, {"fts": False})
|
|
278
|
+
conn.commit()
|
|
279
|
+
return conn
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def open_db(root):
|
|
283
|
+
return _open_path(migrate_legacy(root))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _metadata(conn):
|
|
287
|
+
try:
|
|
288
|
+
return {key: json.loads(value) for key, value in conn.execute("SELECT key,value FROM metadata")}
|
|
289
|
+
except (sqlite3.Error, ValueError):
|
|
290
|
+
return {}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _set_metadata(conn, values):
|
|
294
|
+
conn.executemany("INSERT INTO metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE metadata.value != excluded.value",
|
|
295
|
+
[(key, json.dumps(value, ensure_ascii=False)) for key, value in values.items()])
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def info(root):
|
|
299
|
+
path = existing_db_path(root)
|
|
300
|
+
if not path:
|
|
301
|
+
return {"exists": False, "path": os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")}
|
|
302
|
+
try:
|
|
303
|
+
conn = _readonly(path)
|
|
304
|
+
try:
|
|
305
|
+
meta = _metadata(conn)
|
|
306
|
+
return {**meta, "exists": True, "path": path, "bytes": os.path.getsize(path),
|
|
307
|
+
"files": conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
|
|
308
|
+
"chunks": conn.execute("SELECT count(*) FROM chunks").fetchone()[0],
|
|
309
|
+
"legacy": not bool(meta.get("model_id"))}
|
|
310
|
+
finally:
|
|
311
|
+
conn.close()
|
|
312
|
+
except (OSError, sqlite3.Error) as exc:
|
|
313
|
+
return {"exists": True, "path": path, "error": type(exc).__name__, "legacy": True}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def manifest_snapshot(root):
|
|
317
|
+
path = existing_db_path(root)
|
|
318
|
+
if not path:
|
|
319
|
+
return {}
|
|
320
|
+
conn = _readonly(path)
|
|
321
|
+
try:
|
|
322
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE name='file_state'").fetchone():
|
|
323
|
+
return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
|
|
324
|
+
return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM manifest")}
|
|
325
|
+
except sqlite3.Error:
|
|
326
|
+
return {}
|
|
327
|
+
finally:
|
|
328
|
+
conn.close()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def compute_manifest(root, included_paths):
|
|
332
|
+
"""`(manifest, contagem de descartes por motivo)`. O manifest é a fonte de verdade do
|
|
333
|
+
cálculo completo de hashes. A reindexação usa `read_indexable` com os mesmos gates
|
|
334
|
+
ao detectar alteração por stat/evento; arquivos que deixam de se qualificar saem
|
|
335
|
+
do manifest e têm os chunks removidos."""
|
|
336
|
+
manifest = {}
|
|
337
|
+
skipped = {}
|
|
338
|
+
for rel in included_paths:
|
|
339
|
+
data, reason = read_indexable(root, rel)
|
|
340
|
+
if data is None:
|
|
341
|
+
skipped[reason] = skipped.get(reason, 0) + 1
|
|
342
|
+
continue
|
|
343
|
+
manifest[rel] = hashlib.sha256(data).hexdigest()
|
|
344
|
+
return manifest, skipped
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_manifest(conn):
|
|
348
|
+
return dict(conn.execute("SELECT path, hash FROM manifest").fetchall())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def diff_manifest(old, new):
|
|
352
|
+
added = [p for p in new if p not in old]
|
|
353
|
+
changed = [p for p in new if p in old and old[p] != new[p]]
|
|
354
|
+
removed = [p for p in old if p not in new]
|
|
355
|
+
return added, changed, removed
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_oversized(start_line, end_line, text, max_chars):
|
|
359
|
+
return [
|
|
360
|
+
(start_line, end_line, text[i:i + max_chars])
|
|
361
|
+
for i in range(0, len(text), max_chars)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def chunk_file(root, rel_path, max_lines=CHUNK_LINES, max_chars=CHUNK_MAX_CHARS, data=None, encoding=None):
|
|
366
|
+
"""`data`/`encoding` já resolvidos por `read_indexable` evitam reler o arquivo. Sem
|
|
367
|
+
eles, relê e redecide o encoding — e uma leitura que falhe devolve `None`, distinto
|
|
368
|
+
de `[]` (arquivo legítimo sem conteúdo), porque quem chama não pode gravar hash novo
|
|
369
|
+
de um conteúdo que não conseguiu ler."""
|
|
370
|
+
if data is None:
|
|
371
|
+
data, encoding = read_indexable(root, rel_path)
|
|
372
|
+
if data is None:
|
|
373
|
+
return None
|
|
374
|
+
try:
|
|
375
|
+
text = data.decode(encoding or "utf-8", "ignore")
|
|
376
|
+
except (LookupError, UnicodeDecodeError):
|
|
377
|
+
return None
|
|
378
|
+
lines = text.splitlines(keepends=True)
|
|
379
|
+
if max_lines < 1 or max_chars < 1:
|
|
380
|
+
raise ValueError("Chunk limits must be positive.")
|
|
381
|
+
boundary = re.compile(r"^(?:\s*(?:async\s+)?(?:def|class|function)\s|export\s|#{1,6}\s)")
|
|
382
|
+
chunks, start = [], 0
|
|
383
|
+
while start < len(lines):
|
|
384
|
+
if len(lines[start]) > max_chars:
|
|
385
|
+
chunks.extend(_split_oversized(start + 1, start + 1, lines[start], max_chars))
|
|
386
|
+
start += 1
|
|
387
|
+
continue
|
|
388
|
+
end, chars = start, 0
|
|
389
|
+
while end < len(lines) and end - start < max_lines and chars + len(lines[end]) <= max_chars:
|
|
390
|
+
chars += len(lines[end])
|
|
391
|
+
end += 1
|
|
392
|
+
# Prefere terminar antes de uma nova função/seção, mantendo os limites reais
|
|
393
|
+
# de linha em cada fragmento. Uma pequena sobreposição preserva contexto.
|
|
394
|
+
if end < len(lines) and end - start > 40:
|
|
395
|
+
breaks = [i for i in range(start + 30, end) if boundary.match(lines[i])]
|
|
396
|
+
if breaks:
|
|
397
|
+
end = breaks[-1]
|
|
398
|
+
chunks.append((start + 1, end, "".join(lines[start:end])))
|
|
399
|
+
if end == len(lines):
|
|
400
|
+
break
|
|
401
|
+
overlap = min(CHUNK_OVERLAP_LINES, (end - start) // 5)
|
|
402
|
+
start = max(start + 1, end - overlap)
|
|
403
|
+
return chunks
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _pack_vector(vector):
|
|
407
|
+
validate_vector(vector)
|
|
408
|
+
try:
|
|
409
|
+
packed = array.array("f", vector)
|
|
410
|
+
except (OverflowError, TypeError, ValueError) as exc:
|
|
411
|
+
raise IndexVectorError("Embedding does not fit in float32.") from exc
|
|
412
|
+
if not all(math.isfinite(value) for value in packed):
|
|
413
|
+
raise IndexVectorError("Embedding contains a non-finite float32 value.")
|
|
414
|
+
return packed.tobytes()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _unpack_vector(blob):
|
|
418
|
+
arr = array.array("f")
|
|
419
|
+
arr.frombytes(blob)
|
|
420
|
+
return arr
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
_sumprod = getattr(math, "sumprod", None) or (lambda a, b: sum(map(operator.mul, a, b)))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def cosine_similarity(a, b):
|
|
427
|
+
if len(a) != len(b):
|
|
428
|
+
raise IndexCompatibilityError(f"Incompatible dimensions: query={len(a)}, index={len(b)}.")
|
|
429
|
+
norm_a = math.sqrt(_sumprod(a, a))
|
|
430
|
+
norm_b = math.sqrt(_sumprod(b, b))
|
|
431
|
+
if norm_a == 0 or norm_b == 0:
|
|
432
|
+
return 0.0
|
|
433
|
+
return _sumprod(a, b) / (norm_a * norm_b)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def validate_vector(vector, dimensions=None):
|
|
437
|
+
if not isinstance(vector, (list, tuple, array.array)) or not vector:
|
|
438
|
+
raise IndexVectorError("Embedding must be a non-empty numeric vector.")
|
|
439
|
+
if dimensions not in (None, 0) and len(vector) != dimensions:
|
|
440
|
+
raise IndexCompatibilityError(f"Embedding has {len(vector)} dimensions; expected {dimensions}.")
|
|
441
|
+
if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
|
|
442
|
+
for value in vector):
|
|
443
|
+
raise IndexVectorError("Embedding contains an invalid or non-finite value.")
|
|
444
|
+
return len(vector)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def replace_file_chunks(conn, rel_path, chunks_with_vectors):
|
|
448
|
+
conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
|
|
449
|
+
conn.executemany(
|
|
450
|
+
"INSERT INTO chunks (path, start_line, end_line, text, embedding) VALUES (?, ?, ?, ?, ?)",
|
|
451
|
+
[
|
|
452
|
+
(rel_path, start, end, text, None if vector is None else _pack_vector(vector))
|
|
453
|
+
for (start, end, text), vector in chunks_with_vectors
|
|
454
|
+
],
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def remove_file(conn, rel_path):
|
|
459
|
+
conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
|
|
460
|
+
conn.execute("DELETE FROM manifest WHERE path = ?", (rel_path,))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _file_state(conn, rel_path, stat, reason="indexed"):
|
|
464
|
+
conn.execute("INSERT INTO file_state VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET "
|
|
465
|
+
"mtime_ns=excluded.mtime_ns,size=excluded.size,reason=excluded.reason",
|
|
466
|
+
(rel_path, *stat, reason))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def upsert_manifest_entry(conn, rel_path, file_hash, mtime_ns=0, size=-1):
|
|
470
|
+
conn.execute(
|
|
471
|
+
"INSERT INTO manifest (path, hash, mtime_ns, size) VALUES (?, ?, ?, ?) "
|
|
472
|
+
"ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, mtime_ns=excluded.mtime_ns, size=excluded.size",
|
|
473
|
+
(rel_path, file_hash, mtime_ns, size),
|
|
474
|
+
)
|
|
475
|
+
_file_state(conn, rel_path, (mtime_ns, size))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _stat_candidate(root, rel):
|
|
479
|
+
full = os.path.join(root, rel)
|
|
480
|
+
name = os.path.basename(rel).lower()
|
|
481
|
+
if not project_identity.within_root(root, full):
|
|
482
|
+
return None, SKIP_SECRET
|
|
483
|
+
if any(part.startswith(".") for part in str(rel).replace("\\", "/").split("/")
|
|
484
|
+
if part not in (".", "..")):
|
|
485
|
+
return None, SKIP_DOTFILE
|
|
486
|
+
if _secret_name(name):
|
|
487
|
+
return None, SKIP_SECRET
|
|
488
|
+
try:
|
|
489
|
+
stat = os.stat(full)
|
|
490
|
+
if not os.path.isfile(full):
|
|
491
|
+
return None, SKIP_UNREADABLE
|
|
492
|
+
except OSError:
|
|
493
|
+
return None, SKIP_UNREADABLE
|
|
494
|
+
if stat.st_size > (document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES):
|
|
495
|
+
return None, SKIP_TOO_LARGE
|
|
496
|
+
if name.endswith(".svg") and stat.st_size > MAX_SVG_INDEXABLE_BYTES:
|
|
497
|
+
return None, SKIP_GRAPHIC
|
|
498
|
+
return (stat.st_mtime_ns, stat.st_size), None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _stable_read(root, rel):
|
|
502
|
+
for _ in range(2):
|
|
503
|
+
before, reason = _stat_candidate(root, rel)
|
|
504
|
+
if before is None:
|
|
505
|
+
return None, reason, None
|
|
506
|
+
data, encoding = read_indexable(root, rel)
|
|
507
|
+
if data is None:
|
|
508
|
+
return None, encoding, before
|
|
509
|
+
after, _ = _stat_candidate(root, rel)
|
|
510
|
+
if after == before and (str(rel).lower().endswith('.docx') or len(data) == before[1]):
|
|
511
|
+
return data, encoding, before
|
|
512
|
+
return None, "file changed while reading", None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def invalidate_cache(root):
|
|
516
|
+
with _VECTOR_CACHE_LOCK:
|
|
517
|
+
for key in list(_VECTOR_CACHE):
|
|
518
|
+
if key[0] == project_identity.canonical_root(root):
|
|
519
|
+
_VECTOR_CACHE.pop(key, None)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def reindex(root, included_paths, embed_batch, *args, **kwargs):
|
|
523
|
+
with index_views.pin(root) as view:
|
|
524
|
+
return _reindex(root, included_paths, embed_batch, *args, **kwargs)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _split_batches(texts, max_items, max_chars):
|
|
528
|
+
batches, batch, size = [], [], 0
|
|
529
|
+
for text in texts:
|
|
530
|
+
if batch and (len(batch) >= max_items or size + len(text) > max_chars):
|
|
531
|
+
batches.append(batch)
|
|
532
|
+
batch, size = [], 0
|
|
533
|
+
batch.append(text)
|
|
534
|
+
size += len(text)
|
|
535
|
+
if batch:
|
|
536
|
+
batches.append(batch)
|
|
537
|
+
return batches
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _reindex(root, included_paths, embed_batch, chunk_lines=CHUNK_LINES, on_progress=None,
|
|
541
|
+
batch_max_chars=REINDEX_BATCH_MAX_CHARS, batch_max_items=REINDEX_BATCH_MAX_ITEMS,
|
|
542
|
+
model_id=None, expected_dimensions=None, force_rebuild=False, full_check=False,
|
|
543
|
+
changed_paths=None, cancel_check=None):
|
|
544
|
+
"""Atualiza snapshots por arquivo; um modelo novo só substitui a geração após sucesso."""
|
|
545
|
+
if batch_max_chars < 1 or batch_max_items < 1:
|
|
546
|
+
raise ValueError("Indexing batch limits must be positive.")
|
|
547
|
+
view = index_views.describe(root)
|
|
548
|
+
def check():
|
|
549
|
+
if cancel_check:
|
|
550
|
+
cancel_check()
|
|
551
|
+
index_views.assert_current(view)
|
|
552
|
+
root = project_identity.display_root(root)
|
|
553
|
+
model_id = model_id or "unspecified"
|
|
554
|
+
target = migrate_legacy(root)
|
|
555
|
+
current = info(root)
|
|
556
|
+
# Checkout e commit exigem confronto de conteúdo, mesmo com timestamps iguais.
|
|
557
|
+
if view.get('git') and (current.get('git_commit') != view.get('commit') or
|
|
558
|
+
current.get('view_id') != view.get('view_id')):
|
|
559
|
+
full_check = True
|
|
560
|
+
compatible = (current.get("model_id") == model_id and
|
|
561
|
+
current.get("chunker_version") == CHUNKER_VERSION and
|
|
562
|
+
current.get("schema_version") == SCHEMA_VERSION and
|
|
563
|
+
current.get("chunk_lines") == chunk_lines and
|
|
564
|
+
(expected_dimensions is None or current.get("dimensions") in (0, expected_dimensions)))
|
|
565
|
+
building = force_rebuild or not compatible
|
|
566
|
+
signature = hashlib.sha256(json.dumps([model_id, expected_dimensions, CHUNKER_VERSION,
|
|
567
|
+
SCHEMA_VERSION, chunk_lines]).encode()).hexdigest()[:12]
|
|
568
|
+
work_path = target[:-8] + f".building-{signature}.sqlite3" if building else target
|
|
569
|
+
project_identity.state_path(work_path, INDEX_DIR)
|
|
570
|
+
conn = _open_path(work_path)
|
|
571
|
+
stats = {"added": 0, "changed": 0, "removed": 0, "failed": 0,
|
|
572
|
+
"unchanged": 0, "skipped": {}, "rebuilt": building, "ready": False}
|
|
573
|
+
cache = None
|
|
574
|
+
try:
|
|
575
|
+
meta = _metadata(conn)
|
|
576
|
+
if meta.get("model_id") not in (None, model_id):
|
|
577
|
+
raise IndexCompatibilityError("Temporary generation belongs to another model.")
|
|
578
|
+
dimensions = meta.get("dimensions") or expected_dimensions or 0
|
|
579
|
+
contract = {'model': model_id, 'dimensions': dimensions, 'chunker': CHUNKER_VERSION,
|
|
580
|
+
'chunk_lines': chunk_lines, 'preprocessing': 1}
|
|
581
|
+
cache = embedding_cache.VectorCache(root, INDEX_DIR, contract)
|
|
582
|
+
# Alimenta o cache com o banco existente somente quando o contrato é conhecido.
|
|
583
|
+
if compatible and dimensions and not meta.get('vector_cache_seeded'):
|
|
584
|
+
for text, blob in conn.execute('SELECT text,embedding FROM chunks WHERE embedding IS NOT NULL'):
|
|
585
|
+
vector = _unpack_vector(blob)
|
|
586
|
+
validate_vector(vector, dimensions)
|
|
587
|
+
cache.put(text, vector)
|
|
588
|
+
cache.db.commit()
|
|
589
|
+
_set_metadata(conn, {'vector_cache_seeded': True})
|
|
590
|
+
generation = meta.get("generation") or uuid.uuid4().hex
|
|
591
|
+
_set_metadata(conn, {"root": project_identity.canonical_root(root), "model_id": model_id,
|
|
592
|
+
"dimensions": dimensions, "schema_version": SCHEMA_VERSION,
|
|
593
|
+
"chunker_version": CHUNKER_VERSION, "chunk_lines": chunk_lines,
|
|
594
|
+
"generation": generation})
|
|
595
|
+
conn.commit()
|
|
596
|
+
old = {r[0]: (r[1], r[2], r[3]) for r in conn.execute("SELECT path,hash,mtime_ns,size FROM manifest")}
|
|
597
|
+
old_state = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
|
|
598
|
+
observed, candidates = {}, []
|
|
599
|
+
forced = {str(p).replace("\\", "/").casefold().strip("/") for p in (changed_paths or [])}
|
|
600
|
+
for rel in sorted(set(included_paths)):
|
|
601
|
+
check()
|
|
602
|
+
stat, reason = _stat_candidate(root, rel)
|
|
603
|
+
if stat is None:
|
|
604
|
+
stats["skipped"][reason] = stats["skipped"].get(reason, 0) + 1
|
|
605
|
+
continue
|
|
606
|
+
observed[rel] = stat
|
|
607
|
+
force_file = any(rel.casefold() == p or rel.casefold().startswith(p + "/") for p in forced)
|
|
608
|
+
extractor_changed = rel.lower().endswith('.docx') and meta.get('document_extractor_version') != document_text.VERSION
|
|
609
|
+
if (full_check or building or force_file or extractor_changed or old_state.get(rel) != stat):
|
|
610
|
+
candidates.append(rel)
|
|
611
|
+
else:
|
|
612
|
+
stats["unchanged"] += 1
|
|
613
|
+
for rel in set(old) - set(observed):
|
|
614
|
+
remove_file(conn, rel)
|
|
615
|
+
stats["removed"] += 1
|
|
616
|
+
for rel in set(old_state) - set(observed):
|
|
617
|
+
conn.execute("DELETE FROM file_state WHERE path=?", (rel,))
|
|
618
|
+
conn.commit()
|
|
619
|
+
processed, total = 0, len(candidates)
|
|
620
|
+
if on_progress:
|
|
621
|
+
on_progress(0, total)
|
|
622
|
+
pending, texts, chars = [], [], 0
|
|
623
|
+
|
|
624
|
+
def progress():
|
|
625
|
+
if on_progress:
|
|
626
|
+
on_progress(processed, total)
|
|
627
|
+
|
|
628
|
+
def flush():
|
|
629
|
+
nonlocal pending, texts, chars, processed, dimensions
|
|
630
|
+
if not pending:
|
|
631
|
+
return
|
|
632
|
+
if embed_batch is None:
|
|
633
|
+
vectors = [None] * len(texts)
|
|
634
|
+
else:
|
|
635
|
+
def guarded(part):
|
|
636
|
+
check()
|
|
637
|
+
return embed_batch(part)
|
|
638
|
+
|
|
639
|
+
check()
|
|
640
|
+
vectors = cache.embed(texts, guarded, validate_vector, use_cache=not force_rebuild,
|
|
641
|
+
split=lambda items: _split_batches(items, batch_max_items, batch_max_chars),
|
|
642
|
+
workers=EMBED_WORKERS)
|
|
643
|
+
check()
|
|
644
|
+
if len(vectors) != len(texts):
|
|
645
|
+
raise IndexVectorError("Embedding count differs from the batch sent.")
|
|
646
|
+
for vector in vectors:
|
|
647
|
+
dimension = validate_vector(vector, dimensions)
|
|
648
|
+
if not dimensions:
|
|
649
|
+
dimensions = dimension
|
|
650
|
+
offset = 0
|
|
651
|
+
for rel, chunks, digest, stat in pending:
|
|
652
|
+
check()
|
|
653
|
+
count = len(chunks)
|
|
654
|
+
replace_file_chunks(conn, rel, zip(chunks, vectors[offset:offset + count]))
|
|
655
|
+
upsert_manifest_entry(conn, rel, digest, *stat)
|
|
656
|
+
_set_metadata(conn, {"dimensions": dimensions, "updated_at": time.time()})
|
|
657
|
+
conn.commit()
|
|
658
|
+
offset += count
|
|
659
|
+
processed += 1
|
|
660
|
+
progress()
|
|
661
|
+
pending, texts, chars = [], [], 0
|
|
662
|
+
|
|
663
|
+
for rel in candidates:
|
|
664
|
+
check()
|
|
665
|
+
data, encoding, stat = _stable_read(root, rel)
|
|
666
|
+
if data is None:
|
|
667
|
+
flush()
|
|
668
|
+
stats["skipped"][encoding] = stats["skipped"].get(encoding, 0) + 1
|
|
669
|
+
if encoding in (SKIP_UNREADABLE, "file changed while reading"):
|
|
670
|
+
stats["failed"] += 1
|
|
671
|
+
elif stat is not None:
|
|
672
|
+
_file_state(conn, rel, stat, encoding)
|
|
673
|
+
if rel in old:
|
|
674
|
+
remove_file(conn, rel)
|
|
675
|
+
conn.commit()
|
|
676
|
+
processed += 1
|
|
677
|
+
progress()
|
|
678
|
+
continue
|
|
679
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
680
|
+
if rel in old and digest == old[rel][0]:
|
|
681
|
+
upsert_manifest_entry(conn, rel, digest, *stat)
|
|
682
|
+
conn.commit()
|
|
683
|
+
stats["unchanged"] += 1
|
|
684
|
+
processed += 1
|
|
685
|
+
progress()
|
|
686
|
+
continue
|
|
687
|
+
chunks = chunk_file(root, rel, chunk_lines, data=data, encoding=encoding)
|
|
688
|
+
stats["changed" if rel in old else "added"] += 1
|
|
689
|
+
count_chars = sum(len(chunk[2]) for chunk in chunks)
|
|
690
|
+
if pending and (len(texts) + len(chunks) > batch_max_items * EMBED_WORKERS
|
|
691
|
+
or chars + count_chars > batch_max_chars * EMBED_WORKERS):
|
|
692
|
+
flush()
|
|
693
|
+
pending.append((rel, chunks, digest, stat))
|
|
694
|
+
texts.extend(chunk[2] for chunk in chunks)
|
|
695
|
+
chars += count_chars
|
|
696
|
+
if len(texts) >= batch_max_items * EMBED_WORKERS or chars >= batch_max_chars * EMBED_WORKERS:
|
|
697
|
+
flush()
|
|
698
|
+
flush()
|
|
699
|
+
check()
|
|
700
|
+
if full_check or building:
|
|
701
|
+
_set_metadata(conn, {"full_scan_at": time.time()})
|
|
702
|
+
_set_metadata(conn, {"dimensions": dimensions})
|
|
703
|
+
_set_metadata(conn, {'document_extractor_version':document_text.VERSION})
|
|
704
|
+
_set_metadata(conn, {'view_id': view['view_id'], 'git_branch': view.get('branch'),
|
|
705
|
+
'git_commit': view.get('commit'), 'view_label': view['label']})
|
|
706
|
+
if building or stats["added"] or stats["changed"] or stats["removed"]:
|
|
707
|
+
_set_metadata(conn, {"updated_at": time.time()})
|
|
708
|
+
conn.commit()
|
|
709
|
+
stats.update(generation=generation, dimensions=dimensions,
|
|
710
|
+
embedded_chunks=cache.misses, reused_chunks=cache.hits,
|
|
711
|
+
embedded_characters=cache.characters, view=index_views.public(view),
|
|
712
|
+
files=conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
|
|
713
|
+
chunks=conn.execute("SELECT count(*) FROM chunks").fetchone()[0])
|
|
714
|
+
except BaseException:
|
|
715
|
+
conn.rollback()
|
|
716
|
+
raise
|
|
717
|
+
finally:
|
|
718
|
+
conn.close()
|
|
719
|
+
if cache:
|
|
720
|
+
cache.close()
|
|
721
|
+
check()
|
|
722
|
+
if building and not stats["failed"]:
|
|
723
|
+
if os.path.isfile(target):
|
|
724
|
+
_backup(target, target[:-8] + ".previous.sqlite3")
|
|
725
|
+
atomic_io.atomic_replace(work_path, project_identity.state_path(target, INDEX_DIR))
|
|
726
|
+
stats["ready"] = not bool(stats["failed"])
|
|
727
|
+
stats["path"] = target if stats["ready"] else work_path
|
|
728
|
+
if building or total or stats["removed"] or full_check:
|
|
729
|
+
invalidate_cache(root)
|
|
730
|
+
return stats
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _vector_rows(root, path):
|
|
734
|
+
stat = os.stat(path)
|
|
735
|
+
key = (project_identity.canonical_root(root), path, stat.st_mtime_ns, stat.st_size)
|
|
736
|
+
with _VECTOR_CACHE_LOCK:
|
|
737
|
+
cached = _VECTOR_CACHE.get(key)
|
|
738
|
+
if cached is not None:
|
|
739
|
+
_VECTOR_CACHE.move_to_end(key)
|
|
740
|
+
return cached[0], cached[1], cached[3]
|
|
741
|
+
conn = _readonly(path)
|
|
742
|
+
try:
|
|
743
|
+
meta = _metadata(conn)
|
|
744
|
+
raw = conn.execute("SELECT id,path,start_line,end_line,text,embedding FROM chunks").fetchall()
|
|
745
|
+
finally:
|
|
746
|
+
conn.close()
|
|
747
|
+
rows, norms, byte_count = [], {}, 0
|
|
748
|
+
for chunk_id, file_path, start, end, text, blob in raw:
|
|
749
|
+
vector = _unpack_vector(blob) if blob else array.array("f")
|
|
750
|
+
if vector:
|
|
751
|
+
validate_vector(vector, meta.get("dimensions"))
|
|
752
|
+
norms[chunk_id] = math.sqrt(_sumprod(vector, vector))
|
|
753
|
+
rows.append((chunk_id, file_path, start, end, text, vector))
|
|
754
|
+
byte_count += len(text.encode("utf-8")) + len(blob or b"") + 256
|
|
755
|
+
after = os.stat(path)
|
|
756
|
+
if byte_count <= VECTOR_CACHE_MAX_BYTES and (stat.st_mtime_ns, stat.st_size) == (after.st_mtime_ns, after.st_size):
|
|
757
|
+
with _VECTOR_CACHE_LOCK:
|
|
758
|
+
invalidate_cache(root)
|
|
759
|
+
_VECTOR_CACHE[key] = (rows, meta, byte_count, norms)
|
|
760
|
+
while (len(_VECTOR_CACHE) > VECTOR_CACHE_MAX_PROJECTS or
|
|
761
|
+
sum(item[2] for item in _VECTOR_CACHE.values()) > VECTOR_CACHE_MAX_BYTES):
|
|
762
|
+
_VECTOR_CACHE.popitem(last=False)
|
|
763
|
+
return rows, meta, norms
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _lexical_scored(path, query, rows, limit, offset=0):
|
|
767
|
+
"""(chunk_id, score) best first; higher score is a better lexical match."""
|
|
768
|
+
terms = list(dict.fromkeys(re.findall(r"[\w]{2,}", query or "", re.UNICODE)))[:24]
|
|
769
|
+
if not terms:
|
|
770
|
+
return []
|
|
771
|
+
expression = " OR ".join('"' + term.replace('"', '""') + '"' for term in terms)
|
|
772
|
+
conn = _readonly(path)
|
|
773
|
+
try:
|
|
774
|
+
return [(row[0], -row[1]) for row in conn.execute(
|
|
775
|
+
"SELECT rowid, bm25(chunks_fts,3.0,1.0) FROM chunks_fts WHERE chunks_fts MATCH ? "
|
|
776
|
+
"ORDER BY bm25(chunks_fts,3.0,1.0) LIMIT ? OFFSET ?",
|
|
777
|
+
(expression, limit, offset))]
|
|
778
|
+
except sqlite3.OperationalError:
|
|
779
|
+
# FTS5 indisponível em algum Python: mantém recuperação lexical em stdlib.
|
|
780
|
+
ranked = [(sum(term.casefold() in (file_path + " " + text).casefold() for term in terms), chunk_id)
|
|
781
|
+
for chunk_id, file_path, _start, _end, text, _vector in rows]
|
|
782
|
+
return [(chunk_id, score) for score, chunk_id in sorted(ranked, reverse=True)[offset:offset + limit] if score]
|
|
783
|
+
finally:
|
|
784
|
+
conn.close()
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _min_max(pairs):
|
|
788
|
+
if not pairs:
|
|
789
|
+
return {}
|
|
790
|
+
values = [value for _, value in pairs]
|
|
791
|
+
low, high = min(values), max(values)
|
|
792
|
+
return {key: (value - low) / (high - low) if high > low else 1.0 for key, value in pairs}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def search(root, query_vector=None, top_k=20, query=None, model_id=None, include=None):
|
|
796
|
+
"""Recuperação vetorial + lexical por fusão de notas relativas, sem rede."""
|
|
797
|
+
path = existing_db_path(root)
|
|
798
|
+
if not path:
|
|
799
|
+
return []
|
|
800
|
+
rows, meta, norms = _vector_rows(root, path)
|
|
801
|
+
if include is not None:
|
|
802
|
+
rows = [row for row in rows if include(row[1])]
|
|
803
|
+
if model_id and meta.get("model_id") != model_id:
|
|
804
|
+
raise IndexCompatibilityError("The index must be rebuilt for the selected model.")
|
|
805
|
+
dimensions = meta.get("dimensions")
|
|
806
|
+
vector_scores = {}
|
|
807
|
+
if query_vector is not None:
|
|
808
|
+
validate_vector(query_vector, dimensions)
|
|
809
|
+
query_norm = math.sqrt(_sumprod(query_vector, query_vector))
|
|
810
|
+
for chunk_id, _path, _start, _end, _text, vector in rows:
|
|
811
|
+
if vector:
|
|
812
|
+
if len(vector) != len(query_vector):
|
|
813
|
+
raise IndexCompatibilityError(f"Incompatible dimensions: query={len(query_vector)}, index={len(vector)}.")
|
|
814
|
+
norm = norms[chunk_id] * query_norm
|
|
815
|
+
vector_scores[chunk_id] = _sumprod(query_vector, vector) / norm if norm else 0.0
|
|
816
|
+
vector_ids = sorted(vector_scores, key=vector_scores.get, reverse=True)
|
|
817
|
+
limit = max(top_k * 4, 40)
|
|
818
|
+
if not query:
|
|
819
|
+
lexical = []
|
|
820
|
+
elif include is None:
|
|
821
|
+
lexical = _lexical_scored(path, query, rows, limit)
|
|
822
|
+
else:
|
|
823
|
+
allowed, lexical, offset, page = {row[0] for row in rows}, [], 0, limit * 10
|
|
824
|
+
while len(lexical) < limit:
|
|
825
|
+
batch = _lexical_scored(path, query, rows, page, offset)
|
|
826
|
+
lexical += [pair for pair in batch if pair[0] in allowed]
|
|
827
|
+
if len(batch) < page:
|
|
828
|
+
break
|
|
829
|
+
offset += page
|
|
830
|
+
lexical = lexical[:limit]
|
|
831
|
+
if lexical:
|
|
832
|
+
vector_part = _min_max([(chunk_id, vector_scores[chunk_id]) for chunk_id in vector_ids[:limit]])
|
|
833
|
+
lexical_part = _min_max(lexical)
|
|
834
|
+
alpha = FUSION_ALPHA if vector_part else 0.0
|
|
835
|
+
scores = {chunk_id: alpha * vector_part.get(chunk_id, 0.0) + (1 - alpha) * lexical_part.get(chunk_id, 0.0)
|
|
836
|
+
for chunk_id in set(vector_part) | set(lexical_part)}
|
|
837
|
+
ids = sorted(scores, key=lambda item: (scores[item], item in lexical_part), reverse=True)
|
|
838
|
+
else:
|
|
839
|
+
scores, ids = vector_scores, vector_ids
|
|
840
|
+
by_id = {row[0]: row for row in rows}
|
|
841
|
+
return [(scores[chunk_id], *by_id[chunk_id][1:5]) for chunk_id in ids[:top_k]]
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def remove_index(root):
|
|
845
|
+
"""Remove somente arquivos de índice da raiz conhecida, nunca arquivos do projeto."""
|
|
846
|
+
import glob
|
|
847
|
+
removed = []
|
|
848
|
+
for key in set([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
|
|
849
|
+
pattern = re.compile(re.escape(key) + r"(?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3$")
|
|
850
|
+
for path in glob.glob(os.path.join(INDEX_DIR, key + "*.sqlite3")):
|
|
851
|
+
if pattern.fullmatch(os.path.basename(path)):
|
|
852
|
+
safe = project_identity.state_path(path, INDEX_DIR)
|
|
853
|
+
try:
|
|
854
|
+
os.remove(safe)
|
|
855
|
+
except PermissionError as exc:
|
|
856
|
+
raise RuntimeError("The index is open in another process. Close the external reader and try removing it again.") from exc
|
|
857
|
+
removed.append(safe)
|
|
858
|
+
invalidate_cache(root)
|
|
859
|
+
return removed
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def copy_project_indexes(root, destination):
|
|
863
|
+
"""Copia todas as visões e cache ao reassociar uma raiz, preservando a origem."""
|
|
864
|
+
import glob
|
|
865
|
+
target_key = project_identity.project_id(destination)
|
|
866
|
+
copied = set()
|
|
867
|
+
for key in dict.fromkeys([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
|
|
868
|
+
pattern = re.compile(re.escape(key) + r'((?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3)$')
|
|
869
|
+
for path in glob.glob(os.path.join(INDEX_DIR, key + '*.sqlite3')):
|
|
870
|
+
match = pattern.fullmatch(os.path.basename(path))
|
|
871
|
+
if match:
|
|
872
|
+
target = os.path.join(INDEX_DIR, target_key + match[1])
|
|
873
|
+
if target not in copied:
|
|
874
|
+
if os.path.exists(target):
|
|
875
|
+
raise ValueError('The reassociation target already has data.')
|
|
876
|
+
_backup(path, target)
|
|
877
|
+
copied.add(target)
|
|
878
|
+
return sorted(copied)
|