attocode-code-intel 0.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.
Files changed (345) hide show
  1. attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
  2. attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
  3. attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
  4. attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
  5. attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. attocode_intel/GUIDELINES.md +432 -0
  7. attocode_intel/__init__.py +52 -0
  8. attocode_intel/_internal/__init__.py +1 -0
  9. attocode_intel/_internal/errors/__init__.py +126 -0
  10. attocode_intel/_internal/integrations/__init__.py +1 -0
  11. attocode_intel/_internal/integrations/context/__init__.py +1 -0
  12. attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
  13. attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
  14. attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
  15. attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
  16. attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
  17. attocode_intel/_internal/integrations/context/cas.py +466 -0
  18. attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
  19. attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
  20. attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
  21. attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
  22. attocode_intel/_internal/integrations/context/cross_references.py +616 -0
  23. attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
  24. attocode_intel/_internal/integrations/context/embeddings.py +282 -0
  25. attocode_intel/_internal/integrations/context/frecency.py +427 -0
  26. attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
  27. attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
  28. attocode_intel/_internal/integrations/context/hydration.py +90 -0
  29. attocode_intel/_internal/integrations/context/index_repair.py +87 -0
  30. attocode_intel/_internal/integrations/context/index_store.py +586 -0
  31. attocode_intel/_internal/integrations/context/memory_store.py +602 -0
  32. attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
  33. attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
  34. attocode_intel/_internal/integrations/context/query_history.py +417 -0
  35. attocode_intel/_internal/integrations/context/reranker.py +351 -0
  36. attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
  37. attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
  38. attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
  39. attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
  40. attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
  41. attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
  42. attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
  43. attocode_intel/_internal/integrations/context/vector_store.py +795 -0
  44. attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
  45. attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
  46. attocode_intel/_internal/integrations/lsp/client.py +1140 -0
  47. attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
  48. attocode_intel/_internal/integrations/mcp/client.py +253 -0
  49. attocode_intel/_internal/integrations/security/__init__.py +1 -0
  50. attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
  51. attocode_intel/_internal/integrations/security/dataflow.py +522 -0
  52. attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
  53. attocode_intel/_internal/integrations/security/matcher.py +44 -0
  54. attocode_intel/_internal/integrations/security/patterns.py +724 -0
  55. attocode_intel/_internal/integrations/security/scanner.py +351 -0
  56. attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
  57. attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
  58. attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
  59. attocode_intel/_internal/tricks/__init__.py +1 -0
  60. attocode_intel/_internal/tricks/recursive_context.py +357 -0
  61. attocode_intel/_internal/types/__init__.py +1 -0
  62. attocode_intel/_internal/types/events.py +289 -0
  63. attocode_intel/_internal/types/severity.py +15 -0
  64. attocode_intel/_shared.py +341 -0
  65. attocode_intel/analysis_coverage.py +56 -0
  66. attocode_intel/api/__init__.py +1 -0
  67. attocode_intel/api/access.py +32 -0
  68. attocode_intel/api/app.py +247 -0
  69. attocode_intel/api/auth/__init__.py +128 -0
  70. attocode_intel/api/auth/api_keys.py +61 -0
  71. attocode_intel/api/auth/context.py +34 -0
  72. attocode_intel/api/auth/jwt.py +66 -0
  73. attocode_intel/api/auth/oauth.py +146 -0
  74. attocode_intel/api/auth/passwords.py +15 -0
  75. attocode_intel/api/deps.py +351 -0
  76. attocode_intel/api/knowledge_bridge.py +13 -0
  77. attocode_intel/api/mcp_transport.py +51 -0
  78. attocode_intel/api/middleware.py +411 -0
  79. attocode_intel/api/models.py +698 -0
  80. attocode_intel/api/providers/__init__.py +1 -0
  81. attocode_intel/api/providers/db_provider.py +1106 -0
  82. attocode_intel/api/providers/local_provider.py +476 -0
  83. attocode_intel/api/providers/remote_provider.py +740 -0
  84. attocode_intel/api/repository_source.py +21 -0
  85. attocode_intel/api/routes/__init__.py +1 -0
  86. attocode_intel/api/routes/_pin_helper.py +158 -0
  87. attocode_intel/api/routes/activity.py +148 -0
  88. attocode_intel/api/routes/adr.py +102 -0
  89. attocode_intel/api/routes/analysis.py +515 -0
  90. attocode_intel/api/routes/api_keys.py +170 -0
  91. attocode_intel/api/routes/auth.py +428 -0
  92. attocode_intel/api/routes/branches.py +262 -0
  93. attocode_intel/api/routes/cross_repo_search.py +198 -0
  94. attocode_intel/api/routes/embeddings.py +386 -0
  95. attocode_intel/api/routes/files.py +132 -0
  96. attocode_intel/api/routes/files_v2.py +388 -0
  97. attocode_intel/api/routes/gc.py +322 -0
  98. attocode_intel/api/routes/git_v2.py +433 -0
  99. attocode_intel/api/routes/graph.py +174 -0
  100. attocode_intel/api/routes/graph_viz.py +160 -0
  101. attocode_intel/api/routes/health.py +62 -0
  102. attocode_intel/api/routes/history.py +175 -0
  103. attocode_intel/api/routes/jobs.py +84 -0
  104. attocode_intel/api/routes/learning.py +205 -0
  105. attocode_intel/api/routes/lsp.py +272 -0
  106. attocode_intel/api/routes/notify.py +623 -0
  107. attocode_intel/api/routes/operations.py +27 -0
  108. attocode_intel/api/routes/orgs.py +924 -0
  109. attocode_intel/api/routes/preferences.py +100 -0
  110. attocode_intel/api/routes/presence.py +42 -0
  111. attocode_intel/api/routes/projects.py +116 -0
  112. attocode_intel/api/routes/repos.py +47 -0
  113. attocode_intel/api/routes/search.py +180 -0
  114. attocode_intel/api/routes/snapshots.py +614 -0
  115. attocode_intel/api/routes/webhooks.py +313 -0
  116. attocode_intel/api/routes/websocket.py +116 -0
  117. attocode_intel/api/static/graph.html +239 -0
  118. attocode_intel/api/utils.py +96 -0
  119. attocode_intel/artifacts/__init__.py +45 -0
  120. attocode_intel/artifacts/hashing.py +230 -0
  121. attocode_intel/artifacts/provenance.py +190 -0
  122. attocode_intel/artifacts/retrieval_pin.py +159 -0
  123. attocode_intel/audit.py +59 -0
  124. attocode_intel/bug_finder.py +206 -0
  125. attocode_intel/bundle.py +80 -0
  126. attocode_intel/catalog.py +242 -0
  127. attocode_intel/change_review.py +75 -0
  128. attocode_intel/cli.py +3062 -0
  129. attocode_intel/community.py +63 -0
  130. attocode_intel/confidence/__init__.py +144 -0
  131. attocode_intel/confidence/jev.py +234 -0
  132. attocode_intel/confidence/llm.py +330 -0
  133. attocode_intel/confidence/redact.py +44 -0
  134. attocode_intel/confidence/settings.py +74 -0
  135. attocode_intel/config.py +256 -0
  136. attocode_intel/crypto.py +31 -0
  137. attocode_intel/db/__init__.py +7 -0
  138. attocode_intel/db/base.py +33 -0
  139. attocode_intel/db/engine.py +61 -0
  140. attocode_intel/db/models.py +529 -0
  141. attocode_intel/entrypoint.py +185 -0
  142. attocode_intel/focused_evidence.py +130 -0
  143. attocode_intel/freshness.py +154 -0
  144. attocode_intel/gateway.py +734 -0
  145. attocode_intel/git/__init__.py +7 -0
  146. attocode_intel/git/credentials.py +75 -0
  147. attocode_intel/git/manager.py +493 -0
  148. attocode_intel/git/models.py +96 -0
  149. attocode_intel/git/storage.py +98 -0
  150. attocode_intel/graph_query_parser.py +761 -0
  151. attocode_intel/helpers.py +712 -0
  152. attocode_intel/indexing/__init__.py +29 -0
  153. attocode_intel/indexing/debouncer.py +136 -0
  154. attocode_intel/indexing/delta_indexer.py +151 -0
  155. attocode_intel/indexing/full_indexer.py +323 -0
  156. attocode_intel/indexing/incremental.py +240 -0
  157. attocode_intel/indexing/parser.py +403 -0
  158. attocode_intel/indexing/summarizer.py +264 -0
  159. attocode_intel/installer.py +1896 -0
  160. attocode_intel/knowledge.py +269 -0
  161. attocode_intel/local_knowledge.py +77 -0
  162. attocode_intel/migrations/alembic.ini +36 -0
  163. attocode_intel/migrations/env.py +71 -0
  164. attocode_intel/migrations/script.py.mako +27 -0
  165. attocode_intel/migrations/versions/001_initial_schema.py +219 -0
  166. attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
  167. attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
  168. attocode_intel/migrations/versions/004_audit_events.py +38 -0
  169. attocode_intel/migrations/versions/005_user_preferences.py +30 -0
  170. attocode_intel/migrations/versions/006_google_oauth.py +27 -0
  171. attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
  172. attocode_intel/migrations/versions/008_symbol_references.py +37 -0
  173. attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
  174. attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
  175. attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
  176. attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
  177. attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
  178. attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
  179. attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
  180. attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
  181. attocode_intel/migrations/versions/017_provenance_table.py +102 -0
  182. attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
  183. attocode_intel/migrations/versions/019_symref_caller.py +42 -0
  184. attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
  185. attocode_intel/onboarding.py +258 -0
  186. attocode_intel/output.py +178 -0
  187. attocode_intel/precision.py +133 -0
  188. attocode_intel/presence.py +160 -0
  189. attocode_intel/probe.py +110 -0
  190. attocode_intel/project_dir.py +97 -0
  191. attocode_intel/pubsub.py +113 -0
  192. attocode_intel/query_ranking.py +242 -0
  193. attocode_intel/readiness.py +972 -0
  194. attocode_intel/redis.py +51 -0
  195. attocode_intel/reference_pages.py +60 -0
  196. attocode_intel/remote.py +131 -0
  197. attocode_intel/repo_ranker.py +246 -0
  198. attocode_intel/request_context.py +82 -0
  199. attocode_intel/rules/__init__.py +28 -0
  200. attocode_intel/rules/ci.py +320 -0
  201. attocode_intel/rules/combinators.py +293 -0
  202. attocode_intel/rules/enricher.py +127 -0
  203. attocode_intel/rules/evolution.py +452 -0
  204. attocode_intel/rules/executor.py +531 -0
  205. attocode_intel/rules/filters/__init__.py +5 -0
  206. attocode_intel/rules/filters/pipeline.py +151 -0
  207. attocode_intel/rules/formatter.py +198 -0
  208. attocode_intel/rules/hygiene.py +242 -0
  209. attocode_intel/rules/importers/__init__.py +1 -0
  210. attocode_intel/rules/importers/semgrep.py +382 -0
  211. attocode_intel/rules/loader.py +292 -0
  212. attocode_intel/rules/marketplace.py +375 -0
  213. attocode_intel/rules/metavar.py +181 -0
  214. attocode_intel/rules/model.py +317 -0
  215. attocode_intel/rules/packs/__init__.py +1 -0
  216. attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
  217. attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
  218. attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
  219. attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
  220. attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
  221. attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
  222. attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
  223. attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
  224. attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
  225. attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
  226. attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
  227. attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
  228. attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
  229. attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
  230. attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
  231. attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
  232. attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
  233. attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
  234. attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
  235. attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
  236. attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
  237. attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
  238. attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
  239. attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
  240. attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
  241. attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
  242. attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
  243. attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
  244. attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
  245. attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
  246. attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
  247. attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
  248. attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
  249. attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
  250. attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
  251. attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
  252. attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
  253. attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
  254. attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
  255. attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
  256. attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
  257. attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
  258. attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
  259. attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
  260. attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
  261. attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
  262. attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
  263. attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
  264. attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
  265. attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
  266. attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
  267. attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
  268. attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
  269. attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
  270. attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
  271. attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
  272. attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
  273. attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
  274. attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
  275. attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
  276. attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
  277. attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
  278. attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
  279. attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
  280. attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
  281. attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
  282. attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
  283. attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
  284. attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
  285. attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
  286. attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
  287. attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
  288. attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
  289. attocode_intel/rules/packs/pack_loader.py +244 -0
  290. attocode_intel/rules/plugins/__init__.py +1 -0
  291. attocode_intel/rules/plugins/plugin_loader.py +132 -0
  292. attocode_intel/rules/profiling.py +332 -0
  293. attocode_intel/rules/registry.py +213 -0
  294. attocode_intel/rules/sarif.py +187 -0
  295. attocode_intel/rules/synthesis.py +420 -0
  296. attocode_intel/rules/testing.py +326 -0
  297. attocode_intel/server.py +734 -0
  298. attocode_intel/service.py +3416 -0
  299. attocode_intel/sharing.py +57 -0
  300. attocode_intel/storage/__init__.py +6 -0
  301. attocode_intel/storage/branch_overlay.py +352 -0
  302. attocode_intel/storage/content_store.py +220 -0
  303. attocode_intel/storage/dependency_store.py +143 -0
  304. attocode_intel/storage/diff_engine.py +187 -0
  305. attocode_intel/storage/embedding_store.py +594 -0
  306. attocode_intel/storage/provenance_store.py +137 -0
  307. attocode_intel/storage/security_scanner_db.py +164 -0
  308. attocode_intel/storage/symbol_store.py +160 -0
  309. attocode_intel/structural_evidence.py +144 -0
  310. attocode_intel/symbol_inspection.py +141 -0
  311. attocode_intel/symbol_links.py +167 -0
  312. attocode_intel/telemetry.py +42 -0
  313. attocode_intel/test_ranking.py +73 -0
  314. attocode_intel/testing/__init__.py +43 -0
  315. attocode_intel/testing/fixtures.py +122 -0
  316. attocode_intel/testing/helpers.py +75 -0
  317. attocode_intel/testing/mocks.py +99 -0
  318. attocode_intel/tools/__init__.py +2 -0
  319. attocode_intel/tools/adr_tools.py +628 -0
  320. attocode_intel/tools/analysis_tools.py +382 -0
  321. attocode_intel/tools/composite_tools.py +695 -0
  322. attocode_intel/tools/cross_mode_tools.py +173 -0
  323. attocode_intel/tools/dead_code_tools.py +582 -0
  324. attocode_intel/tools/distill_tools.py +353 -0
  325. attocode_intel/tools/frecency_tools.py +220 -0
  326. attocode_intel/tools/fuzzy_tools.py +255 -0
  327. attocode_intel/tools/history_tools.py +486 -0
  328. attocode_intel/tools/learning_tools.py +222 -0
  329. attocode_intel/tools/lsp_tools.py +218 -0
  330. attocode_intel/tools/maintenance_tools.py +1662 -0
  331. attocode_intel/tools/navigation_tools.py +220 -0
  332. attocode_intel/tools/overlay_tools.py +540 -0
  333. attocode_intel/tools/pin_store.py +432 -0
  334. attocode_intel/tools/pin_tools.py +266 -0
  335. attocode_intel/tools/query_constraints_tools.py +262 -0
  336. attocode_intel/tools/query_history_tools.py +158 -0
  337. attocode_intel/tools/readiness_tools.py +44 -0
  338. attocode_intel/tools/rule_tools.py +992 -0
  339. attocode_intel/tools/search_tools.py +770 -0
  340. attocode_intel/tools/snapshot_tools.py +624 -0
  341. attocode_intel/workers/__init__.py +1 -0
  342. attocode_intel/workers/job_utils.py +111 -0
  343. attocode_intel/workers/jobs.py +573 -0
  344. attocode_intel/workers/run.py +30 -0
  345. attocode_intel/workers/settings.py +61 -0
