code-atlas-forge 1.1.0__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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Fachada de busca — resolve snapshot READY e despacha estratégias
|
|
2
|
+
(lf-0036). ``structural`` delega ao ``StructuralSearch`` existente;
|
|
3
|
+
``graph``/``hybrid``/``vector`` ainda não implementados → diagnóstico
|
|
4
|
+
tipado honesto (nunca resultado vazio silencioso)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
from code_atlas.observability.logging import timed
|
|
13
|
+
from code_atlas.search.contracts import (
|
|
14
|
+
RANKING_VERSION,
|
|
15
|
+
SEARCH_SCHEMA_VERSION,
|
|
16
|
+
SearchCapability,
|
|
17
|
+
SearchDiagnostic,
|
|
18
|
+
SearchHit,
|
|
19
|
+
SearchRequest,
|
|
20
|
+
SearchResult,
|
|
21
|
+
decode_cursor,
|
|
22
|
+
encode_cursor,
|
|
23
|
+
query_fingerprint,
|
|
24
|
+
)
|
|
25
|
+
from code_atlas.search.domain import DomainEntitySearch
|
|
26
|
+
from code_atlas.search.graph import GraphRetrieval
|
|
27
|
+
from code_atlas.search.hybrid import RANKING_VERSION_HYBRID, fuse, to_search_hit
|
|
28
|
+
from code_atlas.search.lexical import LexicalSearch
|
|
29
|
+
from code_atlas.search.structural import StructuralSearch
|
|
30
|
+
from code_atlas.search.vector.contracts import (
|
|
31
|
+
EmbeddingProvider,
|
|
32
|
+
)
|
|
33
|
+
from code_atlas.search.vector.embeddings import SqliteEmbeddingCache
|
|
34
|
+
from code_atlas.search.vector.index import (
|
|
35
|
+
BruteForceIndex,
|
|
36
|
+
sqlite_vec_available,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SearchEngine:
|
|
41
|
+
"""Ponto único de entrada de busca sobre a conexão."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, conn: sqlite3.Connection, *, enable_vector: bool = True,
|
|
45
|
+
enable_rerank: bool = True,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._conn = conn
|
|
48
|
+
self._structural = StructuralSearch(conn)
|
|
49
|
+
self._lexical = LexicalSearch(conn)
|
|
50
|
+
self._graph = GraphRetrieval(conn)
|
|
51
|
+
self._domain = DomainEntitySearch(conn)
|
|
52
|
+
self._vec = enable_vector
|
|
53
|
+
self._rerank = enable_rerank
|
|
54
|
+
|
|
55
|
+
def capabilities(self) -> SearchCapability:
|
|
56
|
+
fts5 = self._conn.execute(
|
|
57
|
+
"SELECT 1 FROM sqlite_master WHERE name='fts_occurrences'"
|
|
58
|
+
).fetchone() is not None
|
|
59
|
+
base: tuple[str, ...] = ("lexical", "structural", "graph", "hybrid")
|
|
60
|
+
if self._vec:
|
|
61
|
+
base += ("vector",)
|
|
62
|
+
backend = (
|
|
63
|
+
"sqlite-vec" if sqlite_vec_available() else "bruteforce")
|
|
64
|
+
return SearchCapability(
|
|
65
|
+
strategies=base if fts5 else tuple(
|
|
66
|
+
s for s in base if s != "lexical"),
|
|
67
|
+
fts5=fts5,
|
|
68
|
+
vector=self._vec,
|
|
69
|
+
detail=(
|
|
70
|
+
f"vector: provider=naive-hash backend={backend}"
|
|
71
|
+
if self._vec else "vector: disabled"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def search(self, req: SearchRequest) -> SearchResult:
|
|
75
|
+
diags: list[SearchDiagnostic] = []
|
|
76
|
+
repo_id = self._structural.repo_for_root(req.repository_root)
|
|
77
|
+
if repo_id is None:
|
|
78
|
+
diags.append(SearchDiagnostic(
|
|
79
|
+
code="repository-not-found", severity="error",
|
|
80
|
+
message=f"repositório não indexado: {req.repository_root}"))
|
|
81
|
+
return self._empty(req, diags)
|
|
82
|
+
snap, snap_err = self._structural.resolve_snapshot(
|
|
83
|
+
repo_id, req.snapshot_id)
|
|
84
|
+
if snap is None:
|
|
85
|
+
diags.append(SearchDiagnostic(
|
|
86
|
+
code="no-ready-snapshot", severity="error",
|
|
87
|
+
message=snap_err or "nenhum snapshot READY"))
|
|
88
|
+
return self._empty(req, diags)
|
|
89
|
+
return self.search_scoped(req, repo_id, int(snap.id or 0))
|
|
90
|
+
|
|
91
|
+
# lf-0141: slow-query guard — cada busca (incl. membro federado, que
|
|
92
|
+
# chama este método por repo) mede e vira linha DEBUG acima de
|
|
93
|
+
# ``CODE_ATLAS_SLOW_MS``. Fora do env, custo = um monotonic extra.
|
|
94
|
+
@timed("search")
|
|
95
|
+
def search_scoped(
|
|
96
|
+
self, req: SearchRequest, repository_id: int, snapshot_id: int
|
|
97
|
+
) -> SearchResult:
|
|
98
|
+
"""Busca num escopo (repositório, snapshot) já resolvido.
|
|
99
|
+
|
|
100
|
+
Diferente de ``search``: NÃO revalida o status do snapshot —
|
|
101
|
+
quem chama garante a legibilidade (a busca federada admite pins
|
|
102
|
+
STALE, cuja leitura é legítima). ``search()`` delega aqui após
|
|
103
|
+
resolver repo + READY.
|
|
104
|
+
"""
|
|
105
|
+
repo_id, sid = repository_id, snapshot_id
|
|
106
|
+
diags: list[SearchDiagnostic] = []
|
|
107
|
+
|
|
108
|
+
# lf-0058: snapshot no fingerprint — cursor de outra revisão é
|
|
109
|
+
# rejeitado (proteção cross-revision exigida pela Phase 0.2).
|
|
110
|
+
filters = {"language": req.language, "kind": req.kind,
|
|
111
|
+
"path": req.path, "domain": req.domain,
|
|
112
|
+
"snapshot": str(sid)}
|
|
113
|
+
fp = query_fingerprint(req.query, filters)
|
|
114
|
+
offset = 0
|
|
115
|
+
if req.cursor:
|
|
116
|
+
decoded = decode_cursor(req.cursor, fp)
|
|
117
|
+
if decoded is None:
|
|
118
|
+
diags.append(SearchDiagnostic(
|
|
119
|
+
code="invalid-cursor", severity="error",
|
|
120
|
+
message="cursor não corresponde a esta query/filtros"))
|
|
121
|
+
return self._empty(req, diags, snapshot_id=sid)
|
|
122
|
+
offset = decoded
|
|
123
|
+
|
|
124
|
+
if req.strategy == "hybrid":
|
|
125
|
+
fused, h_diags = self._hybrid_hits(req, repo_id, sid)
|
|
126
|
+
diags.extend(h_diags)
|
|
127
|
+
# lf-0058 Phase 0.2: limit/offset APLICADOS após fusão —
|
|
128
|
+
# antes retornava todos os candidatos (P1).
|
|
129
|
+
lim = max(1, min(req.limit or 20, 200))
|
|
130
|
+
page = fused[offset:offset + lim]
|
|
131
|
+
truncated = offset + len(page) < len(fused)
|
|
132
|
+
res = SearchResult(
|
|
133
|
+
schema_version=SEARCH_SCHEMA_VERSION,
|
|
134
|
+
repository=req.repository_root,
|
|
135
|
+
snapshot_id=sid, query=req.query, strategy=req.strategy,
|
|
136
|
+
ranking_version=RANKING_VERSION_HYBRID, items=page,
|
|
137
|
+
limit=lim,
|
|
138
|
+
truncated=truncated,
|
|
139
|
+
next_cursor=(encode_cursor(offset + len(page), fp)
|
|
140
|
+
if truncated else None),
|
|
141
|
+
diagnostics=tuple(diags),
|
|
142
|
+
)
|
|
143
|
+
return self._attach_domain(res, req, repo_id, sid)
|
|
144
|
+
if req.strategy == "graph":
|
|
145
|
+
hits, _total, g_diags = self._graph_hits(req, repo_id, sid)
|
|
146
|
+
diags.extend(g_diags)
|
|
147
|
+
res = SearchResult(
|
|
148
|
+
schema_version=SEARCH_SCHEMA_VERSION,
|
|
149
|
+
repository=req.repository_root,
|
|
150
|
+
snapshot_id=sid, query=req.query, strategy=req.strategy,
|
|
151
|
+
ranking_version="graph-structural-v1", items=hits,
|
|
152
|
+
limit=req.limit,
|
|
153
|
+
truncated=len(hits) >= max(1, req.limit or 20),
|
|
154
|
+
diagnostics=tuple(diags),
|
|
155
|
+
)
|
|
156
|
+
return self._attach_domain(res, req, repo_id, sid)
|
|
157
|
+
if req.strategy == "structural":
|
|
158
|
+
hits, total = self._structural_hits(req, repo_id, sid, offset)
|
|
159
|
+
ranking = "structural-sql-v1"
|
|
160
|
+
elif req.strategy == "lexical":
|
|
161
|
+
hits, total, lex_diags = self._lexical.search(req, sid, offset)
|
|
162
|
+
diags.extend(lex_diags)
|
|
163
|
+
ranking = RANKING_VERSION
|
|
164
|
+
elif req.strategy == "vector":
|
|
165
|
+
hits, total, v_diags = self._vector_hits(req, sid, offset)
|
|
166
|
+
diags.extend(v_diags)
|
|
167
|
+
ranking = "vector-cosine-v1"
|
|
168
|
+
else:
|
|
169
|
+
diags.append(SearchDiagnostic(
|
|
170
|
+
code="strategy-unavailable", severity="error",
|
|
171
|
+
message=f"estrategia '{req.strategy}' não implementada "
|
|
172
|
+
f"(capabilities: lexical, structural)"))
|
|
173
|
+
return self._empty(req, diags, snapshot_id=sid)
|
|
174
|
+
|
|
175
|
+
truncated = offset + len(hits) < total
|
|
176
|
+
res = SearchResult(
|
|
177
|
+
schema_version=SEARCH_SCHEMA_VERSION,
|
|
178
|
+
repository=req.repository_root,
|
|
179
|
+
snapshot_id=sid,
|
|
180
|
+
query=req.query,
|
|
181
|
+
strategy=req.strategy,
|
|
182
|
+
ranking_version=ranking,
|
|
183
|
+
items=hits,
|
|
184
|
+
limit=req.limit,
|
|
185
|
+
truncated=truncated,
|
|
186
|
+
next_cursor=(
|
|
187
|
+
encode_cursor(offset + len(hits), fp) if truncated else None
|
|
188
|
+
),
|
|
189
|
+
diagnostics=tuple(diags),
|
|
190
|
+
)
|
|
191
|
+
return self._attach_domain(res, req, repo_id, sid)
|
|
192
|
+
|
|
193
|
+
def _attach_domain(
|
|
194
|
+
self, res: SearchResult, req: SearchRequest,
|
|
195
|
+
repository_id: int, snapshot_id: int,
|
|
196
|
+
) -> SearchResult:
|
|
197
|
+
"""Pós-dispatch: tag de escopo em cada hit + faceta de domínio.
|
|
198
|
+
|
|
199
|
+
Todo hit carrega ``repository_id``/``snapshot_id`` do escopo —
|
|
200
|
+
a busca federada depende disso para nunca colapsar homônimos
|
|
201
|
+
cross-repo (lf-0119). A faceta ``domain_items`` é separada por
|
|
202
|
+
desenho — nunca fundida com símbolos.
|
|
203
|
+
"""
|
|
204
|
+
tagged = tuple(
|
|
205
|
+
dataclasses.replace(
|
|
206
|
+
h, repository_id=repository_id, snapshot_id=snapshot_id)
|
|
207
|
+
for h in res.items
|
|
208
|
+
)
|
|
209
|
+
dom_items, dom_total, d_diags = self._domain.search(
|
|
210
|
+
req, repository_id, snapshot_id)
|
|
211
|
+
return dataclasses.replace(
|
|
212
|
+
res, items=tagged, domain_items=dom_items,
|
|
213
|
+
domain_total=dom_total,
|
|
214
|
+
diagnostics=(*res.diagnostics, *d_diags),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _structural_hits(
|
|
218
|
+
self, req: SearchRequest, repo_id: int, snapshot_id: int,
|
|
219
|
+
offset: int,
|
|
220
|
+
) -> tuple[tuple[SearchHit, ...], int]:
|
|
221
|
+
"""Adapta ``search_symbols`` do StructuralSearch para SearchHit."""
|
|
222
|
+
from code_atlas.search.structural import SearchFilters
|
|
223
|
+
|
|
224
|
+
page = self._structural.search_symbols(
|
|
225
|
+
repo_id, snapshot_id, req.query,
|
|
226
|
+
SearchFilters(
|
|
227
|
+
language=req.language, kind=req.kind, path=req.path,
|
|
228
|
+
),
|
|
229
|
+
max(1, min(req.limit or 20, 200)) + offset,
|
|
230
|
+
0,
|
|
231
|
+
)
|
|
232
|
+
items = page.items[offset:]
|
|
233
|
+
hits = tuple(
|
|
234
|
+
SearchHit(
|
|
235
|
+
occurrence_id=int(it.get("occurrence_id", 0) or 0),
|
|
236
|
+
qualified_name=str(it.get("qualified_name", "")),
|
|
237
|
+
kind=str(it.get("kind", "")),
|
|
238
|
+
language=str(it.get("language", "")),
|
|
239
|
+
path=str(it.get("path", "")),
|
|
240
|
+
span=it.get("span"),
|
|
241
|
+
score=0.0,
|
|
242
|
+
strategy="structural",
|
|
243
|
+
matched_fields=("qualified_name",),
|
|
244
|
+
evidence=(
|
|
245
|
+
(f"occ:{int(it.get('occurrence_id', 0) or 0)}",)
|
|
246
|
+
if int(it.get("occurrence_id", 0) or 0) else ()),
|
|
247
|
+
)
|
|
248
|
+
for it in items
|
|
249
|
+
)
|
|
250
|
+
return hits, int(page.total)
|
|
251
|
+
|
|
252
|
+
def _hybrid_hits(
|
|
253
|
+
self, req: SearchRequest, repo_id: int, snapshot_id: int
|
|
254
|
+
) -> tuple[tuple[SearchHit, ...], tuple[SearchDiagnostic, ...]]:
|
|
255
|
+
"""Pipeline híbrido: lexical+structural+graph → RRF → página."""
|
|
256
|
+
diags: list[SearchDiagnostic] = []
|
|
257
|
+
fetch = max(20, min((req.limit or 20) * 3, 200))
|
|
258
|
+
rankings: dict[str, list[SearchHit]] = {}
|
|
259
|
+
# lf-0058: cursor NÃO pode vazar para os sub-canais — a lista
|
|
260
|
+
# fundida deve ser idêntica entre páginas (paginação estável).
|
|
261
|
+
lex_req = dataclasses.replace(req, limit=fetch, cursor=None)
|
|
262
|
+
lex_hits, _total, lex_diags = self._lexical.search(lex_req, snapshot_id)
|
|
263
|
+
diags.extend(lex_diags)
|
|
264
|
+
rankings["lexical"] = list(lex_hits)
|
|
265
|
+
st_hits, _ = self._structural_hits(
|
|
266
|
+
lex_req, repo_id, snapshot_id, 0)
|
|
267
|
+
rankings["structural"] = list(st_hits)
|
|
268
|
+
g_hits, _, g_diags = self._graph_hits(
|
|
269
|
+
lex_req, repo_id, snapshot_id)
|
|
270
|
+
diags.extend(d for d in g_diags if d.code != "seed-not-found")
|
|
271
|
+
rankings["graph"] = list(g_hits)
|
|
272
|
+
if self._vec:
|
|
273
|
+
# lf-0086: superfícies read-only (API Explorer) abrem o banco
|
|
274
|
+
# com query_only — o canal vetorial materializa embeddings e
|
|
275
|
+
# cachearia a query, o que falharia com OperationalError.
|
|
276
|
+
# Degradação declarada, nunca silenciosa.
|
|
277
|
+
ro = self._conn.execute("PRAGMA query_only").fetchone()
|
|
278
|
+
if ro is not None and bool(ro[0]):
|
|
279
|
+
diags.append(SearchDiagnostic(
|
|
280
|
+
code="vector-unavailable-readonly", severity="info",
|
|
281
|
+
message="canal vetorial desativado em conexão "
|
|
282
|
+
"read-only (embeddings exigem escrita)"))
|
|
283
|
+
else:
|
|
284
|
+
v_hits, _vt, v_diags = self._vector_hits(
|
|
285
|
+
lex_req, snapshot_id, 0)
|
|
286
|
+
diags.extend(
|
|
287
|
+
d for d in v_diags if d.code != "vector-empty")
|
|
288
|
+
rankings["vector"] = list(v_hits)
|
|
289
|
+
from code_atlas.search.intent import classify
|
|
290
|
+
|
|
291
|
+
fused = fuse(
|
|
292
|
+
rankings,
|
|
293
|
+
intent=classify(req.query) if self._rerank else None,
|
|
294
|
+
query=req.query if self._rerank else "",
|
|
295
|
+
)
|
|
296
|
+
return tuple(to_search_hit(c) for c in fused), tuple(diags)
|
|
297
|
+
|
|
298
|
+
def _vector_hits(
|
|
299
|
+
self, req: SearchRequest, snapshot_id: int, offset: int
|
|
300
|
+
) -> tuple[tuple[SearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
|
|
301
|
+
"""Busca vetorial: ensure embeddings → embed query → KNN → hits.
|
|
302
|
+
|
|
303
|
+
Backend: sqlite-vec se o extra estiver instalado, senão scan
|
|
304
|
+
bruteforce stdlib — capability declarada, nunca silenciosa.
|
|
305
|
+
"""
|
|
306
|
+
from code_atlas.search.vector import (
|
|
307
|
+
BruteForceIndex,
|
|
308
|
+
NaiveEmbeddingProvider,
|
|
309
|
+
SqliteEmbeddingCache,
|
|
310
|
+
embed_texts_cached,
|
|
311
|
+
sqlite_vec_available,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
provider = NaiveEmbeddingProvider()
|
|
315
|
+
model = provider.info()
|
|
316
|
+
if sqlite_vec_available():
|
|
317
|
+
from code_atlas.search.vector.index import SqliteVecIndex
|
|
318
|
+
|
|
319
|
+
index: BruteForceIndex = SqliteVecIndex(
|
|
320
|
+
self._conn, model.model_id, model.version, model.dim)
|
|
321
|
+
else:
|
|
322
|
+
index = BruteForceIndex(self._conn, model.model_id, model.version)
|
|
323
|
+
cache = SqliteEmbeddingCache(self._conn)
|
|
324
|
+
built = self._ensure_embeddings(
|
|
325
|
+
snapshot_id, provider, cache, index)
|
|
326
|
+
qvec, _qh, _qm = embed_texts_cached(provider, cache, [req.query])
|
|
327
|
+
results = index.search(
|
|
328
|
+
snapshot_id, qvec[0], offset + max(1, req.limit or 20))
|
|
329
|
+
results = results[offset:]
|
|
330
|
+
occ_ids = [r.occurrence_id for r in results]
|
|
331
|
+
info: dict[int, dict[str, object]] = {}
|
|
332
|
+
if occ_ids:
|
|
333
|
+
rows = self._conn.execute(
|
|
334
|
+
"SELECT o.id, s.qualified_name, s.kind, s.language,"
|
|
335
|
+
" sf.relative_path, o.span_start, o.span_end,"
|
|
336
|
+
" o.line, o.col"
|
|
337
|
+
" FROM symbol_occurrences o"
|
|
338
|
+
" JOIN source_files sf ON sf.id = o.source_file_id"
|
|
339
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
340
|
+
" WHERE o.id IN (SELECT value FROM json_each(?))",
|
|
341
|
+
(json.dumps(occ_ids),),
|
|
342
|
+
).fetchall()
|
|
343
|
+
for r in rows:
|
|
344
|
+
info[int(r["id"])] = dict(r)
|
|
345
|
+
hits = tuple(
|
|
346
|
+
SearchHit(
|
|
347
|
+
occurrence_id=r.occurrence_id,
|
|
348
|
+
qualified_name=str(
|
|
349
|
+
info.get(r.occurrence_id, {}).get(
|
|
350
|
+
"qualified_name", r.symbol_key)),
|
|
351
|
+
kind=str(info.get(r.occurrence_id, {}).get("kind", "")),
|
|
352
|
+
language=str(
|
|
353
|
+
info.get(r.occurrence_id, {}).get("language", "")),
|
|
354
|
+
path=str(
|
|
355
|
+
info.get(r.occurrence_id, {}).get(
|
|
356
|
+
"relative_path", "")),
|
|
357
|
+
span=None,
|
|
358
|
+
score=round(r.score, 6),
|
|
359
|
+
strategy="vector",
|
|
360
|
+
matched_fields=("embedding",),
|
|
361
|
+
evidence=(
|
|
362
|
+
f"cosine={r.score:.4f}",
|
|
363
|
+
f"model={r.model_id} dim={model.dim}",
|
|
364
|
+
f"embeddings_built={built}",
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
for r in results
|
|
368
|
+
if r.occurrence_id in info and r.score > 0.0
|
|
369
|
+
)
|
|
370
|
+
diags: tuple[SearchDiagnostic, ...] = ()
|
|
371
|
+
if not hits:
|
|
372
|
+
diags = (SearchDiagnostic(
|
|
373
|
+
code="vector-empty", severity="info",
|
|
374
|
+
message="sem hits vetoriais para esta query"),)
|
|
375
|
+
return hits, len(hits) + offset, diags
|
|
376
|
+
|
|
377
|
+
def _ensure_embeddings(
|
|
378
|
+
self,
|
|
379
|
+
snapshot_id: int,
|
|
380
|
+
provider: EmbeddingProvider,
|
|
381
|
+
cache: SqliteEmbeddingCache,
|
|
382
|
+
index: BruteForceIndex,
|
|
383
|
+
) -> int:
|
|
384
|
+
"""Materializa embeddings do snapshot — só os que faltam na
|
|
385
|
+
versão atual do modelo (rebuild por mudança de conteúdo/modelo).
|
|
386
|
+
Retorna quantos embeddings novos foram gerados."""
|
|
387
|
+
from code_atlas.search.vector.embeddings import (
|
|
388
|
+
content_hash,
|
|
389
|
+
embed_texts_cached,
|
|
390
|
+
index_text_for,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
model = provider.info()
|
|
394
|
+
rows = self._conn.execute(
|
|
395
|
+
"SELECT f.rowid AS occ_id, s.qualified_name, f.signature,"
|
|
396
|
+
" f.docstring FROM fts_occurrences f"
|
|
397
|
+
" JOIN symbol_occurrences o ON o.id = f.rowid"
|
|
398
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
399
|
+
" WHERE f.snapshot_id = ?",
|
|
400
|
+
(snapshot_id,),
|
|
401
|
+
).fetchall()
|
|
402
|
+
pending: list[tuple[int, str, str, str]] = []
|
|
403
|
+
for r in rows:
|
|
404
|
+
text = index_text_for(
|
|
405
|
+
str(r["qualified_name"]),
|
|
406
|
+
str(r["signature"] or ""), str(r["docstring"] or ""))
|
|
407
|
+
pending.append(
|
|
408
|
+
(int(r["occ_id"]), str(r["qualified_name"]),
|
|
409
|
+
text, content_hash(text)))
|
|
410
|
+
existing = {
|
|
411
|
+
(int(x["occurrence_id"]), str(x["content_hash"]))
|
|
412
|
+
for x in self._conn.execute(
|
|
413
|
+
"SELECT occurrence_id, content_hash "
|
|
414
|
+
"FROM vector_embeddings "
|
|
415
|
+
"WHERE snapshot_id = ? AND model_id = ?"
|
|
416
|
+
" AND model_version = ?",
|
|
417
|
+
(snapshot_id, model.model_id, model.version),
|
|
418
|
+
).fetchall()
|
|
419
|
+
}
|
|
420
|
+
todo = [p for p in pending if (p[0], p[3]) not in existing]
|
|
421
|
+
if not todo:
|
|
422
|
+
return 0
|
|
423
|
+
vecs, _h, _m = embed_texts_cached(
|
|
424
|
+
provider, cache, [t[2] for t in todo])
|
|
425
|
+
for (occ_id, sym, _text, chash), vec in zip(
|
|
426
|
+
todo, vecs, strict=True):
|
|
427
|
+
index.upsert(
|
|
428
|
+
snapshot_id, occ_id, sym, vec, content_hash=chash)
|
|
429
|
+
return len(todo)
|
|
430
|
+
|
|
431
|
+
def _graph_hits(
|
|
432
|
+
self, req: SearchRequest, repo_id: int, snapshot_id: int
|
|
433
|
+
) -> tuple[tuple[SearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
|
|
434
|
+
"""Multi-seed → BFS limitado com direção por intent (lf-0049).
|
|
435
|
+
|
|
436
|
+
Seeds: qualified_name exato + top lexical + top structural —
|
|
437
|
+
dedup por occurrence_id. Seeds entram como hits rotulados
|
|
438
|
+
``graph-seed`` (casaram com a query por nome — honesto).
|
|
439
|
+
Direção vem de ``classify(query)`` — nunca inventa aresta.
|
|
440
|
+
|
|
441
|
+
lf-0119: seeds são coletados direto nos canais snapshot-scoped
|
|
442
|
+
(sem re-resolver repo+status via ``search()``) — funciona em
|
|
443
|
+
pins STALE da federação e não vaza o cursor da página externa
|
|
444
|
+
para a janela de seeds."""
|
|
445
|
+
from code_atlas.search.graph import TraversalLimits
|
|
446
|
+
from code_atlas.search.intent import classify
|
|
447
|
+
|
|
448
|
+
intent = classify(req.query)
|
|
449
|
+
seed_ids = set(self._graph.seed_occurrences(
|
|
450
|
+
snapshot_id, req.query))
|
|
451
|
+
seed_req = dataclasses.replace(req, limit=3, cursor=None)
|
|
452
|
+
lex_hits, _lt, _ld = self._lexical.search(seed_req, snapshot_id)
|
|
453
|
+
st_hits, _st = self._structural_hits(
|
|
454
|
+
seed_req, repo_id, snapshot_id, 0)
|
|
455
|
+
seed_hits: list[SearchHit] = []
|
|
456
|
+
for h in (*lex_hits, *st_hits):
|
|
457
|
+
if h.occurrence_id not in seed_ids:
|
|
458
|
+
seed_ids.add(h.occurrence_id)
|
|
459
|
+
seed_hits.append(h)
|
|
460
|
+
if not seed_ids:
|
|
461
|
+
return (), 0, (SearchDiagnostic(
|
|
462
|
+
code="seed-not-found", severity="warning",
|
|
463
|
+
message=f"nenhuma ocorrência casa '{req.query}' no "
|
|
464
|
+
f"snapshot"),)
|
|
465
|
+
lim = TraversalLimits(
|
|
466
|
+
depth=3, max_nodes=500, max_edges=2000, max_ms=500,
|
|
467
|
+
max_results=max(1, min(req.limit or 20, 200)),
|
|
468
|
+
)
|
|
469
|
+
hits, stats = self._graph.traverse(
|
|
470
|
+
snapshot_id, list(seed_ids), direction=intent.direction,
|
|
471
|
+
limits=lim)
|
|
472
|
+
diags: list[SearchDiagnostic] = [
|
|
473
|
+
SearchDiagnostic(
|
|
474
|
+
code="graph-intent", severity="info",
|
|
475
|
+
message=f"intent={intent.task_type} "
|
|
476
|
+
f"direction={intent.direction} "
|
|
477
|
+
f"seeds={len(seed_ids)}"),
|
|
478
|
+
]
|
|
479
|
+
if stats.truncated_by:
|
|
480
|
+
diags.append(SearchDiagnostic(
|
|
481
|
+
code="traversal-truncated", severity="info",
|
|
482
|
+
message=f"travessia truncada por limite: "
|
|
483
|
+
f"{stats.truncated_by}"))
|
|
484
|
+
seed_out = [
|
|
485
|
+
SearchHit(
|
|
486
|
+
occurrence_id=h.occurrence_id,
|
|
487
|
+
qualified_name=h.qualified_name, kind=h.kind,
|
|
488
|
+
language=h.language, path=h.path, span=h.span,
|
|
489
|
+
score=h.score, strategy="graph",
|
|
490
|
+
matched_fields=("graph-seed", *h.matched_fields),
|
|
491
|
+
evidence=h.evidence,
|
|
492
|
+
)
|
|
493
|
+
for h in seed_hits
|
|
494
|
+
]
|
|
495
|
+
out = tuple(seed_out) + tuple(
|
|
496
|
+
SearchHit(
|
|
497
|
+
occurrence_id=h.occurrence_id,
|
|
498
|
+
qualified_name=h.qualified_name, kind=h.kind,
|
|
499
|
+
language=h.language, path=h.path, span=None,
|
|
500
|
+
score=h.score, strategy="graph",
|
|
501
|
+
matched_fields=("graph",),
|
|
502
|
+
evidence=tuple(
|
|
503
|
+
f"dir={intent.direction}:"
|
|
504
|
+
f"{s.relation}:{s.from_id}->{s.to_id}"
|
|
505
|
+
f"({s.resolution_status})"
|
|
506
|
+
for s in h.steps
|
|
507
|
+
),
|
|
508
|
+
)
|
|
509
|
+
for h in hits
|
|
510
|
+
)
|
|
511
|
+
return out, len(out), tuple(diags)
|
|
512
|
+
|
|
513
|
+
def _empty(
|
|
514
|
+
self,
|
|
515
|
+
req: SearchRequest,
|
|
516
|
+
diags: list[SearchDiagnostic],
|
|
517
|
+
snapshot_id: int = 0,
|
|
518
|
+
) -> SearchResult:
|
|
519
|
+
return SearchResult(
|
|
520
|
+
schema_version=SEARCH_SCHEMA_VERSION,
|
|
521
|
+
repository=req.repository_root,
|
|
522
|
+
snapshot_id=snapshot_id,
|
|
523
|
+
query=req.query,
|
|
524
|
+
strategy=req.strategy,
|
|
525
|
+
ranking_version=RANKING_VERSION,
|
|
526
|
+
items=(),
|
|
527
|
+
limit=req.limit,
|
|
528
|
+
truncated=False,
|
|
529
|
+
diagnostics=tuple(diags),
|
|
530
|
+
)
|