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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- 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)
|