@@ -0,0 +1,2931 @@
1
+ """Semantic search manager.
2
+
3
+ Indexes codebase content into embeddings and provides natural language
4
+ search over code. Gracefully degrades to BM25 keyword matching when no
5
+ embedding provider is available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ import queue
15
+ import re
16
+ import sqlite3
17
+ import subprocess
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass, field, replace
21
+ from typing import Any
22
+
23
+ from attocode_intel.query_ranking import demote_tests, hit_evidence, rerank_broad_candidates
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # BM25 keyword search support types
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _STOP_WORDS = frozenset({
32
+ "self", "cls", "the", "is", "in", "of", "and", "or", "to", "a", "an",
33
+ "for", "not", "on", "with", "as", "at", "by", "from", "it", "be",
34
+ "this", "that", "if", "else", "def", "class", "import", "return",
35
+ "none", "true", "false", "pass", "str", "int", "float", "bool", "list",
36
+ "dict", "set", "tuple", "any", "type", "optional",
37
+ })
38
+
39
+ # Prose and data formats: down-ranked in keyword search, and kept out of
40
+ # whole-file BM25, where a long prose issue would match docs before code.
41
+ _NON_CODE_EXTS = frozenset({
42
+ ".md", ".txt", ".rst", ".cfg", ".ini", ".yml", ".yaml", ".json", ".toml", ".xml", ".csv",
43
+ })
44
+
45
+ _CAMEL_RE = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
46
+
47
+ # Mapping from common query terms to code construct types for query expansion
48
+ _CONSTRUCT_HINTS: dict[str, list[str]] = {
49
+ "function": ["def", "func", "fn"],
50
+ "class": ["class", "struct", "type"],
51
+ "method": ["def", "func", "method"],
52
+ "import": ["import", "require", "from", "use"],
53
+ "test": ["test", "spec", "assert", "expect"],
54
+ "error": ["error", "exception", "raise", "throw", "catch"],
55
+ "config": ["config", "settings", "env", "option"],
56
+ "api": ["route", "endpoint", "handler", "controller"],
57
+ "auth": ["auth", "login", "password", "token", "session"],
58
+ "database": ["query", "model", "schema", "migration", "table"],
59
+ }
60
+
61
+
62
+ def _tokenize(text: str) -> list[str]:
63
+ """Tokenize text: split camelCase/snake_case, lowercase, remove stop words."""
64
+ # Split on non-alphanumeric first
65
+ parts = re.split(r"[^a-zA-Z0-9]+", text)
66
+ tokens: list[str] = []
67
+ for part in parts:
68
+ # Split camelCase
69
+ sub_parts = _CAMEL_RE.sub("_", part).split("_")
70
+ for sp in sub_parts:
71
+ sp_lower = sp.lower()
72
+ if len(sp_lower) >= 2 and sp_lower not in _STOP_WORDS:
73
+ tokens.append(sp_lower)
74
+ return tokens
75
+
76
+
77
+ def _expand_query(query: str, language: str = "") -> str:
78
+ """Expand a search query with language and construct hints.
79
+
80
+ Prepends language context and adds related terms for known constructs,
81
+ improving recall for code-specific embedding models.
82
+
83
+ Examples:
84
+ "auth middleware" + "python" -> "python auth middleware login token session"
85
+ "parse config" -> "parse config settings env option"
86
+ """
87
+ query_lower = query.lower()
88
+ tokens = _tokenize(query)
89
+ expansions: list[str] = []
90
+
91
+ # Add language hint if known
92
+ if language:
93
+ expansions.append(language)
94
+
95
+ # Add construct-related terms for each matching concept
96
+ for concept, hints in _CONSTRUCT_HINTS.items():
97
+ if concept in query_lower or any(t == concept for t in tokens):
98
+ for hint in hints:
99
+ if hint not in query_lower and hint not in expansions:
100
+ expansions.append(hint)
101
+
102
+ if not expansions:
103
+ return query
104
+
105
+ return f"{query} {' '.join(expansions)}"
106
+
107
+
108
+ def _summarize_code_to_nl(name: str, chunk_type: str, text: str) -> str:
109
+ """Generate a heuristic NL summary from code structure.
110
+
111
+ Converts code identifiers into natural language fragments to improve
112
+ BM25 matching between NL queries and code symbols.
113
+
114
+ Examples:
115
+ "parseConfigFile" -> "parse config file"
116
+ "UserAuthMiddleware" -> "user auth middleware"
117
+ """
118
+ # Tokenize the name into natural language words
119
+ name_words = _CAMEL_RE.sub(" ", name).replace("_", " ").lower().strip()
120
+
121
+ if chunk_type == "function":
122
+ return f"function {name_words} {text[:200]}"
123
+ elif chunk_type == "class":
124
+ return f"class {name_words} {text[:200]}"
125
+ elif chunk_type == "method":
126
+ return f"method {name_words} {text[:200]}"
127
+ else:
128
+ return f"{name_words} {text[:200]}"
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class _KeywordDoc:
133
+ """A document in the BM25 keyword index."""
134
+
135
+ id: str # "func:path:name" or "file:path"
136
+ file_path: str
137
+ chunk_type: str # file, function, class, method
138
+ name: str
139
+ text: str # preview text
140
+ is_config: bool
141
+ is_test: bool
142
+ term_freqs: dict[str, int] = field(default_factory=dict)
143
+ doc_len: int = 0
144
+
145
+
146
+ @dataclass(slots=True)
147
+ class IndexProgress:
148
+ """Progress of background embedding indexing."""
149
+
150
+ total_files: int = 0
151
+ indexed_files: int = 0
152
+ failed_files: int = 0
153
+ status: str = "idle" # idle, running, paused, completed, error
154
+ coverage: float = 0.0 # indexed / total
155
+ started_at: float = 0.0
156
+ elapsed_seconds: float = 0.0
157
+ last_error: str = ""
158
+ degraded_reason: str = ""
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class ContextAssemblyConfig:
163
+ """Tunable parameters for bootstrap() and relevant_context().
164
+
165
+ Controls token budget allocation, size tier thresholds, and
166
+ symbol display limits during context assembly.
167
+ """
168
+
169
+ # bootstrap() size tier thresholds (file count)
170
+ small_repo_threshold: int = 100
171
+ large_repo_threshold: int = 5000
172
+
173
+ # bootstrap() budget ratios (with task_hint)
174
+ summary_ratio: float = 0.20
175
+ structure_ratio: float = 0.20
176
+ conventions_ratio: float = 0.05
177
+ search_ratio: float = 0.50
178
+
179
+ # bootstrap() budget ratios (without task_hint)
180
+ summary_ratio_no_hint: float = 0.40
181
+ structure_ratio_no_hint: float = 0.44
182
+ conventions_ratio_no_hint: float = 0.16
183
+
184
+ # bootstrap() medium repo structure split
185
+ medium_structure_map_ratio: float = 0.7
186
+
187
+ # bootstrap() explorer params (large repos)
188
+ explore_max_items: int = 20
189
+ explore_importance_threshold: float = 0.3
190
+
191
+ # bootstrap() hotspots count
192
+ bootstrap_hotspots_n: int = 10
193
+
194
+ # bootstrap() conventions sample size
195
+ conventions_sample_size: int = 25
196
+
197
+ # bootstrap() search top_k
198
+ bootstrap_search_top_k: int = 5
199
+
200
+ # relevant_context() max BFS depth
201
+ max_depth: int = 2
202
+
203
+ # relevant_context() symbol caps per file
204
+ center_symbol_cap: int = 8
205
+ neighbor_symbol_cap: int = 5
206
+
207
+ # relevant_context() preview limits
208
+ param_preview_limit: int = 4
209
+ base_preview_limit: int = 3
210
+ method_preview_limit: int = 4
211
+
212
+
213
+ @dataclass(slots=True)
214
+ class SearchScoringConfig:
215
+ """Tunable scoring parameters for BM25 keyword search and two-stage retrieval.
216
+
217
+ Default values match the original hardcoded constants. Adjusting these
218
+ allows the meta-harness optimization loop to explore better configurations.
219
+ """
220
+
221
+ # BM25 base parameters (optimized via meta-harness, round 5)
222
+ bm25_k1: float = 2.2 # was 1.5 → 2.8 (kw-only) → 2.2 (final with adaptive fusion)
223
+ bm25_b: float = 0.3 # was 0.75 → 0.05 (kw-only) → 0.3 (final with adaptive fusion)
224
+
225
+ # Graduated symbol name match boosts
226
+ name_exact_boost: float = 5.0
227
+ name_substring_boost: float = 3.0
228
+ name_token_boost: float = 2.2
229
+
230
+ # Definition-type boosts
231
+ class_boost: float = 1.8
232
+ function_boost: float = 1.4
233
+ method_boost: float = 1.3
234
+
235
+ # Path relevance: source directory boost
236
+ src_dir_boost: float = 1.7
237
+
238
+ # Multi-term coverage bonuses
239
+ multi_term_high_bonus: float = 2.5
240
+ multi_term_med_bonus: float = 1.8
241
+ multi_term_high_threshold: float = 0.7
242
+ multi_term_med_threshold: float = 0.4
243
+
244
+ # File type penalties
245
+ non_code_penalty: float = 0.3
246
+ config_penalty: float = 0.15
247
+ test_penalty: float = 0.6
248
+
249
+ # Exact phrase bonus
250
+ exact_phrase_bonus: float = 3.0
251
+
252
+ # Deduplication: more diversity per file when vectors return semantic neighbors
253
+ max_chunks_per_file: int = 8
254
+
255
+ # Two-stage retrieval — wide candidates + balanced default RRF
256
+ wide_k_multiplier: int = 12
257
+ wide_k_min: int = 150
258
+ rrf_k: int = 60 # default; adaptive fusion overrides per-list
259
+
260
+ # Algorithmic signals (Phase 1 improvements)
261
+ # Ablation-validated defaults: importance helps (+0.4%), rerank hurts (-1.4%) with aggressive threshold
262
+ importance_weight: float = 0.5 # file importance boost (0 = disabled)
263
+ frecency_weight: float = 0.2 # frecency boost (0 = disabled, currently no tracking data)
264
+ rerank_confidence_threshold: float = 0.0 # disabled by default — ablation showed it hurts on code queries; set >0 to enable
265
+
266
+ # Phase 2: Dependency proximity — boost results near high-ranking files in the import graph
267
+ dep_proximity_weight: float = 0.3 # 0 = disabled
268
+ dep_proximity_seed_count: int = 5 # top-N results used as "seeds" for neighborhood discovery
269
+
270
+ # Phase 3a: Adaptive fusion — vary keyword/vector weighting by keyword confidence
271
+ # When keyword search produces a confident top result, downweight vectors (avoid semantic noise)
272
+ # When keyword is uncertain, use balanced fusion to let vectors fill the gap
273
+ adaptive_fusion: bool = True # True = adapt rrf_k by keyword confidence
274
+ kw_dominance_threshold: float = 1.5 # top-1/top-2 score ratio for "dominant" classification
275
+ rrf_k_keyword_high_conf: int = 10 # sharp k_keyword when keyword is confident
276
+ rrf_k_vector_low_conf: int = 250 # smooth k_vector when keyword is confident (downweight)
277
+
278
+ # Lexical candidates (search_candidates): RRF of keyword and body chunks.
279
+ # A long query then also fuses the file order with whole-file BM25.
280
+ chunk_rrf_k: int = 20
281
+ body_weight: float = 1.15 # the keyword list has weight 1.0
282
+ body_max_tokens: int = 20 # rarest query words that body search sends
283
+ file_bm25_min_tokens: int = 20 # whole-file BM25 needs more unique words than this
284
+ file_rrf_k: int = 60
285
+ file_fusion_depth: int = 48 # files per list in whole-file fusion
286
+
287
+
288
+ @dataclass(slots=True)
289
+ class SemanticSearchResult:
290
+ """A single semantic search result."""
291
+
292
+ file_path: str
293
+ chunk_type: str # "file", "function", "class"
294
+ name: str
295
+ text: str
296
+ score: float
297
+ start_line: int = 0
298
+ end_line: int = 0
299
+
300
+
301
+ def _file_order(results: list[SemanticSearchResult]) -> list[str]:
302
+ """Each file path once, in the order of its first result."""
303
+ return list(dict.fromkeys(result.file_path for result in results))
304
+
305
+
306
+ @dataclass(slots=True)
307
+ class SemanticSearchManager:
308
+ """Manages semantic search over a codebase.
309
+
310
+ Usage::
311
+
312
+ mgr = SemanticSearchManager(root_dir="/path/to/repo")
313
+ mgr.index() # Build embeddings (once)
314
+ results = mgr.search("authentication middleware", top_k=10)
315
+ """
316
+
317
+ root_dir: str
318
+ nl_mode: str = "" # "none" or "heuristic"; "" = read ATTOCODE_NL_EMBEDDING_MODE env var
319
+ scoring_config: SearchScoringConfig = field(default_factory=SearchScoringConfig)
320
+ _provider: Any = field(default=None, repr=False)
321
+ _store: Any = field(default=None, repr=False)
322
+ _indexed: bool = field(default=False, repr=False)
323
+ _keyword_fallback: bool = field(default=False, repr=False)
324
+ _reindex_queue: queue.Queue[str] = field(default_factory=queue.Queue, repr=False)
325
+ _reindex_pending: set[str] = field(default_factory=set, repr=False)
326
+ _reindex_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
327
+ _reindex_worker_started: bool = field(default=False, repr=False)
328
+ _kw_docs: list[_KeywordDoc] = field(default_factory=list, repr=False)
329
+ _kw_df: dict[str, int] = field(default_factory=dict, repr=False)
330
+ _kw_avg_dl: float = field(default=0.0, repr=False)
331
+ _kw_index_built: bool = field(default=False, repr=False)
332
+ _kw_cache_db_path: str = field(default="", repr=False)
333
+ _kw_cache_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
334
+ _body_index_built: bool = field(default=False, repr=False)
335
+ _body_index_available: bool = field(default=True, repr=False)
336
+ _body_revision: str = field(default="", repr=False)
337
+ _body_generation: int = field(default=0, repr=False)
338
+ _body_failed_at: float = field(default=0.0, repr=False)
339
+ _body_thread: threading.Thread | None = field(default=None, repr=False)
340
+ _body_state_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
341
+ _trigram_index: Any = field(default=None, repr=False)
342
+ _bg_indexer: Any = field(default=None, repr=False)
343
+ _bg_thread: Any = field(default=None, repr=False)
344
+ _index_progress: IndexProgress = field(default_factory=IndexProgress, repr=False)
345
+ _summarizer: Any = field(default=None, repr=False)
346
+ _importance_scores: dict[str, float] = field(default_factory=dict, repr=False)
347
+ _frecency_tracker: Any = field(default=None, repr=False)
348
+ _dep_graph: Any = field(default=None, repr=False)
349
+
350
+ def __post_init__(self) -> None:
351
+ # Defer provider creation to first use (_ensure_provider) to avoid
352
+ # 5-15s model-load latency on construction.
353
+ self._provider = None
354
+ self._keyword_fallback = True # assume keyword-only until provider loads
355
+ self._kw_cache_db_path = os.path.join(
356
+ self.root_dir, ".attocode", "index", "kw_index.db",
357
+ )
358
+ if not self.nl_mode:
359
+ self.nl_mode = os.environ.get("ATTOCODE_NL_EMBEDDING_MODE", "heuristic")
360
+
361
+ def _get_importance(self, file_path: str) -> float:
362
+ """Get file importance score (0.0-1.0), lazily loaded."""
363
+ if not self._importance_scores:
364
+ self._load_importance_scores()
365
+ return self._importance_scores.get(file_path, 0.0)
366
+
367
+ def _load_importance_scores(self, context_manager: Any = None) -> None:
368
+ """Load file importance from CodebaseContextManager if available."""
369
+ try:
370
+ from attocode_intel._internal.integrations.context.codebase_context import (
371
+ CodebaseContextManager,
372
+ )
373
+ ctx = context_manager or CodebaseContextManager(self.root_dir)
374
+ ctx._ensure_fresh()
375
+ for fi in ctx._files:
376
+ self._importance_scores[fi.relative_path] = fi.importance
377
+ # Use sentinel to avoid reload if genuinely empty
378
+ if not self._importance_scores:
379
+ self._importance_scores["__empty__"] = 0.0
380
+ except Exception:
381
+ logger.debug("Failed to load importance scores", exc_info=True)
382
+
383
+ def _get_frecency_score(self, file_path: str) -> float:
384
+ """Get frecency score for a file, lazily loading the tracker."""
385
+ if self._frecency_tracker is None:
386
+ try:
387
+ from attocode_intel._internal.integrations.context.frecency import FrecencyTracker
388
+ self._frecency_tracker = FrecencyTracker(self.root_dir, ai_mode=True)
389
+ except Exception:
390
+ self._frecency_tracker = False # sentinel: don't retry
391
+ return 0.0
392
+ if self._frecency_tracker is False:
393
+ return 0.0
394
+ try:
395
+ result = self._frecency_tracker.get_score(file_path)
396
+ return float(result.score) if result else 0.0
397
+ except Exception:
398
+ return 0.0
399
+
400
+ def _get_dep_graph(self) -> Any:
401
+ """Lazily load the dependency graph from CodebaseContextManager."""
402
+ if self._dep_graph is False:
403
+ return None
404
+ if self._dep_graph is None:
405
+ try:
406
+ from attocode_intel._internal.integrations.context.codebase_context import (
407
+ CodebaseContextManager,
408
+ )
409
+ ctx = CodebaseContextManager(self.root_dir)
410
+ ctx._ensure_fresh()
411
+ self._dep_graph = ctx.dependency_graph
412
+ if self._dep_graph is None:
413
+ self._dep_graph = False
414
+ return None
415
+ except Exception:
416
+ logger.debug("Failed to load dependency graph", exc_info=True)
417
+ self._dep_graph = False
418
+ return None
419
+ return self._dep_graph
420
+
421
+ @staticmethod
422
+ def _path_rank_penalty(file_path: str) -> float:
423
+ """Multiplicative rank penalty for non-source paths (tests, benchmarks,
424
+ docs) so genuine source outranks them. 1.0 = no penalty."""
425
+ p = file_path.replace("\\", "/")
426
+ for frag in (
427
+ "/tests/", "tests/", "/test/", "asv_bench/",
428
+ "/docs_src/", "docs_src/", "/benchmarks/",
429
+ ):
430
+ if frag in p or p.startswith(frag):
431
+ return 0.6
432
+ return 1.0
433
+
434
+ def _apply_dependency_boost(
435
+ self,
436
+ fused: list[tuple[str, float]],
437
+ result_map: dict[str, SemanticSearchResult],
438
+ weight: float,
439
+ seed_count: int,
440
+ ) -> list[tuple[str, float]]:
441
+ """Boost results that are in the dependency neighborhood of top-N seed results.
442
+
443
+ For each of the top seed_count results, find files that import them or are
444
+ imported by them. Any result file in this neighborhood gets a score boost.
445
+ """
446
+ graph = self._get_dep_graph()
447
+ if graph is None or not fused:
448
+ return fused
449
+
450
+ # Collect neighborhood files from top-N seeds
451
+ neighborhood: set[str] = set()
452
+ for item_id, _ in fused[:seed_count]:
453
+ result = result_map.get(item_id)
454
+ if not result:
455
+ continue
456
+ fp = result.file_path
457
+ neighborhood.update(graph.get_imports(fp))
458
+ neighborhood.update(graph.get_importers(fp))
459
+
460
+ if not neighborhood:
461
+ return fused
462
+
463
+ # Apply boost to any result in the neighborhood
464
+ boosted: list[tuple[str, float]] = []
465
+ for item_id, score in fused:
466
+ result = result_map.get(item_id)
467
+ if result and result.file_path in neighborhood:
468
+ score *= (1.0 + weight)
469
+ boosted.append((item_id, score))
470
+ return sorted(boosted, key=lambda x: x[1], reverse=True)
471
+
472
+ def _ensure_provider(self) -> None:
473
+ """Initialize embedding provider on first use."""
474
+ if self._provider is not None:
475
+ return
476
+ from attocode_intel._internal.integrations.context.embeddings import (
477
+ NullEmbeddingProvider,
478
+ create_embedding_provider,
479
+ )
480
+ self._provider = create_embedding_provider()
481
+ self._keyword_fallback = isinstance(self._provider, NullEmbeddingProvider)
482
+ if self._keyword_fallback:
483
+ self._index_progress.degraded_reason = "embedding_provider_unavailable"
484
+ if not self._index_progress.last_error:
485
+ self._index_progress.last_error = (
486
+ "Embedding provider unavailable; semantic search is running in keyword-only mode."
487
+ )
488
+
489
+ if not self._keyword_fallback:
490
+ from attocode_intel._internal.integrations.context.vector_store import (
491
+ VectorStore,
492
+ VectorStoreDimensionMismatchError,
493
+ )
494
+ db_dir = os.path.join(self.root_dir, ".attocode", "vectors")
495
+ os.makedirs(db_dir, exist_ok=True)
496
+ # strict_dimension=False: catch the mismatch as degraded mode
497
+ # instead of crashing the whole MCP server. The provider + store
498
+ # both stay available for diagnostic tools
499
+ # (`embeddings_status`, etc.) but writes refuse to land until
500
+ # the user resolves the mismatch.
501
+ provider_name = getattr(self._provider, "name", lambda: "unknown")
502
+ provider_name_str = provider_name() if callable(provider_name) else str(provider_name)
503
+ provider_version = getattr(self._provider, "version", lambda: "")
504
+ provider_version_str = (
505
+ provider_version() if callable(provider_version) else str(provider_version)
506
+ )
507
+ try:
508
+ self._store = VectorStore(
509
+ db_path=os.path.join(db_dir, "embeddings.db"),
510
+ dimension=self._provider.dimension(),
511
+ model_name=provider_name_str,
512
+ model_version=provider_version_str,
513
+ strict_dimension=False,
514
+ )
515
+ except VectorStoreDimensionMismatchError as exc:
516
+ logger.warning(
517
+ "semantic_search: vector store unusable (%s); "
518
+ "falling back to keyword-only mode",
519
+ exc,
520
+ )
521
+ self._store = None
522
+ self._keyword_fallback = True
523
+ self._index_progress.degraded_reason = "dimension_mismatch"
524
+ self._index_progress.last_error = str(exc)
525
+ else:
526
+ if self._store.degraded:
527
+ self._index_progress.degraded_reason = self._store.degraded_reason
528
+ self._index_progress.last_error = (
529
+ f"Vector store at {self._store.db_path} is in degraded mode: "
530
+ f"{self._store.degraded_reason}. Existing vectors preserved; "
531
+ f"writes disabled until resolved."
532
+ )
533
+
534
+ if not self.nl_mode:
535
+ self.nl_mode = os.environ.get("ATTOCODE_NL_EMBEDDING_MODE", "none")
536
+ if self.nl_mode == "heuristic" and self._summarizer is None:
537
+ from attocode_intel.indexing.summarizer import get_summarizer
538
+ self._summarizer = get_summarizer()
539
+
540
+ def _start_reindex_worker(self) -> None:
541
+ """Start the background reindex worker once."""
542
+ with self._reindex_lock:
543
+ if self._reindex_worker_started:
544
+ return
545
+ self._reindex_worker_started = True
546
+
547
+ worker = threading.Thread(
548
+ target=self._reindex_worker_loop,
549
+ daemon=True,
550
+ name="semantic-reindex-worker",
551
+ )
552
+ worker.start()
553
+
554
+ def _reindex_worker_loop(self) -> None:
555
+ """Background worker for bounded, queued file reindexing."""
556
+ while True:
557
+ rel_path = self._reindex_queue.get()
558
+ try:
559
+ self.reindex_file(rel_path)
560
+ except Exception:
561
+ logger.debug("queue_reindex_failed: %s", rel_path, exc_info=True)
562
+ finally:
563
+ with self._reindex_lock:
564
+ self._reindex_pending.discard(rel_path)
565
+ self._reindex_queue.task_done()
566
+
567
+ def queue_reindex(self, file_path: str) -> None:
568
+ """Queue changes only for an already initialized embedding index.
569
+
570
+ File notifications must never initialize or download an embedding model.
571
+ Explicit indexing or search initializes the provider before enqueueing.
572
+ """
573
+ if self._keyword_fallback or not self._store:
574
+ return
575
+ try:
576
+ rel = os.path.relpath(file_path, self.root_dir)
577
+ except ValueError:
578
+ rel = file_path
579
+
580
+ self._start_reindex_worker()
581
+ with self._reindex_lock:
582
+ if rel in self._reindex_pending:
583
+ return
584
+ self._reindex_pending.add(rel)
585
+ self._reindex_queue.put(rel)
586
+
587
+ @property
588
+ def is_available(self) -> bool:
589
+ """Check if semantic search is fully available."""
590
+ self._ensure_provider()
591
+ return not self._keyword_fallback
592
+
593
+ @property
594
+ def provider_name(self) -> str:
595
+ self._ensure_provider()
596
+ return self._provider.name if self._provider else "none"
597
+
598
+ @staticmethod
599
+ def _supported_languages() -> set[str]:
600
+ """Languages the chunker actually embeds: Python/JS/TS always, plus
601
+ any tree-sitter languages (Go, C, Rust, ...) when available.
602
+
603
+ Coverage/readiness must use this set, not a hardcoded py/js/ts subset,
604
+ or non-Python repos report 0 coverage despite having a built index.
605
+ """
606
+ _ts_langs: set[str] = set()
607
+ try:
608
+ from attocode_intel._internal.integrations.context.ts_parser import supported_languages
609
+ _ts_langs = set(supported_languages())
610
+ except ImportError:
611
+ pass
612
+ return {"python", "javascript", "typescript"} | _ts_langs
613
+
614
+ def index(self, context_manager: Any = None) -> int:
615
+ """Build or update the vector index.
616
+
617
+ Args:
618
+ context_manager: Optional CodebaseContextManager for file discovery.
619
+
620
+ Returns:
621
+ Number of chunks indexed.
622
+ """
623
+ self._ensure_provider()
624
+ if self._keyword_fallback:
625
+ logger.info("Semantic search: no provider, skipping indexing")
626
+ return 0
627
+
628
+ from attocode_intel._internal.integrations.context.codebase_context import (
629
+ CodebaseContextManager,
630
+ )
631
+ from attocode_intel._internal.integrations.context.vector_store import VectorEntry
632
+
633
+ ctx = context_manager or CodebaseContextManager(root_dir=self.root_dir)
634
+ ctx._ensure_fresh()
635
+ if not ctx._files:
636
+ ctx.discover_files()
637
+
638
+ # Supported languages: Python, JS, TS always; others when tree-sitter available
639
+ _supported = self._supported_languages()
640
+
641
+ chunks: list[tuple[str, str, str, str]] = []
642
+
643
+ for f in ctx._files:
644
+ if f.language not in _supported:
645
+ continue
646
+ file_chunks = self._chunk_single_file(f.relative_path, f.path)
647
+ chunks.extend(file_chunks)
648
+
649
+ if not chunks:
650
+ return 0
651
+
652
+ # Batch embed (use NL summaries when heuristic mode is enabled)
653
+ texts = self._get_embedding_texts(chunks)
654
+ batch_size = 64
655
+ all_vectors: list[list[float]] = []
656
+
657
+ for i in range(0, len(texts), batch_size):
658
+ batch = texts[i:i + batch_size]
659
+ try:
660
+ vectors = self._provider.embed(batch)
661
+ all_vectors.extend(vectors)
662
+ except Exception:
663
+ logger.warning("Embedding batch failed at offset %d", i, exc_info=True)
664
+ all_vectors.extend([[] for _ in batch])
665
+
666
+ # Store (display signature at index 3 is preserved, not body/NL summary)
667
+ entries = []
668
+ for chunk, vec in zip(chunks, all_vectors, strict=False):
669
+ if not vec:
670
+ continue
671
+ cid = chunk[0]
672
+ fpath = chunk[1]
673
+ ctype = chunk[2]
674
+ text = chunk[3]
675
+ entries.append(VectorEntry(
676
+ id=cid,
677
+ file_path=fpath,
678
+ chunk_type=ctype,
679
+ name=cid.split(":")[-1] if ":" in cid else fpath,
680
+ text=text,
681
+ vector=vec,
682
+ ))
683
+
684
+ if entries:
685
+ self._store.upsert_batch(entries)
686
+
687
+ # Update metadata for indexed files
688
+ indexed_files: dict[str, int] = {}
689
+ for e in entries:
690
+ indexed_files[e.file_path] = indexed_files.get(e.file_path, 0) + 1
691
+ for f in ctx._files:
692
+ if f.relative_path in indexed_files:
693
+ try:
694
+ mtime = os.path.getmtime(f.path)
695
+ except OSError:
696
+ mtime = 0.0
697
+ self._store.set_file_metadata(
698
+ f.relative_path, mtime, indexed_files[f.relative_path],
699
+ )
700
+
701
+ self._indexed = True
702
+ logger.info("Semantic search: indexed %d chunks from %d files",
703
+ len(entries), len(ctx._files))
704
+ return len(entries)
705
+
706
+ def search(
707
+ self,
708
+ query: str,
709
+ top_k: int = 10,
710
+ file_filter: str = "",
711
+ two_stage: bool = True,
712
+ rerank: bool = False,
713
+ expand_query: bool = True,
714
+ ) -> list[SemanticSearchResult]:
715
+ """Search the codebase by natural language query.
716
+
717
+ Uses two-stage retrieval when vector search is available:
718
+ 1. Wide recall: vector top-50 + keyword top-50
719
+ 2. Score normalization (min-max to [0,1])
720
+ 3. Merge with Reciprocal Rank Fusion (RRF)
721
+ 4. Optional cross-encoder reranking
722
+
723
+ This approach outperforms single-stage search on code retrieval
724
+ benchmarks by combining semantic similarity with keyword matching.
725
+
726
+ Args:
727
+ query: Natural language search query.
728
+ top_k: Number of results to return.
729
+ file_filter: Optional glob pattern (e.g. "*.py").
730
+ two_stage: Whether to use two-stage retrieval (default True).
731
+ rerank: Whether to apply cross-encoder reranking after fusion.
732
+ expand_query: Whether to expand query with code construct hints.
733
+
734
+ Returns:
735
+ List of search results sorted by relevance.
736
+ """
737
+ # Detect language from file_filter for query expansion
738
+ _lang = ""
739
+ if file_filter:
740
+ _ext_map = {".py": "python", ".js": "javascript", ".ts": "typescript",
741
+ ".go": "go", ".rs": "rust", ".java": "java", ".rb": "ruby"}
742
+ for ext, lang in _ext_map.items():
743
+ if file_filter.endswith(ext):
744
+ _lang = lang
745
+ break
746
+
747
+ # Apply query expansion for better recall
748
+ expanded_query = _expand_query(query, _lang) if expand_query else query
749
+
750
+ # Search never initializes an optional embedding provider: doing so can
751
+ # load or download weights on an interactive request. Explicit indexing
752
+ # activates vectors; until then, lexical candidates remain available.
753
+ if self._provider is None:
754
+ return self.search_candidates(expanded_query, top_k, file_filter)
755
+
756
+ # An explicitly initialized vector path retains keyword fusion.
757
+ if not self._kw_index_built:
758
+ self._build_keyword_index()
759
+
760
+ self._ensure_provider()
761
+ if self._keyword_fallback:
762
+ return self.search_candidates(expanded_query, top_k, file_filter)
763
+
764
+ # Refuse vectors produced by a different embedding model (manual-reindex
765
+ # migration): serving them returns semantically garbage results.
766
+ store_model = getattr(self._store, "model_name", "") if self._store else ""
767
+ active_model = getattr(self._provider, "name", "")
768
+ if store_model and active_model and store_model != active_model:
769
+ logger.warning(
770
+ "Vector index built with %s but active model is %s; serving "
771
+ "keyword results only. Run reindex to rebuild vectors.",
772
+ store_model, active_model,
773
+ )
774
+ return self.search_candidates(expanded_query, top_k, file_filter)
775
+
776
+ # Coverage-based switchover: use keyword fallback while indexing
777
+ if not self._indexed and self._store:
778
+ count = self._store.count()
779
+ if count == 0 and self._bg_indexer is None:
780
+ # No embeddings and no background indexer — use keyword fallback
781
+ return self.search_candidates(expanded_query, top_k, file_filter)
782
+ elif not self.is_index_ready() and self._bg_indexer is not None:
783
+ # Indexer running but coverage < 80% — use keyword fallback
784
+ return self.search_candidates(expanded_query, top_k, file_filter)
785
+
786
+ # Embed query — use expanded query for better recall
787
+ try:
788
+ query_vectors = self._provider.embed_query([expanded_query])
789
+ if not query_vectors or not query_vectors[0]:
790
+ self._index_progress.degraded_reason = "query_embedding_failed"
791
+ self._index_progress.last_error = "Query embedding returned no vector."
792
+ return self.search_candidates(expanded_query, top_k, file_filter)
793
+ query_vec = query_vectors[0]
794
+ except Exception as exc:
795
+ logger.warning("Query embedding failed, falling back to keyword", exc_info=True)
796
+ self._index_progress.degraded_reason = "query_embedding_failed"
797
+ self._index_progress.last_error = f"Query embedding failed: {type(exc).__name__}: {exc}"
798
+ return self.search_candidates(expanded_query, top_k, file_filter)
799
+
800
+ # Build set of files that exist on disk to filter out stale branch data.
801
+ # In local mode the filesystem is the source of truth — vectors from
802
+ # files that no longer exist (e.g. after a branch switch) are skipped.
803
+ existing_files: set[str] | None = None
804
+ try:
805
+ indexed_paths = self._store.get_all_indexed_files()
806
+ if indexed_paths:
807
+ existing_files = {
808
+ p for p in indexed_paths
809
+ if os.path.exists(os.path.join(self.root_dir, p))
810
+ }
811
+ except Exception:
812
+ logger.debug("Failed to build existing_files set", exc_info=True)
813
+
814
+ # Stage 1a: Vector search (wide recall)
815
+ cfg = self.scoring_config
816
+ wide_k = max(top_k * cfg.wide_k_multiplier, cfg.wide_k_min) if two_stage else top_k
817
+ raw_results = self._store.search(
818
+ query_vec, top_k=wide_k, file_filter=file_filter,
819
+ existing_files=existing_files,
820
+ )
821
+
822
+ if not two_stage:
823
+ return [
824
+ SemanticSearchResult(
825
+ file_path=r.file_path,
826
+ chunk_type=r.chunk_type,
827
+ name=r.name,
828
+ text=r.text,
829
+ score=r.score,
830
+ )
831
+ for r in raw_results[:top_k]
832
+ ]
833
+
834
+ # Stage 1b: Keyword search (complementary recall) — always run
835
+ keyword_results = self.search_candidates(expanded_query, top_k=wide_k, file_filter=file_filter)
836
+
837
+ # If both pipelines returned nothing, bail out early
838
+ if not raw_results and not keyword_results:
839
+ return []
840
+
841
+ # Stage 2: Score normalization + Reciprocal Rank Fusion
842
+ from attocode_intel._internal.integrations.context.ast_chunker import reciprocal_rank_fusion
843
+
844
+ # Map chunk_type to vector ID prefix for consistent key space
845
+ _type_prefix = {"function": "func", "class": "cls", "method": "method", "file": "file"}
846
+
847
+ vector_ranked = self._normalize_scores(
848
+ [(r.id, r.score) for r in raw_results],
849
+ )
850
+ def _result_id(result: SemanticSearchResult) -> str:
851
+ prefix = _type_prefix.get(result.chunk_type, result.chunk_type)
852
+ if result.chunk_type == "file":
853
+ return f"file:{result.file_path}"
854
+ return f"{prefix}:{result.file_path}:{result.name}"
855
+
856
+ # Use composite IDs for keyword results matching vector key space
857
+ keyword_ranked = self._normalize_scores([
858
+ (_result_id(r), r.score)
859
+ for r in keyword_results
860
+ ])
861
+
862
+ # Adaptive fusion: trust keyword more only when BOTH lists agree on the top result.
863
+ # Agreement = vector and keyword identify the same top file → keyword is genuinely correct.
864
+ # Disagreement = treat both as uncertain, use balanced fusion (vectors may have semantic insight).
865
+ # This avoids over-trusting keyword when its top match is high-dominance but wrong (Redis case).
866
+ if cfg.adaptive_fusion and keyword_ranked and vector_ranked:
867
+ kw_top = keyword_ranked[0][1] if keyword_ranked else 0.0
868
+ kw_second = keyword_ranked[1][1] if len(keyword_ranked) > 1 else 0.0
869
+ dominance = (kw_top / kw_second) if kw_second > 0 else float("inf")
870
+
871
+ # Extract file path from top results (id format: "type:path:name")
872
+ def _file_of(item_id: str) -> str:
873
+ parts = item_id.split(":", 2)
874
+ return parts[1] if len(parts) >= 2 else item_id
875
+
876
+ kw_top_file = _file_of(keyword_ranked[0][0])
877
+ # Cross-modal agreement: keyword top-1 file appears in vector top-3
878
+ # Strict agreement protects redis-style queries (where vectors are correct
879
+ # and keyword is wrong) — keyword shouldn't be trusted unless vectors confirm.
880
+ vec_top_files = {_file_of(r[0]) for r in vector_ranked[:3]}
881
+ agreement = kw_top_file in vec_top_files
882
+ high_confidence = dominance >= cfg.kw_dominance_threshold and agreement
883
+
884
+ if high_confidence:
885
+ k_keyword = cfg.rrf_k_keyword_high_conf
886
+ k_vector = cfg.rrf_k_vector_low_conf
887
+ else:
888
+ k_keyword = cfg.rrf_k
889
+ k_vector = cfg.rrf_k
890
+
891
+ # Per-list weighted RRF (inline to support per-list k)
892
+ fused_dict: dict[str, float] = {}
893
+ for rank, (item_id, _) in enumerate(vector_ranked):
894
+ fused_dict[item_id] = fused_dict.get(item_id, 0.0) + 1.0 / (k_vector + rank + 1)
895
+ for rank, (item_id, _) in enumerate(keyword_ranked):
896
+ fused_dict[item_id] = fused_dict.get(item_id, 0.0) + 1.0 / (k_keyword + rank + 1)
897
+ fused = sorted(fused_dict.items(), key=lambda x: x[1], reverse=True)
898
+ else:
899
+ fused = reciprocal_rank_fusion(vector_ranked, keyword_ranked, k=cfg.rrf_k)
900
+
901
+ # Build result lookup for fast access
902
+ result_map: dict[str, SemanticSearchResult] = {}
903
+ for r in raw_results:
904
+ result_map[r.id] = SemanticSearchResult(
905
+ file_path=r.file_path,
906
+ chunk_type=r.chunk_type,
907
+ name=r.name,
908
+ text=r.text,
909
+ score=r.score,
910
+ )
911
+ for r in keyword_results:
912
+ kw_id = _result_id(r)
913
+ if kw_id not in result_map:
914
+ result_map[kw_id] = r
915
+
916
+ # Cross-encoder reranking: explicit or lazy (auto-trigger on low confidence)
917
+ if rerank:
918
+ rerank_k = min(len(fused), 3 * top_k)
919
+ fused = self._rerank(query, fused[:rerank_k], result_map, top_k)
920
+ elif cfg.rerank_confidence_threshold > 0 and fused:
921
+ top_score = fused[0][1] if fused else 0
922
+ if top_score < cfg.rerank_confidence_threshold:
923
+ rerank_k = min(len(fused), 3 * top_k)
924
+ fused = self._rerank(query, fused[:rerank_k], result_map, top_k)
925
+
926
+ # Dependency proximity boost: files structurally close to top seeds get a bump
927
+ if cfg.dep_proximity_weight > 0 and fused:
928
+ fused = self._apply_dependency_boost(
929
+ fused, result_map,
930
+ weight=cfg.dep_proximity_weight,
931
+ seed_count=cfg.dep_proximity_seed_count,
932
+ )
933
+
934
+ # Frecency boost: recently-accessed files get a score bump
935
+ if cfg.frecency_weight > 0:
936
+ boosted: list[tuple[str, float]] = []
937
+ for item_id, fused_score in fused:
938
+ result = result_map.get(item_id)
939
+ if result:
940
+ frec = self._get_frecency_score(result.file_path)
941
+ if frec > 0:
942
+ boost = min(frec / 100.0, 0.3) * cfg.frecency_weight
943
+ fused_score *= (1.0 + boost)
944
+ boosted.append((item_id, fused_score))
945
+ fused = sorted(boosted, key=lambda x: x[1], reverse=True)
946
+
947
+ # Path penalty: down-weight tests/benchmarks/docs so source ranks above them
948
+ penalized: list[tuple[str, float]] = []
949
+ for item_id, fused_score in fused:
950
+ result = result_map.get(item_id)
951
+ if result:
952
+ fused_score *= self._path_rank_penalty(result.file_path)
953
+ penalized.append((item_id, fused_score))
954
+ fused = sorted(penalized, key=lambda x: x[1], reverse=True)
955
+
956
+ # Return top-k by fused score
957
+ merged: list[SemanticSearchResult] = []
958
+ for item_id, fused_score in fused[:top_k]:
959
+ result = result_map.get(item_id)
960
+ if result:
961
+ result.score = round(fused_score, 4)
962
+ merged.append(result)
963
+
964
+ return merged
965
+
966
+ def search_candidates(
967
+ self, query: str, top_k: int = 50, file_filter: str = "",
968
+ trace: dict[str, list[str]] | None = None, tests_last: bool = True,
969
+ ) -> list[SemanticSearchResult]:
970
+ """Return local lexical candidates without initializing an embedding model.
971
+
972
+ Name/path/docstring BM25 and source-body FTS5 each contribute a ranked
973
+ list. Explicit exclusions only down-rank matches; they never remove a
974
+ candidate. This is also the provider-free entry point for bootstrap.
975
+
976
+ A ``trace`` dict receives the file order of each stage. The keys are
977
+ ``keyword``, ``body``, ``file_bm25``, ``chunk_fused`` (before whole-file
978
+ fusion) and ``fused`` (before the broad-query rerank).
979
+
980
+ ``tests_last`` puts test files after the other files, unless a short query
981
+ asks for tests (``demote_tests``). Task scores for test suggestions turn it off.
982
+ """
983
+ if top_k <= 0:
984
+ return []
985
+ from attocode_intel.focused_evidence import task_terms
986
+
987
+ positive, negative = task_terms(query)
988
+ # The indexes keep plurals and match whole words: send "settings" as written too.
989
+ positive |= task_terms(query, fold=False)[0]
990
+ terms = sorted(positive) if positive else _tokenize(query)
991
+ if not terms:
992
+ return []
993
+ if not self._kw_index_built:
994
+ self._schedule_body_index()
995
+ return []
996
+ cfg = self.scoring_config
997
+ lexical_query = " ".join(terms)
998
+ wide_k = max(top_k * 3, 60)
999
+ keyword = self._keyword_search(lexical_query, wide_k, file_filter)
1000
+ body = self._body_search(terms, wide_k, file_filter)
1001
+ files = self._file_search(terms, cfg.file_fusion_depth, file_filter)
1002
+ if not self._kw_index_built:
1003
+ # A body hit failed freshness validation while keyword results
1004
+ # were being scored. Do not expose potentially stale candidates.
1005
+ self._schedule_body_index()
1006
+ return []
1007
+ if trace is not None:
1008
+ trace.update(keyword=_file_order(keyword), body=_file_order(body), file_bm25=files)
1009
+ if not body and not files:
1010
+ keyword = self._penalize_exclusions(keyword, negative)
1011
+ if trace is not None:
1012
+ trace["chunk_fused"] = _file_order(keyword)
1013
+ if tests_last:
1014
+ keyword = demote_tests(query, keyword)
1015
+ if trace is not None:
1016
+ trace["fused"] = _file_order(keyword)
1017
+ return rerank_broad_candidates(query, keyword, top_k, file_filter)
1018
+
1019
+ def key(result: SemanticSearchResult) -> tuple[str, str, str]:
1020
+ return result.file_path, result.chunk_type, result.name
1021
+
1022
+ candidates: dict[tuple[str, str, str], SemanticSearchResult] = {}
1023
+ scores: dict[tuple[str, str, str], float] = {}
1024
+ for weight, ranked in ((1.0, keyword), (cfg.body_weight, body)):
1025
+ for rank, result in enumerate(ranked):
1026
+ item = key(result)
1027
+ scores[item] = scores.get(item, 0.0) + weight / (cfg.chunk_rrf_k + rank + 1)
1028
+ # Source evidence is more useful to consumers than a signature.
1029
+ if item not in candidates or (ranked is body and result.start_line):
1030
+ candidates[item] = result
1031
+
1032
+ ordered = sorted(scores, key=lambda item: (-scores[item], item))
1033
+ fused = [
1034
+ SemanticSearchResult(
1035
+ file_path=candidates[item].file_path,
1036
+ chunk_type=candidates[item].chunk_type,
1037
+ name=candidates[item].name,
1038
+ text=candidates[item].text,
1039
+ score=round(scores[item], 6),
1040
+ start_line=candidates[item].start_line,
1041
+ end_line=candidates[item].end_line,
1042
+ )
1043
+ for item in ordered
1044
+ ]
1045
+ fused = self._penalize_exclusions(fused, negative)
1046
+ if trace is not None:
1047
+ trace["chunk_fused"] = _file_order(fused)
1048
+ if files:
1049
+ fused = self._fuse_file_order(fused, files)
1050
+ if tests_last:
1051
+ fused = demote_tests(query, fused)
1052
+ if trace is not None:
1053
+ trace["fused"] = _file_order(fused)
1054
+ return rerank_broad_candidates(query, fused, top_k, file_filter)
1055
+
1056
+ def _fuse_file_order(
1057
+ self, results: list[SemanticSearchResult], files: list[str],
1058
+ ) -> list[SemanticSearchResult]:
1059
+ """Fuse the file order of ``results`` with whole-file BM25.
1060
+
1061
+ This is the Loc-Bench eval arm: equal-weight RRF (k=60) of the two
1062
+ file orders, each cut to 48 files. Each fused file's first entry moves
1063
+ to the front in fused order, and a file that only BM25 found gets an
1064
+ entry. The other entries follow in their old order, scored no higher.
1065
+ """
1066
+ cfg = self.scoring_config
1067
+ first: dict[str, SemanticSearchResult] = {}
1068
+ for result in results:
1069
+ first.setdefault(result.file_path, result)
1070
+ fused: dict[str, float] = {}
1071
+ for order in (list(first)[:cfg.file_fusion_depth], files[:cfg.file_fusion_depth]):
1072
+ for rank, path in enumerate(order):
1073
+ fused[path] = fused.get(path, 0.0) + 1.0 / (cfg.file_rrf_k + rank + 1)
1074
+ heads = []
1075
+ for path in sorted(fused, key=lambda p: (-fused[p], p)):
1076
+ head = first.get(path) or self._file_entry(path)
1077
+ if head is not None:
1078
+ heads.append(replace(head, score=round(fused[path], 6)))
1079
+ moved = {id(first[path]) for path in fused if path in first}
1080
+ floor = heads[-1].score if heads else 0.0
1081
+ return heads + [
1082
+ replace(result, score=min(result.score, floor))
1083
+ for result in results if id(result) not in moved
1084
+ ]
1085
+
1086
+ @staticmethod
1087
+ def _penalize_exclusions(
1088
+ results: list[SemanticSearchResult], negative: set[str],
1089
+ ) -> list[SemanticSearchResult]:
1090
+ if not negative:
1091
+ return results
1092
+ penalized: list[SemanticSearchResult] = []
1093
+ for result in results:
1094
+ identity = set(_tokenize(f"{result.file_path} {result.name}"))
1095
+ source = set(_tokenize(result.text))
1096
+ # Explicit negatives such as "without swarm" also cover compound
1097
+ # path segments like ``attoswarm``; this is only a soft penalty.
1098
+ identity_hits = sum(
1099
+ any(term == token or (len(term) >= 4 and term in token) for token in identity)
1100
+ for term in negative
1101
+ )
1102
+ source_hits = len(source & negative)
1103
+ factor = 0.25 ** identity_hits * 0.8 ** source_hits
1104
+ penalized.append(SemanticSearchResult(
1105
+ file_path=result.file_path,
1106
+ chunk_type=result.chunk_type,
1107
+ name=result.name,
1108
+ text=result.text,
1109
+ score=round(result.score * factor, 6),
1110
+ start_line=result.start_line,
1111
+ end_line=result.end_line,
1112
+ ))
1113
+ return sorted(penalized, key=lambda result: -result.score)
1114
+
1115
+ def _source_revision(self) -> str:
1116
+ """Detect checkouts across branches without trusting stale cached rows."""
1117
+ try:
1118
+ result = subprocess.run(
1119
+ ["git", "rev-parse", "HEAD"], cwd=self.root_dir,
1120
+ capture_output=True, text=True, timeout=1, check=False,
1121
+ )
1122
+ return result.stdout.strip() if result.returncode == 0 else ""
1123
+ except (OSError, subprocess.TimeoutExpired):
1124
+ return ""
1125
+
1126
+ def _open_body_db(self) -> sqlite3.Connection | None:
1127
+ """Open the optional FTS5 index; classic BM25 remains the fallback."""
1128
+ if not self._body_index_available:
1129
+ return None
1130
+ conn = self._open_kw_cache_db()
1131
+ if conn is None:
1132
+ return None
1133
+ try:
1134
+ version_row = conn.execute(
1135
+ "SELECT value FROM metadata WHERE key = 'body_schema_version'",
1136
+ ).fetchone()
1137
+ if version_row is None or version_row[0] != "4":
1138
+ conn.execute("DROP TABLE IF EXISTS body_fts")
1139
+ conn.execute("DROP TABLE IF EXISTS body_file_fts")
1140
+ conn.execute("DROP TABLE IF EXISTS body_files")
1141
+ conn.execute("""
1142
+ CREATE TABLE IF NOT EXISTS body_files (
1143
+ file_path TEXT PRIMARY KEY, mtime_ns INTEGER NOT NULL,
1144
+ size INTEGER NOT NULL
1145
+ )
1146
+ """)
1147
+ conn.execute("""
1148
+ CREATE VIRTUAL TABLE IF NOT EXISTS body_fts USING fts5(
1149
+ file_path UNINDEXED, chunk_type UNINDEXED, name UNINDEXED,
1150
+ start_line UNINDEXED, end_line UNINDEXED,
1151
+ body UNINDEXED, terms
1152
+ )
1153
+ """)
1154
+ # One row per file: BM25 over a whole file counts matches that
1155
+ # are spread over many chunks.
1156
+ conn.execute("""
1157
+ CREATE VIRTUAL TABLE IF NOT EXISTS body_file_fts USING fts5(
1158
+ file_path UNINDEXED, terms
1159
+ )
1160
+ """)
1161
+ if version_row is None or version_row[0] != "4":
1162
+ conn.execute(
1163
+ "INSERT OR REPLACE INTO metadata (key, value) "
1164
+ "VALUES ('body_schema_version', '4')",
1165
+ )
1166
+ conn.commit()
1167
+ return conn
1168
+ except sqlite3.OperationalError:
1169
+ logger.debug("FTS5 unavailable; using AST keyword search", exc_info=True)
1170
+ self._body_index_available = False
1171
+ conn.close()
1172
+ return None
1173
+
1174
+ @staticmethod
1175
+ def _body_chunks(rel_path: str, abs_path: str) -> list[tuple[str, str, int, int, str, str]]:
1176
+ """Produce bounded, line-accurate source windows with enclosing symbols."""
1177
+ from attocode_intel._internal.integrations.context.codebase_ast import parse_file
1178
+
1179
+ try:
1180
+ with open(abs_path, encoding="utf-8", errors="replace") as source:
1181
+ lines = source.read().splitlines()
1182
+ except OSError:
1183
+ return []
1184
+ if not lines:
1185
+ return []
1186
+
1187
+ symbol_spans: list[tuple[int, int, str, str]] = []
1188
+ class_spans: list[tuple[int, int, str, str]] = []
1189
+ try:
1190
+ ast = parse_file(abs_path)
1191
+ symbol_spans.extend(
1192
+ (f.start_line, f.end_line, "function", f.name) for f in ast.functions
1193
+ )
1194
+ for cls in ast.classes:
1195
+ class_spans.append((cls.start_line, cls.end_line, "class", cls.name))
1196
+ symbol_spans.extend(
1197
+ (m.start_line, m.end_line, "method", f"{cls.name}.{m.name}")
1198
+ for m in cls.methods
1199
+ )
1200
+ except Exception:
1201
+ logger.debug("Source window AST parse failed for %s", rel_path, exc_info=True)
1202
+
1203
+ chunks: list[tuple[str, str, int, int, str, str]] = []
1204
+
1205
+ def emit(first: int, last: int, chunk_type: str, name: str) -> None:
1206
+ """Emit windows within one owner span (1-based inclusive)."""
1207
+ start = max(0, first - 1)
1208
+ stop = min(len(lines), last)
1209
+ while start < stop:
1210
+ shown: list[str] = []
1211
+ chars = 0
1212
+ for line in lines[start:min(start + 32, stop)]:
1213
+ remaining = 2400 - chars
1214
+ if remaining <= 0:
1215
+ break
1216
+ shown.append(line[:remaining])
1217
+ chars += min(len(line), remaining) + 1
1218
+ if len(line) >= remaining:
1219
+ break
1220
+ if not shown:
1221
+ break
1222
+ end = start + len(shown)
1223
+ body = "\n".join(shown)
1224
+ terms = " ".join(_tokenize(body))
1225
+ if terms:
1226
+ chunks.append((chunk_type, name, start + 1, end, body, terms))
1227
+ # Four-line overlap catches matches near a window boundary.
1228
+ start = max(start + 1, end - 4) if end < stop else end
1229
+
1230
+ covered = [False] * len(lines)
1231
+ for first, last, chunk_type, name in symbol_spans:
1232
+ if first <= 0 or last < first:
1233
+ continue
1234
+ emit(first, last, chunk_type, name)
1235
+ for index in range(first - 1, min(last, len(lines))):
1236
+ covered[index] = True
1237
+
1238
+ # Imports, constants, class attributes, and parser-unsupported files
1239
+ # still have searchable source windows, but cannot impersonate methods.
1240
+ cursor = 0
1241
+ while cursor < len(lines):
1242
+ if covered[cursor]:
1243
+ cursor += 1
1244
+ continue
1245
+ first = cursor
1246
+ while cursor < len(lines) and not covered[cursor]:
1247
+ cursor += 1
1248
+ middle = (first + cursor + 1) // 2
1249
+ enclosing = [span for span in class_spans if span[0] <= middle <= span[1]]
1250
+ if enclosing:
1251
+ _first, _last, chunk_type, name = min(
1252
+ enclosing, key=lambda span: (span[1] - span[0], span[0]),
1253
+ )
1254
+ else:
1255
+ chunk_type, name = "file", os.path.basename(rel_path)
1256
+ emit(first + 1, cursor, chunk_type, name)
1257
+ return chunks
1258
+
1259
+ def _sync_body_index(self, context_manager: Any = None) -> None:
1260
+ """Refresh changed/deleted discovered files, including branch changes."""
1261
+ generation = self._body_generation
1262
+ revision = self._source_revision()
1263
+ if self._body_index_built and revision == self._body_revision:
1264
+ return
1265
+ conn = self._open_body_db()
1266
+ if conn is None:
1267
+ return
1268
+ from attocode_intel._internal.integrations.context.codebase_context import (
1269
+ EXTENSION_LANGUAGES,
1270
+ CodebaseContextManager,
1271
+ )
1272
+
1273
+ try:
1274
+ ctx = context_manager or CodebaseContextManager(root_dir=self.root_dir)
1275
+ ctx._ensure_fresh()
1276
+ if not ctx._files:
1277
+ ctx.discover_files()
1278
+ current: dict[str, tuple[str, int, int]] = {}
1279
+ root_real = os.path.realpath(self.root_dir)
1280
+ for file_info in ctx._files:
1281
+ try:
1282
+ # Discovery can include symlinks. Never read or persist
1283
+ # source from outside the selected workspace.
1284
+ if os.path.commonpath((root_real, os.path.realpath(file_info.path))) != root_real:
1285
+ continue
1286
+ stat = os.stat(file_info.path)
1287
+ except (OSError, ValueError):
1288
+ continue
1289
+ # Avoid loading generated or giant files into the interactive index.
1290
+ if stat.st_size <= 512 * 1024:
1291
+ current[file_info.relative_path] = (
1292
+ file_info.path, stat.st_mtime_ns, stat.st_size,
1293
+ )
1294
+ with self._kw_cache_lock:
1295
+ cached = {
1296
+ path: (mtime, size)
1297
+ for path, mtime, size in conn.execute(
1298
+ "SELECT file_path, mtime_ns, size FROM body_files",
1299
+ )
1300
+ }
1301
+ deleted = set(cached) - set(current)
1302
+ stale = {
1303
+ path for path, (_abs, mtime, size) in current.items()
1304
+ if cached.get(path) != (mtime, size)
1305
+ }
1306
+ for path in sorted(deleted | stale):
1307
+ conn.execute("DELETE FROM body_fts WHERE file_path = ?", (path,))
1308
+ conn.execute("DELETE FROM body_file_fts WHERE file_path = ?", (path,))
1309
+ conn.execute("DELETE FROM body_files WHERE file_path = ?", (path,))
1310
+ for path in sorted(stale):
1311
+ abs_path, mtime, size = current[path]
1312
+ for chunk_type, name, start, end, body, terms in self._body_chunks(path, abs_path):
1313
+ conn.execute(
1314
+ "INSERT INTO body_fts "
1315
+ "(file_path, chunk_type, name, start_line, end_line, body, terms) "
1316
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
1317
+ (path, chunk_type, name, start, end, body, terms),
1318
+ )
1319
+ # Source files only: docs and data stay in the chunk search.
1320
+ ext = os.path.splitext(path)[1].lower()
1321
+ file_terms = ""
1322
+ if ext in EXTENSION_LANGUAGES and ext not in _NON_CODE_EXTS:
1323
+ try:
1324
+ with open(abs_path, encoding="utf-8", errors="replace") as source:
1325
+ file_terms = " ".join(_tokenize(source.read()))
1326
+ except OSError:
1327
+ pass
1328
+ if file_terms:
1329
+ conn.execute(
1330
+ "INSERT INTO body_file_fts (file_path, terms) VALUES (?, ?)",
1331
+ (path, file_terms),
1332
+ )
1333
+ conn.execute(
1334
+ "INSERT INTO body_files (file_path, mtime_ns, size) VALUES (?, ?, ?)",
1335
+ (path, mtime, size),
1336
+ )
1337
+ conn.commit()
1338
+ self._body_index_built = generation == self._body_generation
1339
+ self._body_revision = revision
1340
+ except Exception:
1341
+ logger.debug("Failed to refresh source-body index", exc_info=True)
1342
+ finally:
1343
+ conn.close()
1344
+
1345
+ def _schedule_body_index(self) -> None:
1346
+ """Warm both lexical indexes once without delaying navigation."""
1347
+ if self._kw_index_built and not self._body_index_available:
1348
+ return
1349
+ with self._body_state_lock:
1350
+ if self._body_thread is not None and self._body_thread.is_alive():
1351
+ return
1352
+ if self._body_failed_at and time.monotonic() - self._body_failed_at < 5.0:
1353
+ return
1354
+ worker = threading.Thread(
1355
+ target=self._warm_candidate_indexes, daemon=True,
1356
+ name="lexical-candidate-indexer",
1357
+ )
1358
+ self._body_thread = worker
1359
+ worker.start()
1360
+
1361
+ def warm(self) -> None:
1362
+ """Start the lexical warm-up in the background, unless both indexes are ready."""
1363
+ if not (self._kw_index_built and (self._body_index_built or not self._body_index_available)):
1364
+ self._schedule_body_index()
1365
+
1366
+ def _warm_candidate_indexes(self) -> None:
1367
+ """Build AST and optional body indexes, honoring mid-build invalidation."""
1368
+ generation = self._body_generation
1369
+ try:
1370
+ from attocode_intel._internal.integrations.context.codebase_context import (
1371
+ CodebaseContextManager,
1372
+ )
1373
+
1374
+ # One file discovery serves all three steps. A new context per step
1375
+ # discovered the files and built the dependency graph three times.
1376
+ ctx = CodebaseContextManager(root_dir=self.root_dir)
1377
+ if not self._kw_index_built:
1378
+ self._build_keyword_index(ctx)
1379
+ if generation != self._body_generation:
1380
+ self._kw_index_built = False
1381
+ return
1382
+ # Importance is used in first-query scoring; trigram mmap files
1383
+ # remain lazy to avoid holding descriptors in idle workspaces.
1384
+ self._load_importance_scores(ctx)
1385
+ if self._body_index_available and not self._body_index_built:
1386
+ self._sync_body_index(ctx)
1387
+ except Exception:
1388
+ logger.warning("Lexical candidate warm-up failed", exc_info=True)
1389
+ finally:
1390
+ if generation == self._body_generation and not (
1391
+ self._kw_index_built
1392
+ and (self._body_index_built or not self._body_index_available)
1393
+ ):
1394
+ self._body_failed_at = time.monotonic()
1395
+
1396
+ def wait_for_body_index(self, timeout: float = 30.0) -> bool:
1397
+ """Wait for background warm-up in tests and explicit offline evaluations."""
1398
+ worker = self._body_thread
1399
+ if worker is not None:
1400
+ worker.join(timeout=timeout)
1401
+ return self._body_index_built
1402
+
1403
+ def _body_search(
1404
+ self, query_terms: list[str], top_k: int, file_filter: str,
1405
+ ) -> list[SemanticSearchResult]:
1406
+ """Find body-only implementations; all MATCH terms are tokenizer-made."""
1407
+ if top_k <= 0:
1408
+ return []
1409
+ if not self._body_index_built or self._source_revision() != self._body_revision:
1410
+ self._body_index_built = False
1411
+ self._schedule_body_index()
1412
+ return []
1413
+ conn = self._open_body_db()
1414
+ if conn is None:
1415
+ return []
1416
+ import fnmatch
1417
+
1418
+ tokens = list(dict.fromkeys(t for term in query_terms for t in _tokenize(term)))
1419
+ if not tokens:
1420
+ conn.close()
1421
+ return []
1422
+ try:
1423
+ # Filter before the SQL limit. Applying a glob only to the fetched
1424
+ # rows can hide all matches from a requested language or subtree.
1425
+ where = "WHERE terms MATCH ?"
1426
+ parameters: list[Any] = []
1427
+ if file_filter:
1428
+ where += " AND body_fts.file_path GLOB ?"
1429
+ parameters.append(file_filter.replace("[!", "[^"))
1430
+ parameters.append(max(top_k * 8, 120))
1431
+ limit = self.scoring_config.body_max_tokens
1432
+ with self._kw_cache_lock:
1433
+ if len(tokens) > limit:
1434
+ tokens = self._rarest_body_tokens(conn, tokens, limit)
1435
+ if not tokens:
1436
+ return []
1437
+ parameters.insert(0, " OR ".join(f'"{token}"' for token in tokens))
1438
+ rows = conn.execute(
1439
+ "SELECT body_fts.file_path, chunk_type, name, start_line, end_line, "
1440
+ "body, bm25(body_fts), body_files.mtime_ns, body_files.size "
1441
+ "FROM body_fts JOIN body_files "
1442
+ "ON body_fts.file_path = body_files.file_path "
1443
+ + where + " ORDER BY bm25(body_fts), body_fts.file_path, "
1444
+ "body_fts.start_line, body_fts.name LIMIT ?",
1445
+ parameters,
1446
+ ).fetchall()
1447
+ except sqlite3.Error:
1448
+ logger.debug("Source-body query failed; using AST keyword results", exc_info=True)
1449
+ return []
1450
+ finally:
1451
+ conn.close()
1452
+
1453
+ selected: dict[tuple[str, str, str], SemanticSearchResult] = {}
1454
+ files: dict[str, int] = {}
1455
+ valid_paths: dict[str, bool] = {}
1456
+ stale_paths: set[str] = set()
1457
+ for path, chunk_type, name, start, end, body, rank, mtime_ns, size in rows:
1458
+ if file_filter and not fnmatch.fnmatch(path, file_filter):
1459
+ continue
1460
+ if path not in valid_paths:
1461
+ try:
1462
+ stat = os.stat(os.path.join(self.root_dir, path))
1463
+ valid_paths[path] = (stat.st_mtime_ns, stat.st_size) == (mtime_ns, size)
1464
+ except OSError:
1465
+ valid_paths[path] = False
1466
+ if not valid_paths[path]:
1467
+ stale_paths.add(path)
1468
+ continue
1469
+ key = (path, chunk_type, name)
1470
+ if key in selected or files.get(path, 0) >= self.scoring_config.max_chunks_per_file:
1471
+ continue
1472
+ files[path] = files.get(path, 0) + 1
1473
+ selected[key] = SemanticSearchResult(
1474
+ file_path=path, chunk_type=chunk_type, name=name,
1475
+ text=f"{path}:{start}-{end}\n{body}",
1476
+ score=max(0.0, -float(rank)),
1477
+ start_line=int(start), end_line=int(end),
1478
+ )
1479
+ if len(selected) >= top_k:
1480
+ break
1481
+ if stale_paths:
1482
+ for path in stale_paths:
1483
+ self.invalidate_file(os.path.join(self.root_dir, path))
1484
+ return []
1485
+ return list(selected.values())
1486
+
1487
+ def _file_search(self, query_terms: list[str], top_k: int, file_filter: str) -> list[str]:
1488
+ """Rank whole source files by BM25 over their terms; fresh files only.
1489
+
1490
+ Only for a query of more than 20 words, where the chunk search keeps
1491
+ only part of the query. On a short query, BM25 length normalization
1492
+ prefers tiny files that mention a name over the file that defines it.
1493
+ """
1494
+ tokens = list(dict.fromkeys(t for term in query_terms for t in _tokenize(term)))
1495
+ if (top_k <= 0 or len(tokens) <= self.scoring_config.file_bm25_min_tokens
1496
+ or not self._body_index_built):
1497
+ return []
1498
+ if self._source_revision() != self._body_revision:
1499
+ # As in body search: report the index as stale, not a full result.
1500
+ self._body_index_built = False
1501
+ self._schedule_body_index()
1502
+ return []
1503
+ conn = self._open_body_db()
1504
+ if conn is None:
1505
+ return []
1506
+ import fnmatch
1507
+
1508
+ try:
1509
+ where = "WHERE terms MATCH ?"
1510
+ parameters: list[Any] = []
1511
+ if file_filter:
1512
+ where += " AND body_file_fts.file_path GLOB ?"
1513
+ parameters.append(file_filter.replace("[!", "[^"))
1514
+ parameters.append(top_k)
1515
+ with self._kw_cache_lock:
1516
+ if len(tokens) > 64:
1517
+ tokens = self._rarest_body_tokens(conn, tokens, 64)
1518
+ if not tokens:
1519
+ return []
1520
+ parameters.insert(0, " OR ".join(f'"{token}"' for token in tokens))
1521
+ rows = conn.execute(
1522
+ "SELECT body_file_fts.file_path, body_files.mtime_ns, body_files.size "
1523
+ "FROM body_file_fts JOIN body_files "
1524
+ "ON body_file_fts.file_path = body_files.file_path "
1525
+ + where + " ORDER BY bm25(body_file_fts), body_file_fts.file_path LIMIT ?",
1526
+ parameters,
1527
+ ).fetchall()
1528
+ except sqlite3.Error:
1529
+ logger.debug("Whole-file query failed; using chunk results", exc_info=True)
1530
+ return []
1531
+ finally:
1532
+ conn.close()
1533
+ files = []
1534
+ for path, mtime_ns, size in rows:
1535
+ if file_filter and not fnmatch.fnmatch(path, file_filter):
1536
+ continue
1537
+ try:
1538
+ stat = os.stat(os.path.join(self.root_dir, path))
1539
+ except OSError:
1540
+ continue
1541
+ # A changed file is skipped here; the body search schedules its refresh.
1542
+ if (stat.st_mtime_ns, stat.st_size) == (mtime_ns, size):
1543
+ files.append(path)
1544
+ return files
1545
+
1546
+ def _file_entry(self, path: str) -> SemanticSearchResult | None:
1547
+ """A result for a file that only whole-file BM25 found: its first lines."""
1548
+ try:
1549
+ with open(os.path.join(self.root_dir, path), encoding="utf-8", errors="replace") as source:
1550
+ lines = [line.rstrip("\n") for _, line in zip(range(30), source, strict=False)]
1551
+ except OSError:
1552
+ return None
1553
+ return SemanticSearchResult(
1554
+ file_path=path, chunk_type="file", name=os.path.basename(path),
1555
+ text=f"{path}:1-{len(lines)}\n" + "\n".join(lines), score=0.0,
1556
+ start_line=1, end_line=max(len(lines), 1),
1557
+ )
1558
+
1559
+ @staticmethod
1560
+ def _rarest_body_tokens(conn: sqlite3.Connection, tokens: list[str], limit: int) -> list[str]:
1561
+ """Keep the words of a long task that the fewest source chunks contain.
1562
+
1563
+ The MATCH stays bounded. Without document counts, a long issue sent the
1564
+ words that came first, and those were often common ones.
1565
+ """
1566
+ try:
1567
+ conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp.body_vocab "
1568
+ "USING fts5vocab(main, body_fts, row)")
1569
+ marks = ",".join("?" * len(tokens))
1570
+ counts = dict(conn.execute(
1571
+ f"SELECT term, doc FROM temp.body_vocab WHERE term IN ({marks})", tokens,
1572
+ ).fetchall())
1573
+ except sqlite3.Error:
1574
+ logger.debug("Source-body term counts unavailable", exc_info=True)
1575
+ return tokens[:limit]
1576
+ # A word that no chunk holds cannot match. Ties keep the query order.
1577
+ return sorted((t for t in tokens if t in counts), key=counts.__getitem__)[:limit]
1578
+
1579
+ def candidate_diagnostics(self) -> dict[str, int | float | bool | str]:
1580
+ """Read-only coverage snapshot for compact search provenance."""
1581
+ ready = self._kw_index_built and (
1582
+ self._body_index_built or not self._body_index_available
1583
+ )
1584
+ snapshot: dict[str, int | float | bool | str] = {
1585
+ "status": "ready" if ready else "warming",
1586
+ "keyword_index_ready": self._kw_index_built,
1587
+ "body_index_available": self._body_index_available,
1588
+ "body_index_ready": self._body_index_built,
1589
+ "body_indexed_files": 0,
1590
+ "keyword_indexed_files": 0,
1591
+ "body_index_coverage": 0.0,
1592
+ "vector_status": self._index_progress.status,
1593
+ "vector_coverage": self._index_progress.coverage,
1594
+ }
1595
+ if not self._body_index_built:
1596
+ return snapshot
1597
+ conn = self._open_body_db()
1598
+ if conn is None:
1599
+ snapshot["body_index_available"] = False
1600
+ return snapshot
1601
+ try:
1602
+ body_count = int(conn.execute("SELECT count(*) FROM body_files").fetchone()[0])
1603
+ keyword_count = int(conn.execute("SELECT count(*) FROM kw_files").fetchone()[0])
1604
+ snapshot.update(
1605
+ body_indexed_files=body_count,
1606
+ keyword_indexed_files=keyword_count,
1607
+ body_index_coverage=round(body_count / keyword_count, 3)
1608
+ if keyword_count else 0.0,
1609
+ )
1610
+ except sqlite3.Error:
1611
+ logger.debug("Failed to read candidate coverage", exc_info=True)
1612
+ finally:
1613
+ conn.close()
1614
+ return snapshot
1615
+
1616
+ def _normalize_scores(
1617
+ self,
1618
+ ranked: list[tuple[str, float]],
1619
+ method: str = "min_max",
1620
+ ) -> list[tuple[str, float]]:
1621
+ """Normalize scores to [0, 1] range for fair fusion.
1622
+
1623
+ Ensures that vector similarity scores and BM25 scores are on
1624
+ comparable scales before Reciprocal Rank Fusion.
1625
+
1626
+ Args:
1627
+ ranked: List of (id, score) tuples.
1628
+ method: Normalization method (currently only "min_max").
1629
+
1630
+ Returns:
1631
+ List of (id, normalized_score) tuples in the same order.
1632
+ """
1633
+ if not ranked or len(ranked) < 2:
1634
+ return ranked
1635
+ scores = [s for _, s in ranked]
1636
+ min_s, max_s = min(scores), max(scores)
1637
+ if max_s == min_s:
1638
+ return [(key, 1.0) for key, _ in ranked]
1639
+ return [(key, (s - min_s) / (max_s - min_s)) for key, s in ranked]
1640
+
1641
+ def _rerank(
1642
+ self,
1643
+ query: str,
1644
+ fused: list[tuple[str, float]],
1645
+ result_map: dict[str, SemanticSearchResult],
1646
+ top_k: int,
1647
+ ) -> list[tuple[str, float]]:
1648
+ """Apply cross-encoder reranking to fused results.
1649
+
1650
+ Args:
1651
+ query: The search query.
1652
+ fused: List of (id, fused_score) from RRF.
1653
+ result_map: Lookup from item ID to SemanticSearchResult.
1654
+ top_k: Number of final results desired.
1655
+
1656
+ Returns:
1657
+ Reranked list of (id, score) tuples.
1658
+ """
1659
+ from attocode_intel._internal.integrations.context.reranker import get_reranker
1660
+
1661
+ reranker = get_reranker()
1662
+
1663
+ # Build candidates: (id, text, fused_score)
1664
+ candidates: list[tuple[str, str, float]] = []
1665
+ for item_id, score in fused:
1666
+ result = result_map.get(item_id)
1667
+ if result:
1668
+ candidates.append((item_id, result.text, score))
1669
+
1670
+ if not candidates:
1671
+ return fused[:top_k]
1672
+
1673
+ reranked = reranker.rerank(query, candidates, top_k=top_k)
1674
+ return [(cid, score) for cid, _text, score in reranked]
1675
+
1676
+ # ------------------------------------------------------------------
1677
+ # Trigram pre-filtering for keyword search
1678
+ # ------------------------------------------------------------------
1679
+
1680
+ def _get_trigram_index(self) -> Any | None:
1681
+ """Get a loaded TrigramIndex instance, or None if unavailable."""
1682
+ if self._trigram_index is not None:
1683
+ return self._trigram_index
1684
+ try:
1685
+ from attocode_intel._internal.integrations.context.trigram_index import TrigramIndex
1686
+
1687
+ index_dir = os.path.join(self.root_dir, ".attocode", "index")
1688
+ if not os.path.isdir(index_dir):
1689
+ return None
1690
+ idx = TrigramIndex(index_dir=index_dir)
1691
+ if idx.load():
1692
+ self._trigram_index = idx
1693
+ return idx
1694
+ except Exception:
1695
+ logger.debug("Failed to load trigram index for kw pre-filter", exc_info=True)
1696
+ return None
1697
+
1698
+ def _trigram_prefilter(self, query_tokens: list[str]) -> set[str] | None:
1699
+ """Use trigram index to find candidate files containing query terms.
1700
+
1701
+ Returns set of candidate file paths, or None to fall back to full scan.
1702
+ Uses UNION semantics: a file matching ANY query term is included.
1703
+ """
1704
+ idx = self._get_trigram_index()
1705
+ if idx is None or not idx.is_ready():
1706
+ return None
1707
+
1708
+ filterable = [t for t in query_tokens if len(t) >= 3]
1709
+ if not filterable:
1710
+ return None
1711
+
1712
+ candidates: set[str] = set()
1713
+ any_definitive = False
1714
+
1715
+ for token in filterable:
1716
+ escaped = re.escape(token)
1717
+ try:
1718
+ result = idx.query(escaped, selectivity_threshold=0.5)
1719
+ except Exception:
1720
+ logger.debug("Trigram query failed for '%s'", token, exc_info=True)
1721
+ return None
1722
+
1723
+ if result is None:
1724
+ continue # too common or no trigrams — skip this token
1725
+ any_definitive = True
1726
+ candidates.update(result)
1727
+
1728
+ if not any_definitive:
1729
+ return None # no token could be filtered, full scan needed
1730
+
1731
+ return candidates
1732
+
1733
+ def _keyword_search(
1734
+ self,
1735
+ query: str,
1736
+ top_k: int,
1737
+ file_filter: str,
1738
+ ) -> list[SemanticSearchResult]:
1739
+ """BM25 content-aware keyword search using AST-extracted data."""
1740
+ if not self._kw_index_built:
1741
+ self._build_keyword_index()
1742
+
1743
+ if not self._kw_docs:
1744
+ return []
1745
+
1746
+ import fnmatch
1747
+
1748
+ query_tokens = _tokenize(query)
1749
+ if not query_tokens:
1750
+ return []
1751
+
1752
+ # Trigram pre-filter: narrow to files containing query terms
1753
+ candidate_files = self._trigram_prefilter(query_tokens)
1754
+ if candidate_files is not None:
1755
+ docs_to_score = [
1756
+ d for d in self._kw_docs if d.file_path in candidate_files
1757
+ ]
1758
+ logger.debug(
1759
+ "Trigram pre-filter: %d/%d docs from %d candidate files",
1760
+ len(docs_to_score), len(self._kw_docs), len(candidate_files),
1761
+ )
1762
+ else:
1763
+ docs_to_score = self._kw_docs
1764
+
1765
+ query_lower = query.lower()
1766
+ cfg = self.scoring_config
1767
+
1768
+ # BM25 parameters — N and avg_dl use FULL corpus for correct IDF
1769
+ k1 = cfg.bm25_k1
1770
+ b = cfg.bm25_b
1771
+ N = len(self._kw_docs) # noqa: N806
1772
+ avg_dl = self._kw_avg_dl or 1.0
1773
+
1774
+ scored: list[tuple[float, _KeywordDoc]] = []
1775
+ for doc in docs_to_score:
1776
+ if file_filter and not fnmatch.fnmatch(doc.file_path, file_filter):
1777
+ continue
1778
+
1779
+ # BM25 score
1780
+ score = 0.0
1781
+ for term in query_tokens:
1782
+ tf = doc.term_freqs.get(term, 0)
1783
+ if tf == 0:
1784
+ continue
1785
+ df = self._kw_df.get(term, 0)
1786
+ if df == 0:
1787
+ continue
1788
+ idf = math.log((N - df + 0.5) / (df + 0.5) + 1.0)
1789
+ dl = doc.doc_len or 1
1790
+ tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avg_dl))
1791
+ score += idf * tf_norm
1792
+
1793
+ if score <= 0:
1794
+ continue
1795
+
1796
+ # Post-hoc boosts
1797
+ _name_lower = doc.name.lower()
1798
+ name_tokens = _tokenize(doc.name)
1799
+
1800
+ # Graduated symbol name match boost
1801
+ _name_boost_applied = False
1802
+ for term in query_tokens:
1803
+ if term == _name_lower:
1804
+ score *= cfg.name_exact_boost
1805
+ _name_boost_applied = True
1806
+ break
1807
+ if term in _name_lower:
1808
+ score *= cfg.name_substring_boost
1809
+ _name_boost_applied = True
1810
+ break
1811
+ if not _name_boost_applied:
1812
+ for term in query_tokens:
1813
+ if term in name_tokens:
1814
+ score *= cfg.name_token_boost
1815
+ break
1816
+
1817
+ # Definition-type boost: classes and functions rank above file-level
1818
+ if doc.chunk_type == "class":
1819
+ score *= cfg.class_boost
1820
+ elif doc.chunk_type == "function":
1821
+ score *= cfg.function_boost
1822
+ elif doc.chunk_type == "method":
1823
+ score *= cfg.method_boost
1824
+
1825
+ # Path relevance boost: source directories rank higher
1826
+ _SRC_DIRS = {"src", "lib", "pkg", "core", "internal", "app", "main"} # noqa: N806
1827
+ _first_dir = doc.file_path.split("/")[0] if "/" in doc.file_path else ""
1828
+ if _first_dir.lower() in _SRC_DIRS:
1829
+ score *= cfg.src_dir_boost
1830
+
1831
+ # Multi-term coverage bonus
1832
+ if len(query_tokens) > 1:
1833
+ _matched_terms = sum(1 for t in query_tokens if doc.term_freqs.get(t, 0) > 0)
1834
+ _coverage = _matched_terms / len(query_tokens)
1835
+ if _coverage >= cfg.multi_term_high_threshold:
1836
+ score *= cfg.multi_term_high_bonus
1837
+ elif _coverage >= cfg.multi_term_med_threshold:
1838
+ score *= cfg.multi_term_med_bonus
1839
+
1840
+ # Non-code file penalty (markdown, text, config formats)
1841
+ _ext = os.path.splitext(doc.file_path)[1].lower()
1842
+ if _ext in _NON_CODE_EXTS:
1843
+ score *= cfg.non_code_penalty
1844
+
1845
+ # Config/doc file penalty (stacks with non-code ext penalty)
1846
+ if doc.is_config:
1847
+ score *= cfg.config_penalty
1848
+
1849
+ # Test file mild penalty
1850
+ if doc.is_test:
1851
+ score *= cfg.test_penalty
1852
+
1853
+ # Exact phrase bonus: multi-word query substring match in text
1854
+ if len(query_tokens) > 1 and query_lower in doc.text.lower():
1855
+ score *= cfg.exact_phrase_bonus
1856
+
1857
+ # File importance boost (PageRank + hub score from dependency graph)
1858
+ if cfg.importance_weight > 0:
1859
+ imp = self._get_importance(doc.file_path)
1860
+ score *= (1.0 + imp * cfg.importance_weight)
1861
+
1862
+ scored.append((score, doc))
1863
+
1864
+ if not scored:
1865
+ return []
1866
+
1867
+ # Equal BM25 scores must not inherit filesystem/discovery insertion
1868
+ # order. Otherwise a cold rebuild can change ranks without code edits.
1869
+ scored.sort(key=lambda x: (-x[0], x[1].file_path, x[1].id))
1870
+
1871
+ # Normalize to 0-1
1872
+ max_score = scored[0][0] if scored else 1.0
1873
+ if max_score <= 0:
1874
+ max_score = 1.0
1875
+
1876
+ # Deduplicate: max N chunks per file
1877
+ file_counts: dict[str, int] = {}
1878
+ results: list[SemanticSearchResult] = []
1879
+ for raw_score, doc in scored:
1880
+ if file_counts.get(doc.file_path, 0) >= cfg.max_chunks_per_file:
1881
+ continue
1882
+ file_counts[doc.file_path] = file_counts.get(doc.file_path, 0) + 1
1883
+ results.append(SemanticSearchResult(
1884
+ file_path=doc.file_path,
1885
+ chunk_type=doc.chunk_type,
1886
+ name=doc.name,
1887
+ text=doc.text,
1888
+ score=round(raw_score / max_score, 4),
1889
+ ))
1890
+ if len(results) >= top_k:
1891
+ break
1892
+
1893
+ return results
1894
+
1895
+ # ------------------------------------------------------------------
1896
+ # BM25 keyword index disk cache
1897
+ # ------------------------------------------------------------------
1898
+
1899
+ def _open_kw_cache_db(self) -> sqlite3.Connection | None:
1900
+ """Open the keyword index cache database. Returns None on failure."""
1901
+ try:
1902
+ cache_dir = os.path.dirname(self._kw_cache_db_path)
1903
+ root_real = os.path.realpath(self.root_dir)
1904
+ if os.path.commonpath((root_real, os.path.realpath(os.path.dirname(cache_dir)))) != root_real:
1905
+ return None
1906
+ os.makedirs(cache_dir, mode=0o700, exist_ok=True)
1907
+ if os.path.commonpath((root_real, os.path.realpath(cache_dir))) != root_real:
1908
+ return None
1909
+ os.chmod(cache_dir, 0o700)
1910
+ flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
1911
+ fd = os.open(self._kw_cache_db_path, flags, 0o600)
1912
+ try:
1913
+ os.fchmod(fd, 0o600)
1914
+ finally:
1915
+ os.close(fd)
1916
+ conn = sqlite3.connect(self._kw_cache_db_path, check_same_thread=False)
1917
+ conn.execute("PRAGMA journal_mode=WAL")
1918
+ conn.execute("PRAGMA synchronous=NORMAL")
1919
+ conn.executescript("""
1920
+ CREATE TABLE IF NOT EXISTS metadata (
1921
+ key TEXT PRIMARY KEY, value TEXT NOT NULL
1922
+ );
1923
+ CREATE TABLE IF NOT EXISTS kw_files (
1924
+ file_path TEXT PRIMARY KEY, mtime REAL NOT NULL
1925
+ );
1926
+ CREATE TABLE IF NOT EXISTS kw_docs (
1927
+ id TEXT PRIMARY KEY, file_path TEXT NOT NULL,
1928
+ chunk_type TEXT NOT NULL, name TEXT NOT NULL,
1929
+ text TEXT NOT NULL,
1930
+ is_config INTEGER NOT NULL DEFAULT 0,
1931
+ is_test INTEGER NOT NULL DEFAULT 0,
1932
+ term_freqs TEXT NOT NULL, doc_len INTEGER NOT NULL DEFAULT 0
1933
+ );
1934
+ CREATE INDEX IF NOT EXISTS ix_kw_docs_file ON kw_docs(file_path);
1935
+ """)
1936
+ row = conn.execute(
1937
+ "SELECT value FROM metadata WHERE key='schema_version'",
1938
+ ).fetchone()
1939
+ if row is None:
1940
+ conn.execute(
1941
+ "INSERT INTO metadata (key, value) VALUES ('schema_version', '1')",
1942
+ )
1943
+ conn.commit()
1944
+ elif row[0] != "1":
1945
+ conn.executescript(
1946
+ "DROP TABLE IF EXISTS kw_files; DROP TABLE IF EXISTS kw_docs;"
1947
+ )
1948
+ conn.executescript("""
1949
+ CREATE TABLE kw_files (
1950
+ file_path TEXT PRIMARY KEY, mtime REAL NOT NULL
1951
+ );
1952
+ CREATE TABLE kw_docs (
1953
+ id TEXT PRIMARY KEY, file_path TEXT NOT NULL,
1954
+ chunk_type TEXT NOT NULL, name TEXT NOT NULL,
1955
+ text TEXT NOT NULL,
1956
+ is_config INTEGER NOT NULL DEFAULT 0,
1957
+ is_test INTEGER NOT NULL DEFAULT 0,
1958
+ term_freqs TEXT NOT NULL, doc_len INTEGER NOT NULL DEFAULT 0
1959
+ );
1960
+ CREATE INDEX IF NOT EXISTS ix_kw_docs_file ON kw_docs(file_path);
1961
+ """)
1962
+ conn.execute(
1963
+ "INSERT OR REPLACE INTO metadata (key, value) "
1964
+ "VALUES ('schema_version', '1')",
1965
+ )
1966
+ conn.commit()
1967
+ return conn
1968
+ except Exception:
1969
+ logger.debug("Failed to open kw cache db", exc_info=True)
1970
+ return None
1971
+
1972
+ def _save_kw_cache(
1973
+ self,
1974
+ docs: list[_KeywordDoc],
1975
+ file_mtimes: dict[str, float],
1976
+ ) -> None:
1977
+ """Persist keyword index docs to disk cache."""
1978
+ conn = self._open_kw_cache_db()
1979
+ if conn is None:
1980
+ return
1981
+ try:
1982
+ with self._kw_cache_lock:
1983
+ conn.execute("DELETE FROM kw_files")
1984
+ conn.execute("DELETE FROM kw_docs")
1985
+ for fpath, mtime in file_mtimes.items():
1986
+ conn.execute(
1987
+ "INSERT INTO kw_files (file_path, mtime) VALUES (?, ?)",
1988
+ (fpath, mtime),
1989
+ )
1990
+ for doc in docs:
1991
+ conn.execute(
1992
+ "INSERT OR REPLACE INTO kw_docs "
1993
+ "(id, file_path, chunk_type, name, text, is_config, "
1994
+ "is_test, term_freqs, doc_len) "
1995
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1996
+ (
1997
+ doc.id, doc.file_path, doc.chunk_type, doc.name,
1998
+ doc.text, int(doc.is_config), int(doc.is_test),
1999
+ json.dumps(doc.term_freqs), doc.doc_len,
2000
+ ),
2001
+ )
2002
+ conn.commit()
2003
+ except Exception:
2004
+ logger.debug("Failed to save kw cache", exc_info=True)
2005
+ finally:
2006
+ conn.close()
2007
+
2008
+ def _load_kw_cache(
2009
+ self,
2010
+ current_files: dict[str, tuple[str, float]],
2011
+ ) -> tuple[list[_KeywordDoc], set[str]] | None:
2012
+ """Load cached keyword docs, identify files that need re-parsing.
2013
+
2014
+ Args:
2015
+ current_files: dict of rel_path -> (abs_path, mtime)
2016
+
2017
+ Returns:
2018
+ (cached_docs, files_to_parse) or None if cache unavailable.
2019
+ """
2020
+ conn = self._open_kw_cache_db()
2021
+ if conn is None:
2022
+ return None
2023
+ try:
2024
+ with self._kw_cache_lock:
2025
+ rows = conn.execute(
2026
+ "SELECT file_path, mtime FROM kw_files",
2027
+ ).fetchall()
2028
+ if not rows:
2029
+ conn.close()
2030
+ return None
2031
+
2032
+ cached_mtimes = {r[0]: r[1] for r in rows}
2033
+
2034
+ # Identify stale (modified/new) and deleted files
2035
+ stale: set[str] = set()
2036
+ for fpath, (_, current_mtime) in current_files.items():
2037
+ cached_mtime = cached_mtimes.get(fpath)
2038
+ if cached_mtime is None or current_mtime > cached_mtime:
2039
+ stale.add(fpath)
2040
+ deleted = set(cached_mtimes.keys()) - set(current_files.keys())
2041
+ exclude = stale | deleted
2042
+
2043
+ # Load docs for unchanged files
2044
+ doc_rows = conn.execute(
2045
+ "SELECT id, file_path, chunk_type, name, text, "
2046
+ "is_config, is_test, term_freqs, doc_len FROM kw_docs",
2047
+ ).fetchall()
2048
+
2049
+ docs: list[_KeywordDoc] = []
2050
+ for row in doc_rows:
2051
+ if row[1] in exclude:
2052
+ continue
2053
+ docs.append(_KeywordDoc(
2054
+ id=row[0], file_path=row[1], chunk_type=row[2],
2055
+ name=row[3], text=row[4], is_config=bool(row[5]),
2056
+ is_test=bool(row[6]),
2057
+ term_freqs=json.loads(row[7]), doc_len=row[8],
2058
+ ))
2059
+
2060
+ conn.close()
2061
+ return (docs, stale)
2062
+ except Exception:
2063
+ logger.debug("Failed to load kw cache", exc_info=True)
2064
+ try:
2065
+ conn.close()
2066
+ except Exception:
2067
+ pass
2068
+ return None
2069
+
2070
+ # ------------------------------------------------------------------
2071
+ # BM25 keyword index builder (incremental with cache)
2072
+ # ------------------------------------------------------------------
2073
+
2074
+ def _build_keyword_index(self, context_manager: Any = None) -> None:
2075
+ """Build BM25 inverted index from AST-extracted data.
2076
+
2077
+ Uses disk cache to avoid re-parsing unchanged files.
2078
+ """
2079
+ from attocode_intel._internal.integrations.context.codebase_ast import parse_file
2080
+ from attocode_intel._internal.integrations.context.codebase_context import (
2081
+ CodebaseContextManager,
2082
+ )
2083
+
2084
+ ctx = context_manager or CodebaseContextManager(root_dir=self.root_dir)
2085
+ ctx._ensure_fresh()
2086
+ if not ctx._files:
2087
+ ctx.discover_files()
2088
+
2089
+ _CONFIG_EXTS = frozenset({".toml", ".json", ".yaml", ".yml", ".cfg", ".ini", ".md", ".rst", ".txt"}) # noqa: N806
2090
+ _CONFIG_NAMES = frozenset({ # noqa: N806
2091
+ "pyproject.toml", "package.json", "tsconfig.json", "setup.cfg",
2092
+ "setup.py", "readme.md", "readme.rst", "changelog.md", "license",
2093
+ })
2094
+
2095
+ # Discover current files with mtimes
2096
+ current_files: dict[str, tuple[str, float]] = {}
2097
+ file_meta: dict[str, tuple[bool, bool]] = {} # rel -> (is_config, is_test)
2098
+ for f in ctx._files:
2099
+ try:
2100
+ mtime = os.path.getmtime(f.path)
2101
+ except OSError:
2102
+ continue
2103
+ current_files[f.relative_path] = (f.path, mtime)
2104
+ basename = os.path.basename(f.relative_path).lower()
2105
+ ext = os.path.splitext(basename)[1]
2106
+ is_config = ext in _CONFIG_EXTS or basename in _CONFIG_NAMES
2107
+ file_meta[f.relative_path] = (is_config, f.is_test)
2108
+
2109
+ # Try incremental update from cache
2110
+ cache_result = self._load_kw_cache(current_files)
2111
+
2112
+ if cache_result is not None:
2113
+ docs, files_to_parse = cache_result
2114
+ logger.debug(
2115
+ "kw_index incremental: %d cached docs, %d files to parse",
2116
+ len(docs), len(files_to_parse),
2117
+ )
2118
+ else:
2119
+ docs = []
2120
+ files_to_parse = set(current_files.keys())
2121
+ logger.debug("kw_index full rebuild: %d files", len(files_to_parse))
2122
+
2123
+ # Parse only files that need updating
2124
+ for rel in files_to_parse:
2125
+ entry = current_files.get(rel)
2126
+ if entry is None:
2127
+ continue
2128
+ abs_path, _ = entry
2129
+ is_config, is_test = file_meta.get(rel, (False, False))
2130
+
2131
+ try:
2132
+ ast = parse_file(abs_path)
2133
+ except Exception:
2134
+ continue
2135
+
2136
+ # File-level doc
2137
+ file_text_parts = list(re.split(r"[/\\]", rel))
2138
+ file_text_parts.extend(imp.module for imp in ast.imports[:30])
2139
+ file_text_parts.extend(ast.get_symbols()[:30])
2140
+ file_text = " ".join(file_text_parts)
2141
+ file_tokens = _tokenize(file_text)
2142
+
2143
+ file_tf: dict[str, int] = {}
2144
+ for t in file_tokens:
2145
+ file_tf[t] = file_tf.get(t, 0) + 1
2146
+
2147
+ docs.append(_KeywordDoc(
2148
+ id=f"file:{rel}",
2149
+ file_path=rel,
2150
+ chunk_type="file",
2151
+ name=os.path.basename(rel),
2152
+ text=file_text[:200],
2153
+ is_config=is_config,
2154
+ is_test=is_test,
2155
+ term_freqs=file_tf,
2156
+ doc_len=len(file_tokens),
2157
+ ))
2158
+
2159
+ # Function-level docs
2160
+ for func in ast.functions:
2161
+ parts: list[str] = []
2162
+ parts.extend([func.name] * 3)
2163
+ # Add NL summary of the function name for better query matching
2164
+ nl_summary = _summarize_code_to_nl(func.name, "function", "")
2165
+ parts.append(nl_summary)
2166
+ if func.docstring:
2167
+ parts.append(func.docstring[:300])
2168
+ parts.extend(p.name for p in func.parameters[:10])
2169
+ parts.extend(p.type_annotation for p in func.parameters[:10] if p.type_annotation)
2170
+ if func.return_type:
2171
+ parts.append(func.return_type)
2172
+ parts.extend(func.decorators[:5])
2173
+ text = " ".join(parts)
2174
+ tokens = _tokenize(text)
2175
+
2176
+ tf: dict[str, int] = {}
2177
+ for t in tokens:
2178
+ tf[t] = tf.get(t, 0) + 1
2179
+
2180
+ preview_parts = [f"function {func.name}"]
2181
+ if func.docstring:
2182
+ preview_parts.append(func.docstring[:100])
2183
+ params_str = ", ".join(p.name for p in func.parameters[:6])
2184
+ if params_str:
2185
+ preview_parts.append(f"({params_str})")
2186
+
2187
+ docs.append(_KeywordDoc(
2188
+ id=f"func:{rel}:{func.name}",
2189
+ file_path=rel,
2190
+ chunk_type="function",
2191
+ name=func.name,
2192
+ text=" | ".join(preview_parts),
2193
+ is_config=False,
2194
+ is_test=is_test,
2195
+ term_freqs=tf,
2196
+ doc_len=len(tokens),
2197
+ ))
2198
+
2199
+ # Class + method-level docs
2200
+ for cls in ast.classes:
2201
+ cls_parts: list[str] = [cls.name] * 3
2202
+ # Add NL summary of the class name
2203
+ cls_nl = _summarize_code_to_nl(cls.name, "class", "")
2204
+ cls_parts.append(cls_nl)
2205
+ if cls.bases:
2206
+ cls_parts.extend(cls.bases)
2207
+ if cls.docstring:
2208
+ cls_parts.append(cls.docstring[:300])
2209
+ cls_parts.extend(m.name for m in cls.methods[:15])
2210
+ cls_text = " ".join(cls_parts)
2211
+ cls_tokens = _tokenize(cls_text)
2212
+
2213
+ cls_tf: dict[str, int] = {}
2214
+ for t in cls_tokens:
2215
+ cls_tf[t] = cls_tf.get(t, 0) + 1
2216
+
2217
+ cls_preview = f"class {cls.name}"
2218
+ if cls.bases:
2219
+ cls_preview += f"({', '.join(cls.bases[:3])})"
2220
+ if cls.docstring:
2221
+ cls_preview += f" | {cls.docstring[:100]}"
2222
+
2223
+ docs.append(_KeywordDoc(
2224
+ id=f"cls:{rel}:{cls.name}",
2225
+ file_path=rel,
2226
+ chunk_type="class",
2227
+ name=cls.name,
2228
+ text=cls_preview,
2229
+ is_config=False,
2230
+ is_test=is_test,
2231
+ term_freqs=cls_tf,
2232
+ doc_len=len(cls_tokens),
2233
+ ))
2234
+
2235
+ for method in cls.methods:
2236
+ m_parts: list[str] = [method.name] * 3
2237
+ m_parts.append(cls.name)
2238
+ if method.docstring:
2239
+ m_parts.append(method.docstring[:300])
2240
+ m_parts.extend(p.name for p in method.parameters[:10])
2241
+ m_parts.extend(p.type_annotation for p in method.parameters[:10] if p.type_annotation)
2242
+ if method.return_type:
2243
+ m_parts.append(method.return_type)
2244
+ m_text = " ".join(m_parts)
2245
+ m_tokens = _tokenize(m_text)
2246
+
2247
+ m_tf: dict[str, int] = {}
2248
+ for t in m_tokens:
2249
+ m_tf[t] = m_tf.get(t, 0) + 1
2250
+
2251
+ m_preview = f"method {cls.name}.{method.name}"
2252
+ if method.docstring:
2253
+ m_preview += f" | {method.docstring[:100]}"
2254
+
2255
+ docs.append(_KeywordDoc(
2256
+ id=f"method:{rel}:{cls.name}.{method.name}",
2257
+ file_path=rel,
2258
+ chunk_type="method",
2259
+ name=f"{cls.name}.{method.name}",
2260
+ text=m_preview,
2261
+ is_config=False,
2262
+ is_test=is_test,
2263
+ term_freqs=m_tf,
2264
+ doc_len=len(m_tokens),
2265
+ ))
2266
+
2267
+ # Build document frequencies (always recomputed from full doc list)
2268
+ df: dict[str, int] = {}
2269
+ for doc in docs:
2270
+ for term in doc.term_freqs:
2271
+ df[term] = df.get(term, 0) + 1
2272
+
2273
+ total_len = sum(doc.doc_len for doc in docs)
2274
+ avg_dl = total_len / len(docs) if docs else 1.0
2275
+
2276
+ self._kw_docs = docs
2277
+ self._kw_df = df
2278
+ self._kw_avg_dl = avg_dl
2279
+ self._kw_index_built = True
2280
+
2281
+ # Persist to cache
2282
+ file_mtimes = {rel: mt for rel, (_, mt) in current_files.items()}
2283
+ self._save_kw_cache(docs, file_mtimes)
2284
+
2285
+ logger.debug(
2286
+ "BM25 keyword index built: %d docs, %d terms (%d files parsed)",
2287
+ len(docs), len(df), len(files_to_parse),
2288
+ )
2289
+
2290
+ def _slice_body(
2291
+ self, file_lines: list[str], start_line: int, end_line: int, max_tokens: int,
2292
+ ) -> str:
2293
+ """Return the symbol's body text (lines after the signature line),
2294
+ trimmed to an approximate token budget.
2295
+
2296
+ start_line/end_line are 1-based and inclusive (codebase_ast convention).
2297
+ The first line is the signature; the body is start_line+1..end_line.
2298
+ Returns "" when there is no body (one-line def) or budget <= 0.
2299
+ """
2300
+ from attocode_intel._internal.integrations.utilities.token_estimate import estimate_tokens
2301
+
2302
+ if max_tokens <= 0:
2303
+ return ""
2304
+ n = len(file_lines)
2305
+ body_start = start_line # 0-based index of first body line == 1-based start_line
2306
+ body_end = min(end_line, n) # clamp past EOF; slice upper bound
2307
+ if body_start >= body_end:
2308
+ return ""
2309
+ body = file_lines[body_start:body_end]
2310
+ text = "\n".join(line.rstrip() for line in body).strip()
2311
+ if not text:
2312
+ return ""
2313
+ if estimate_tokens(text) <= max_tokens:
2314
+ return text
2315
+ # Trim from the head, keeping whole lines, until under budget.
2316
+ kept: list[str] = []
2317
+ for line in body:
2318
+ kept.append(line.rstrip())
2319
+ if estimate_tokens("\n".join(kept)) > max_tokens:
2320
+ kept.pop()
2321
+ break
2322
+ return "\n".join(kept).strip()
2323
+
2324
+ def _chunk_single_file(
2325
+ self, rel_path: str, abs_path: str,
2326
+ ) -> list[tuple]:
2327
+ """Extract chunks from a single file.
2328
+
2329
+ Returns a list of chunk tuples. File- and class-level chunks are
2330
+ 4-tuples ``(id, file_path, chunk_type, text)``. Function- and
2331
+ method-level chunks are 5-tuples
2332
+ ``(id, file_path, chunk_type, display_text, embed_text)`` where
2333
+ ``display_text`` is the concise signature (stored for display) and
2334
+ ``embed_text`` additionally includes a token-budgeted body so the
2335
+ embedding model sees the implementation.
2336
+ """
2337
+ from attocode_intel._internal.integrations.context.codebase_ast import parse_file
2338
+
2339
+ try:
2340
+ ast = parse_file(abs_path)
2341
+ except Exception:
2342
+ return []
2343
+
2344
+ import os
2345
+ try:
2346
+ _body_budget = int(os.environ.get("ATTOCODE_BODY_TOKEN_BUDGET", "400"))
2347
+ except ValueError:
2348
+ _body_budget = 400
2349
+ try:
2350
+ with open(abs_path, encoding="utf-8", errors="ignore") as _fh:
2351
+ _file_lines = _fh.read().splitlines()
2352
+ except OSError:
2353
+ _file_lines = []
2354
+
2355
+ chunks: list[tuple] = []
2356
+
2357
+ # File-level summary
2358
+ imports = [imp.module for imp in ast.imports[:20]]
2359
+ symbols = ast.get_symbols()[:20]
2360
+ summary_parts = []
2361
+ file_doc = getattr(ast, "docstring", None)
2362
+ if file_doc:
2363
+ summary_parts.append(file_doc[:200])
2364
+ if imports:
2365
+ summary_parts.append(f"imports: {', '.join(imports)}")
2366
+ if symbols:
2367
+ summary_parts.append(f"defines: {', '.join(symbols)}")
2368
+ if summary_parts:
2369
+ chunks.append((
2370
+ f"file:{rel_path}",
2371
+ rel_path,
2372
+ "file",
2373
+ " | ".join(summary_parts),
2374
+ ))
2375
+
2376
+ # Function-level chunks
2377
+ for func in ast.functions:
2378
+ text_parts = [f"function {func.name}"]
2379
+ if func.docstring:
2380
+ text_parts.append(func.docstring[:150])
2381
+ params = ", ".join(p.name for p in func.parameters[:8])
2382
+ if params:
2383
+ text_parts.append(f"params: {params}")
2384
+ if func.return_type:
2385
+ text_parts.append(f"returns: {func.return_type}")
2386
+ sig_text = " | ".join(text_parts)
2387
+ body = (
2388
+ self._slice_body(_file_lines, func.start_line, func.end_line, _body_budget)
2389
+ if _file_lines else ""
2390
+ )
2391
+ embed_text = f"{sig_text}\n{body}" if body else sig_text
2392
+ chunks.append((
2393
+ f"func:{rel_path}:{func.name}",
2394
+ rel_path,
2395
+ "function",
2396
+ sig_text,
2397
+ embed_text,
2398
+ ))
2399
+
2400
+ # Class-level chunks (including method-level)
2401
+ for cls in ast.classes:
2402
+ text_parts = [f"class {cls.name}"]
2403
+ if cls.bases:
2404
+ text_parts.append(f"extends: {', '.join(cls.bases)}")
2405
+ if cls.docstring:
2406
+ text_parts.append(cls.docstring[:150])
2407
+ methods = [m.name for m in cls.methods[:8]]
2408
+ if methods:
2409
+ text_parts.append(f"methods: {', '.join(methods)}")
2410
+ chunks.append((
2411
+ f"cls:{rel_path}:{cls.name}",
2412
+ rel_path,
2413
+ "class",
2414
+ " | ".join(text_parts),
2415
+ ))
2416
+
2417
+ # Method-level chunks for richer search
2418
+ for method in cls.methods:
2419
+ m_parts = [f"method {cls.name}.{method.name}"]
2420
+ if method.docstring:
2421
+ m_parts.append(method.docstring[:150])
2422
+ m_params = ", ".join(p.name for p in method.parameters[:8])
2423
+ if m_params:
2424
+ m_parts.append(f"params: {m_params}")
2425
+ if method.return_type:
2426
+ m_parts.append(f"returns: {method.return_type}")
2427
+ m_sig = " | ".join(m_parts)
2428
+ m_body = (
2429
+ self._slice_body(
2430
+ _file_lines, method.start_line, method.end_line, _body_budget,
2431
+ )
2432
+ if _file_lines else ""
2433
+ )
2434
+ m_embed = f"{m_sig}\n{m_body}" if m_body else m_sig
2435
+ chunks.append((
2436
+ f"method:{rel_path}:{cls.name}.{method.name}",
2437
+ rel_path,
2438
+ "method",
2439
+ m_sig,
2440
+ m_embed,
2441
+ ))
2442
+
2443
+ return chunks
2444
+
2445
+ def _get_embedding_texts(
2446
+ self, chunks: list[tuple], language: str = "python",
2447
+ ) -> list[str]:
2448
+ """Return texts to feed to the embedding model.
2449
+
2450
+ When ``nl_mode`` is ``"heuristic"``, each chunk's text is transformed
2451
+ into a natural language summary via :class:`CodeSummarizer` before
2452
+ embedding. The original chunk text (index 3) is still stored in
2453
+ ``VectorEntry.text`` for display.
2454
+
2455
+ Args:
2456
+ chunks: List of ``(id, file_path, chunk_type, text)`` tuples as
2457
+ returned by :meth:`_chunk_single_file`.
2458
+ language: Programming language hint for the summarizer.
2459
+
2460
+ Returns:
2461
+ A list of strings, one per chunk, suitable for embedding.
2462
+ """
2463
+ if self._summarizer is None:
2464
+ # No NL mode — embed body-augmented text (index 4) when present,
2465
+ # else the display text (index 3).
2466
+ return [c[4] if len(c) > 4 else c[3] for c in chunks]
2467
+
2468
+ result: list[str] = []
2469
+ for chunk in chunks:
2470
+ cid = chunk[0]
2471
+ _fpath = chunk[1]
2472
+ ctype = chunk[2]
2473
+ text = chunk[3]
2474
+ # Derive the symbol name from the chunk ID
2475
+ name = cid.split(":")[-1] if ":" in cid else _fpath
2476
+ summary = self._summarizer.summarize(text, ctype, name, language)
2477
+ result.append(summary)
2478
+ return result
2479
+
2480
+ def reindex_file(self, file_path: str) -> int:
2481
+ """Re-index a single file: delete old vectors and re-embed.
2482
+
2483
+ Args:
2484
+ file_path: Absolute or relative path to the file.
2485
+
2486
+ Returns:
2487
+ Number of chunks indexed (0 if skipped).
2488
+ """
2489
+ self._ensure_provider()
2490
+ if self._keyword_fallback or not self._store:
2491
+ return 0
2492
+
2493
+ from attocode_intel._internal.integrations.context.vector_store import VectorEntry
2494
+
2495
+ try:
2496
+ rel = os.path.relpath(file_path, self.root_dir)
2497
+ except ValueError:
2498
+ rel = file_path
2499
+
2500
+ abs_path = os.path.join(self.root_dir, rel) if not os.path.isabs(file_path) else file_path
2501
+
2502
+ if not os.path.isfile(abs_path):
2503
+ # File was deleted — just remove vectors
2504
+ self._store.delete_by_file(rel)
2505
+ self._store.delete_file_metadata(rel)
2506
+ return 0
2507
+
2508
+ # Re-chunk
2509
+ chunks = self._chunk_single_file(rel, abs_path)
2510
+ if not chunks:
2511
+ self._store.delete_by_file(rel)
2512
+ self._store.delete_file_metadata(rel)
2513
+ return 0
2514
+
2515
+ # Embed FIRST (before deleting old vectors) — if embed fails,
2516
+ # we keep existing vectors rather than losing them
2517
+ texts = self._get_embedding_texts(chunks)
2518
+ try:
2519
+ vectors = self._provider.embed(texts)
2520
+ except Exception:
2521
+ logger.warning(
2522
+ "reindex_file embed failed for %s, keeping old vectors",
2523
+ rel, exc_info=True,
2524
+ )
2525
+ return 0
2526
+
2527
+ # Build entries
2528
+ entries = []
2529
+ for chunk, vec in zip(chunks, vectors, strict=False):
2530
+ if not vec:
2531
+ continue
2532
+ cid = chunk[0]
2533
+ fpath = chunk[1]
2534
+ ctype = chunk[2]
2535
+ text = chunk[3]
2536
+ entries.append(VectorEntry(
2537
+ id=cid,
2538
+ file_path=fpath,
2539
+ chunk_type=ctype,
2540
+ name=cid.split(":")[-1] if ":" in cid else fpath,
2541
+ text=text,
2542
+ vector=vec,
2543
+ ))
2544
+
2545
+ # Now safe to delete old + upsert new
2546
+ self._store.delete_by_file(rel)
2547
+ if entries:
2548
+ self._store.upsert_batch(entries)
2549
+
2550
+ # Update metadata
2551
+ try:
2552
+ mtime = os.path.getmtime(abs_path)
2553
+ except OSError:
2554
+ mtime = 0.0
2555
+ self._store.set_file_metadata(rel, mtime, len(entries))
2556
+
2557
+ logger.debug("reindex_file: %s -> %d chunks", rel, len(entries))
2558
+ self._indexed = True
2559
+ return len(entries)
2560
+
2561
+ def invalidate_file(self, file_path: str) -> None:
2562
+ """Remove embeddings for a changed file."""
2563
+ self._kw_index_built = False # Force rebuild on next keyword search
2564
+ self._body_index_built = False # Recheck this file in the source-body index
2565
+ self._body_generation += 1
2566
+ self._body_failed_at = 0.0
2567
+ if self._trigram_index is not None:
2568
+ self._trigram_index.close()
2569
+ self._trigram_index = None # Reload on next use
2570
+ try:
2571
+ rel = os.path.relpath(file_path, self.root_dir)
2572
+ except ValueError:
2573
+ rel = file_path
2574
+ # Mark stale in keyword cache
2575
+ try:
2576
+ conn = self._open_kw_cache_db()
2577
+ if conn:
2578
+ with self._kw_cache_lock:
2579
+ conn.execute(
2580
+ "UPDATE kw_files SET mtime = 0 WHERE file_path = ?",
2581
+ (rel,),
2582
+ )
2583
+ if conn.execute(
2584
+ "SELECT 1 FROM sqlite_master WHERE name = 'body_files'",
2585
+ ).fetchone():
2586
+ conn.execute(
2587
+ "UPDATE body_files SET mtime_ns = -1 WHERE file_path = ?",
2588
+ (rel,),
2589
+ )
2590
+ conn.commit()
2591
+ conn.close()
2592
+ except Exception:
2593
+ pass
2594
+ if self._store:
2595
+ self._store.delete_by_file(rel)
2596
+
2597
+ def reindex_stale_files(self, context_manager: Any = None) -> int:
2598
+ """Re-index only files whose mtime has changed since last indexing.
2599
+
2600
+ Designed to be called on agent startup for incremental freshness.
2601
+
2602
+ Returns:
2603
+ Number of chunks re-indexed.
2604
+ """
2605
+ self._ensure_provider()
2606
+ if self._keyword_fallback or not self._store:
2607
+ return 0
2608
+
2609
+ from attocode_intel._internal.integrations.context.codebase_context import (
2610
+ CodebaseContextManager,
2611
+ )
2612
+
2613
+ ctx = context_manager or CodebaseContextManager(root_dir=self.root_dir)
2614
+ ctx._ensure_fresh()
2615
+ if not ctx._files:
2616
+ ctx.discover_files()
2617
+
2618
+ # Build mtime map for all discovered files
2619
+ file_mtimes: dict[str, float] = {}
2620
+ file_paths: dict[str, str] = {} # rel -> abs
2621
+ for f in ctx._files:
2622
+ try:
2623
+ file_mtimes[f.relative_path] = os.path.getmtime(f.path)
2624
+ file_paths[f.relative_path] = f.path
2625
+ except OSError:
2626
+ continue
2627
+
2628
+ stale = self._store.get_stale_files(file_mtimes)
2629
+
2630
+ # Clean up vectors for files that have been deleted from disk
2631
+ all_indexed = self._store.get_all_indexed_files()
2632
+ current_files = set(file_mtimes.keys())
2633
+ deleted_count = 0
2634
+ for indexed_path in all_indexed:
2635
+ if indexed_path not in current_files:
2636
+ self._store.delete_by_file(indexed_path)
2637
+ self._store.delete_file_metadata(indexed_path)
2638
+ deleted_count += 1
2639
+ logger.debug("Cleaned up vectors for deleted file: %s", indexed_path)
2640
+
2641
+ if not stale and not deleted_count:
2642
+ logger.debug("reindex_stale_files: all files up to date")
2643
+ return 0
2644
+
2645
+ if deleted_count:
2646
+ logger.info("reindex_stale_files: cleaned up %d deleted files", deleted_count)
2647
+
2648
+ logger.info("reindex_stale_files: %d stale files to re-index", len(stale))
2649
+ total_chunks = 0
2650
+ for rel_path in stale:
2651
+ abs_path = file_paths.get(rel_path)
2652
+ if abs_path:
2653
+ total_chunks += self.reindex_file(abs_path)
2654
+
2655
+ self._indexed = True
2656
+ return total_chunks
2657
+
2658
+ # ------------------------------------------------------------------
2659
+ # Background indexing (P3)
2660
+ # ------------------------------------------------------------------
2661
+
2662
+ def is_index_ready(self) -> bool:
2663
+ """Check if the vector index has sufficient coverage (>=80%)."""
2664
+ self._ensure_provider()
2665
+ if self._keyword_fallback or not self._store:
2666
+ return False
2667
+ progress = self.get_index_progress()
2668
+ return progress.coverage >= 0.8
2669
+
2670
+ def get_index_progress(self) -> IndexProgress:
2671
+ """Get current indexing progress (thread-safe snapshot)."""
2672
+ with self._reindex_lock:
2673
+ if self._bg_indexer is not None:
2674
+ # Return a snapshot to avoid races
2675
+ p = self._index_progress
2676
+ return IndexProgress(
2677
+ total_files=p.total_files,
2678
+ indexed_files=p.indexed_files,
2679
+ failed_files=p.failed_files,
2680
+ status=p.status,
2681
+ coverage=p.coverage,
2682
+ started_at=p.started_at,
2683
+ elapsed_seconds=p.elapsed_seconds,
2684
+ )
2685
+ # If no background indexer, check store directly
2686
+ if self._store:
2687
+ # Use cached total if available to avoid expensive filesystem scan
2688
+ cached_total = self._index_progress.total_files
2689
+ if cached_total > 0:
2690
+ total = cached_total
2691
+ else:
2692
+ from attocode_intel._internal.integrations.context.codebase_context import (
2693
+ CodebaseContextManager,
2694
+ )
2695
+ ctx = CodebaseContextManager(root_dir=self.root_dir)
2696
+ ctx._ensure_fresh()
2697
+ if not ctx._files:
2698
+ ctx.discover_files()
2699
+ _supported = self._supported_languages()
2700
+ total = len([f for f in ctx._files if f.language in _supported])
2701
+ self._index_progress.total_files = total
2702
+ indexed = len(self._store.get_all_indexed_files()) if total > 0 else 0
2703
+ coverage = indexed / total if total > 0 else 0.0
2704
+ return IndexProgress(
2705
+ total_files=total,
2706
+ indexed_files=indexed,
2707
+ coverage=round(coverage, 3),
2708
+ status="completed" if coverage >= 0.99 else "idle",
2709
+ )
2710
+ return self._index_progress
2711
+
2712
+ def start_background_indexing(self) -> IndexProgress:
2713
+ """Start background progressive embedding indexing."""
2714
+ import time
2715
+
2716
+ self._ensure_provider()
2717
+ if self._keyword_fallback or not self._store:
2718
+ self._index_progress.status = "error"
2719
+ self._index_progress.degraded_reason = "embedding_provider_unavailable"
2720
+ self._index_progress.last_error = (
2721
+ "Background indexing unavailable because no embedding provider is configured."
2722
+ )
2723
+ return self._index_progress
2724
+
2725
+ with self._reindex_lock:
2726
+ if self._bg_indexer is not None:
2727
+ return self._index_progress
2728
+
2729
+ self._index_progress = IndexProgress(status="running", started_at=time.time())
2730
+ stop_event = threading.Event()
2731
+
2732
+ def _worker() -> None:
2733
+ try:
2734
+ self._run_background_indexing(stop_event)
2735
+ except Exception as exc:
2736
+ logger.error("Background indexer crashed", exc_info=True)
2737
+ with self._reindex_lock:
2738
+ self._index_progress.status = "error"
2739
+ self._index_progress.degraded_reason = "background_indexer_crashed"
2740
+ self._index_progress.last_error = (
2741
+ f"Background indexer crashed: {type(exc).__name__}: {exc}"
2742
+ )
2743
+ finally:
2744
+ with self._reindex_lock:
2745
+ self._bg_indexer = None
2746
+ self._bg_thread = None
2747
+
2748
+ worker = threading.Thread(target=_worker, daemon=True, name="bg-embedding-indexer")
2749
+ self._bg_indexer = stop_event
2750
+ self._bg_thread = worker
2751
+ worker.start()
2752
+ return self._index_progress
2753
+
2754
+ def stop_background_indexing(self) -> None:
2755
+ """Stop background indexing gracefully."""
2756
+ if self._bg_indexer is not None:
2757
+ self._bg_indexer.set()
2758
+
2759
+ def _run_background_indexing(self, stop_event: threading.Event) -> None:
2760
+ """Background indexer loop: process files in batches."""
2761
+ import time
2762
+
2763
+ from attocode_intel._internal.integrations.context.codebase_context import (
2764
+ CodebaseContextManager,
2765
+ )
2766
+ from attocode_intel._internal.integrations.context.vector_store import VectorEntry
2767
+
2768
+ ctx = CodebaseContextManager(root_dir=self.root_dir)
2769
+ ctx._ensure_fresh()
2770
+ if not ctx._files:
2771
+ ctx.discover_files()
2772
+
2773
+ _supported = self._supported_languages()
2774
+ if _supported == {"python", "javascript", "typescript"}:
2775
+ self._index_progress.degraded_reason = "tree_sitter_languages_unavailable"
2776
+
2777
+ indexable = [f for f in ctx._files if f.language in _supported]
2778
+ self._index_progress.total_files = len(indexable)
2779
+
2780
+ # Determine which files need indexing
2781
+ file_mtimes: dict[str, float] = {}
2782
+ for f in indexable:
2783
+ try:
2784
+ file_mtimes[f.relative_path] = os.path.getmtime(f.path)
2785
+ except OSError:
2786
+ pass
2787
+
2788
+ stale = set(self._store.get_stale_files(file_mtimes))
2789
+ # Also include files not yet indexed at all
2790
+ all_indexed = set(self._store.get_all_indexed_files())
2791
+ to_index = [f for f in indexable if f.relative_path in stale or f.relative_path not in all_indexed]
2792
+
2793
+ self._index_progress.indexed_files = len(indexable) - len(to_index)
2794
+ batch_size = 50
2795
+
2796
+ for i in range(0, len(to_index), batch_size):
2797
+ if stop_event.is_set():
2798
+ self._index_progress.status = "paused"
2799
+ return
2800
+
2801
+ batch = to_index[i:i + batch_size]
2802
+ for f in batch:
2803
+ if stop_event.is_set():
2804
+ self._index_progress.status = "paused"
2805
+ return
2806
+ try:
2807
+ chunks = self._chunk_single_file(f.relative_path, f.path)
2808
+ if not chunks:
2809
+ self._index_progress.failed_files += 1
2810
+ self._index_progress.last_error = (
2811
+ f"No indexable chunks generated for {f.relative_path}"
2812
+ )
2813
+ continue
2814
+
2815
+ texts = self._get_embedding_texts(chunks)
2816
+ vectors = self._provider.embed(texts)
2817
+ entries = []
2818
+ for chunk, vec in zip(chunks, vectors, strict=False):
2819
+ if not vec:
2820
+ continue
2821
+ cid = chunk[0]
2822
+ fpath = chunk[1]
2823
+ ctype = chunk[2]
2824
+ text = chunk[3]
2825
+ entries.append(VectorEntry(
2826
+ id=cid,
2827
+ file_path=fpath,
2828
+ chunk_type=ctype,
2829
+ name=cid.split(":")[-1] if ":" in cid else fpath,
2830
+ text=text,
2831
+ vector=vec,
2832
+ ))
2833
+
2834
+ if entries:
2835
+ self._store.delete_by_file(f.relative_path)
2836
+ self._store.upsert_batch(entries)
2837
+ try:
2838
+ mtime = os.path.getmtime(f.path)
2839
+ except OSError:
2840
+ mtime = 0.0
2841
+ self._store.set_file_metadata(f.relative_path, mtime, len(entries))
2842
+ with self._reindex_lock:
2843
+ self._index_progress.indexed_files += 1
2844
+ else:
2845
+ with self._reindex_lock:
2846
+ self._index_progress.failed_files += 1
2847
+ self._index_progress.last_error = (
2848
+ f"No vectors generated for {f.relative_path}"
2849
+ )
2850
+ self._index_progress.degraded_reason = "partial_index_failure"
2851
+ except Exception as exc:
2852
+ logger.debug("bg_index_failed: %s", f.relative_path, exc_info=True)
2853
+ with self._reindex_lock:
2854
+ self._index_progress.failed_files += 1
2855
+ self._index_progress.last_error = (
2856
+ f"Failed to index {f.relative_path}: {type(exc).__name__}: {exc}"
2857
+ )
2858
+ self._index_progress.degraded_reason = "partial_index_failure"
2859
+
2860
+ # Update coverage
2861
+ with self._reindex_lock:
2862
+ total = self._index_progress.total_files or 1
2863
+ self._index_progress.coverage = round(self._index_progress.indexed_files / total, 3)
2864
+ self._index_progress.elapsed_seconds = round(time.time() - self._index_progress.started_at, 1)
2865
+
2866
+ # Yield CPU between batches
2867
+ time.sleep(0.1)
2868
+
2869
+ with self._reindex_lock:
2870
+ self._index_progress.status = "completed"
2871
+ total = self._index_progress.total_files or 1
2872
+ self._index_progress.coverage = round(self._index_progress.indexed_files / total, 3)
2873
+ self._index_progress.elapsed_seconds = round(time.time() - self._index_progress.started_at, 1)
2874
+ if self._index_progress.failed_files > 0 and not self._index_progress.degraded_reason:
2875
+ self._index_progress.degraded_reason = "partial_index_failure"
2876
+ self._indexed = True
2877
+ logger.info(
2878
+ "Background indexing complete: %d/%d files (%.0f%% coverage)",
2879
+ self._index_progress.indexed_files,
2880
+ self._index_progress.total_files,
2881
+ self._index_progress.coverage * 100,
2882
+ )
2883
+
2884
+ def close(self) -> None:
2885
+ """Close the vector store and stop background indexer."""
2886
+ self.stop_background_indexing()
2887
+ thread = self._bg_thread
2888
+ if thread is not None:
2889
+ thread.join(timeout=5.0)
2890
+ if thread.is_alive():
2891
+ logger.warning("Background indexer thread did not stop within 5s")
2892
+ if self._store:
2893
+ self._store.close()
2894
+ if self._trigram_index is not None:
2895
+ self._trigram_index.close()
2896
+ self._trigram_index = None
2897
+ body_thread = self._body_thread
2898
+ if body_thread is not None:
2899
+ body_thread.join(timeout=5.0)
2900
+ if body_thread.is_alive():
2901
+ logger.warning("Source-body indexer thread did not stop within 5s")
2902
+
2903
+ def format_results(self, results: list[SemanticSearchResult], query: str = "") -> str:
2904
+ """Format search results as human-readable text."""
2905
+ if not results:
2906
+ return "No results found."
2907
+
2908
+ lines = [f"Semantic search results ({len(results)}):"]
2909
+ for i, r in enumerate(results, 1):
2910
+ location = r.file_path
2911
+ if r.start_line > 0:
2912
+ location += f":{r.start_line}-{r.end_line or r.start_line}"
2913
+ lines.append(
2914
+ f" {i}. [{r.chunk_type}] {location}"
2915
+ f" — {r.name} (score: {r.score:.3f})"
2916
+ )
2917
+ if query:
2918
+ evidence = hit_evidence(query, r)
2919
+ if evidence["matched_terms"]:
2920
+ lines.append(
2921
+ " matches: " + ", ".join(evidence["matched_terms"])
2922
+ + " (" + ", ".join(evidence["match_fields"]) + ")"
2923
+ )
2924
+ if r.text:
2925
+ # Show first 120 chars of text
2926
+ body = r.text.split("\n", 1)[-1] if r.start_line > 0 else r.text
2927
+ preview = body[:120]
2928
+ if len(body) > 120:
2929
+ preview += "..."
2930
+ lines.append(f" {preview}")
2931
+ return "\n".join(lines)