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,594 @@
|
|
|
1
|
+
"""Embedding storage — content-SHA-keyed with model tracking, pgvector-ready."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingDimensionMismatchError(RuntimeError):
|
|
17
|
+
"""Raised when the running embedding provider's dimension does not match
|
|
18
|
+
the vectors already persisted in the ``embeddings`` table.
|
|
19
|
+
|
|
20
|
+
Migration 016 replaced the old behavior — which silently ran
|
|
21
|
+
``UPDATE embeddings SET vector = NULL`` and dropped every stored
|
|
22
|
+
embedding — with a loud refusal. Callers must now explicitly rotate
|
|
23
|
+
to the new dimension via the (phase-4) embeddings rotation endpoint,
|
|
24
|
+
or clear the table manually. Silent data loss is never OK.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, stored: int, expected: int) -> None:
|
|
28
|
+
self.stored = stored
|
|
29
|
+
self.expected = expected
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"embedding dimension mismatch: stored={stored} expected={expected}. "
|
|
32
|
+
f"Refusing to wipe existing vectors. "
|
|
33
|
+
f"Resolve by starting an embedding rotation (POST /embeddings/rotate), "
|
|
34
|
+
f"or explicitly re-dimensioning the column after backing up."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def ensure_vector_columns(
|
|
39
|
+
session: AsyncSession,
|
|
40
|
+
primary_dim: int,
|
|
41
|
+
shadow_dim: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Ensure ``embeddings.vector`` exists at ``primary_dim`` without destroying
|
|
44
|
+
data, and optionally ensure a ``vector_b`` shadow column at ``shadow_dim``.
|
|
45
|
+
|
|
46
|
+
Behavior:
|
|
47
|
+
- If ``vector`` does not exist: CREATE at ``primary_dim``, build HNSW index.
|
|
48
|
+
- If ``vector`` exists at ``primary_dim``: no-op.
|
|
49
|
+
- If ``vector`` exists at a DIFFERENT dim: **raise** ``EmbeddingDimensionMismatchError``.
|
|
50
|
+
Old code silently NULLed every row here; that was a footgun.
|
|
51
|
+
- If ``shadow_dim`` is given and differs from ``primary_dim``: ensure a
|
|
52
|
+
``vector_b`` column at ``shadow_dim`` (used during rotation dual-write).
|
|
53
|
+
Never touches the primary column.
|
|
54
|
+
|
|
55
|
+
``shadow_dim`` is accepted now so the rotation machinery in a later phase
|
|
56
|
+
doesn't have to re-touch this function.
|
|
57
|
+
"""
|
|
58
|
+
from sqlalchemy import text
|
|
59
|
+
|
|
60
|
+
result = await session.execute(
|
|
61
|
+
text(
|
|
62
|
+
"SELECT atttypmod FROM pg_attribute "
|
|
63
|
+
"WHERE attrelid = 'embeddings'::regclass AND attname = 'vector'"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
row = result.first()
|
|
67
|
+
|
|
68
|
+
if row is None:
|
|
69
|
+
# Column doesn't exist — safe to add.
|
|
70
|
+
await session.execute(
|
|
71
|
+
text(f"ALTER TABLE embeddings ADD COLUMN vector vector({primary_dim})")
|
|
72
|
+
)
|
|
73
|
+
await session.execute(
|
|
74
|
+
text(
|
|
75
|
+
"CREATE INDEX IF NOT EXISTS idx_embeddings_vector_hnsw "
|
|
76
|
+
"ON embeddings USING hnsw (vector vector_cosine_ops) "
|
|
77
|
+
"WITH (m = 16, ef_construction = 64)"
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
logger.info(
|
|
81
|
+
"Created vector column with dimension %d and HNSW index", primary_dim
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
stored_dim = row[0] - 4 # pgvector stores dim+4 in atttypmod
|
|
85
|
+
if stored_dim != primary_dim:
|
|
86
|
+
logger.error(
|
|
87
|
+
"Embedding dimension mismatch: stored=%d, expected=%d. "
|
|
88
|
+
"Refusing destructive UPDATE. Use embedding rotation flow.",
|
|
89
|
+
stored_dim, primary_dim,
|
|
90
|
+
)
|
|
91
|
+
raise EmbeddingDimensionMismatchError(
|
|
92
|
+
stored=stored_dim, expected=primary_dim,
|
|
93
|
+
)
|
|
94
|
+
logger.debug("Vector column already has correct dimension %d", primary_dim)
|
|
95
|
+
|
|
96
|
+
# Shadow column handling — used during a live rotation where new writes
|
|
97
|
+
# target vector_b with the new dimension while reads still hit vector.
|
|
98
|
+
if shadow_dim is not None and shadow_dim != primary_dim:
|
|
99
|
+
result_b = await session.execute(
|
|
100
|
+
text(
|
|
101
|
+
"SELECT atttypmod FROM pg_attribute "
|
|
102
|
+
"WHERE attrelid = 'embeddings'::regclass AND attname = 'vector_b'"
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
row_b = result_b.first()
|
|
106
|
+
if row_b is None:
|
|
107
|
+
await session.execute(
|
|
108
|
+
text(f"ALTER TABLE embeddings ADD COLUMN vector_b vector({shadow_dim})")
|
|
109
|
+
)
|
|
110
|
+
await session.execute(
|
|
111
|
+
text(
|
|
112
|
+
"CREATE INDEX IF NOT EXISTS idx_embeddings_vector_b_hnsw "
|
|
113
|
+
"ON embeddings USING hnsw (vector_b vector_cosine_ops) "
|
|
114
|
+
"WITH (m = 16, ef_construction = 64)"
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
logger.info("Created shadow vector_b column at dimension %d", shadow_dim)
|
|
118
|
+
else:
|
|
119
|
+
stored_shadow = row_b[0] - 4
|
|
120
|
+
if stored_shadow != shadow_dim:
|
|
121
|
+
raise EmbeddingDimensionMismatchError(
|
|
122
|
+
stored=stored_shadow, expected=shadow_dim,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await session.commit()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class EmbeddingStore:
|
|
129
|
+
"""Content-SHA-keyed embedding storage for semantic search.
|
|
130
|
+
|
|
131
|
+
Embeddings are keyed by (content_sha, embedding_model, embedding_model_version)
|
|
132
|
+
for deduplication. If content_sha+model+version already exists, skip — same
|
|
133
|
+
content always produces the same embeddings for a given model version.
|
|
134
|
+
|
|
135
|
+
Branch-aware queries resolve through BranchOverlay.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
139
|
+
self._session = session
|
|
140
|
+
|
|
141
|
+
async def upsert_embeddings(
|
|
142
|
+
self,
|
|
143
|
+
content_sha: str,
|
|
144
|
+
embeddings: list[dict],
|
|
145
|
+
) -> int:
|
|
146
|
+
"""Store embeddings for a content hash.
|
|
147
|
+
|
|
148
|
+
Each embedding dict: {chunk_text, chunk_type, embedding_model,
|
|
149
|
+
embedding_model_version?, embedding_dim?, embedding_provenance?, vector?}
|
|
150
|
+
|
|
151
|
+
Idempotent within a (content_sha, model, model_version) triple —
|
|
152
|
+
replaces all rows for that triple. Other (model, version) pairs
|
|
153
|
+
coexist peacefully (needed for the dual-write rotation flow).
|
|
154
|
+
|
|
155
|
+
Codex fix M7: every insert is mirrored into the ``provenance``
|
|
156
|
+
table so downstream tools can join on ``action_hash`` /
|
|
157
|
+
``input_blob_oid``. The provenance write is batched alongside
|
|
158
|
+
the embedding insert and does not commit separately — the
|
|
159
|
+
caller's existing session lifecycle controls atomicity.
|
|
160
|
+
|
|
161
|
+
Returns count of embeddings stored.
|
|
162
|
+
"""
|
|
163
|
+
from sqlalchemy import delete
|
|
164
|
+
|
|
165
|
+
from attocode_intel.db.models import Embedding
|
|
166
|
+
from attocode_intel.storage.provenance_store import (
|
|
167
|
+
embedding_provenance_dict,
|
|
168
|
+
write_provenance_rows,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if not embeddings:
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
# Determine (model, version) pairs being upserted. Rows at other
|
|
175
|
+
# versions stay put — this is the key invariant enabling rotation.
|
|
176
|
+
pairs_in_batch: set[tuple[str, str]] = {
|
|
177
|
+
(
|
|
178
|
+
e.get("embedding_model", "default"),
|
|
179
|
+
e.get("embedding_model_version", ""),
|
|
180
|
+
)
|
|
181
|
+
for e in embeddings
|
|
182
|
+
}
|
|
183
|
+
for model, version in pairs_in_batch:
|
|
184
|
+
await self._session.execute(
|
|
185
|
+
delete(Embedding).where(
|
|
186
|
+
Embedding.content_sha == content_sha,
|
|
187
|
+
Embedding.embedding_model == model,
|
|
188
|
+
Embedding.embedding_model_version == version,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Insert new + collect provenance rows in parallel so they land
|
|
193
|
+
# in the same unit of work.
|
|
194
|
+
provenance_batch: list[dict] = []
|
|
195
|
+
for emb_data in embeddings:
|
|
196
|
+
model = emb_data.get("embedding_model", "default")
|
|
197
|
+
version = emb_data.get("embedding_model_version", "")
|
|
198
|
+
dim = emb_data.get("embedding_dim")
|
|
199
|
+
provenance = emb_data.get("embedding_provenance", {}) or {}
|
|
200
|
+
action_hash = provenance.get("action_hash", "") if isinstance(provenance, dict) else ""
|
|
201
|
+
kwargs = {
|
|
202
|
+
"content_sha": content_sha,
|
|
203
|
+
"embedding_model": model,
|
|
204
|
+
"embedding_model_version": version,
|
|
205
|
+
"embedding_dim": dim,
|
|
206
|
+
"embedding_provenance": provenance,
|
|
207
|
+
"chunk_text": emb_data.get("chunk_text", ""),
|
|
208
|
+
"chunk_type": emb_data.get("chunk_type", "file"),
|
|
209
|
+
}
|
|
210
|
+
# Include vector if provided and pgvector is available
|
|
211
|
+
if "vector" in emb_data and emb_data["vector"] is not None:
|
|
212
|
+
kwargs["vector"] = emb_data["vector"]
|
|
213
|
+
emb = Embedding(**kwargs)
|
|
214
|
+
self._session.add(emb)
|
|
215
|
+
|
|
216
|
+
# One provenance row per chunk. Empty action_hash is fine —
|
|
217
|
+
# it just means no dedup key was computed upstream; the row
|
|
218
|
+
# still provides the indexer + blob lineage needed for
|
|
219
|
+
# later audits.
|
|
220
|
+
provenance_batch.append(embedding_provenance_dict(
|
|
221
|
+
action_hash=action_hash,
|
|
222
|
+
content_sha=content_sha,
|
|
223
|
+
model_name=model,
|
|
224
|
+
model_version=version,
|
|
225
|
+
dimension=int(dim) if dim else None,
|
|
226
|
+
config_digest=(
|
|
227
|
+
provenance.get("config_digest", "")
|
|
228
|
+
if isinstance(provenance, dict)
|
|
229
|
+
else ""
|
|
230
|
+
),
|
|
231
|
+
chunk_type=emb_data.get("chunk_type", "file"),
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
if provenance_batch:
|
|
235
|
+
await write_provenance_rows(self._session, provenance_batch)
|
|
236
|
+
|
|
237
|
+
await self._session.flush()
|
|
238
|
+
return len(embeddings)
|
|
239
|
+
|
|
240
|
+
async def batch_has_embeddings(self, content_shas: set[str], model: str = "default") -> set[str]:
|
|
241
|
+
"""Check which content_shas have embeddings. Returns the subset that do."""
|
|
242
|
+
if not content_shas:
|
|
243
|
+
return set()
|
|
244
|
+
|
|
245
|
+
from sqlalchemy import select
|
|
246
|
+
|
|
247
|
+
from attocode_intel.db.models import Embedding
|
|
248
|
+
|
|
249
|
+
stmt = select(Embedding.content_sha).where(
|
|
250
|
+
Embedding.content_sha.in_(content_shas),
|
|
251
|
+
Embedding.embedding_model == model,
|
|
252
|
+
)
|
|
253
|
+
if hasattr(Embedding, 'vector'):
|
|
254
|
+
stmt = stmt.where(Embedding.vector.isnot(None))
|
|
255
|
+
result = await self._session.execute(stmt.distinct())
|
|
256
|
+
return {row[0] for row in result}
|
|
257
|
+
|
|
258
|
+
async def batch_embedding_stats(
|
|
259
|
+
self, content_shas: set[str], model: str = "default",
|
|
260
|
+
) -> dict[str, dict]:
|
|
261
|
+
"""Get chunk count + last embedded time for content SHAs with embeddings."""
|
|
262
|
+
if not content_shas:
|
|
263
|
+
return {}
|
|
264
|
+
|
|
265
|
+
from sqlalchemy import func, select
|
|
266
|
+
|
|
267
|
+
from attocode_intel.db.models import Embedding
|
|
268
|
+
|
|
269
|
+
stmt = select(
|
|
270
|
+
Embedding.content_sha,
|
|
271
|
+
func.count().label("chunk_count"),
|
|
272
|
+
func.max(Embedding.created_at).label("last_embedded"),
|
|
273
|
+
).where(
|
|
274
|
+
Embedding.content_sha.in_(content_shas),
|
|
275
|
+
Embedding.embedding_model == model,
|
|
276
|
+
)
|
|
277
|
+
if hasattr(Embedding, 'vector'):
|
|
278
|
+
stmt = stmt.where(Embedding.vector.isnot(None))
|
|
279
|
+
stmt = stmt.group_by(Embedding.content_sha)
|
|
280
|
+
result = await self._session.execute(stmt)
|
|
281
|
+
return {
|
|
282
|
+
row.content_sha: {
|
|
283
|
+
"chunk_count": row.chunk_count,
|
|
284
|
+
"last_embedded": row.last_embedded,
|
|
285
|
+
}
|
|
286
|
+
for row in result
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async def has_embeddings(self, content_sha: str, model: str = "default") -> bool:
|
|
290
|
+
"""Check if embeddings exist for a content_sha+model. Used for dedup gating."""
|
|
291
|
+
from sqlalchemy import select
|
|
292
|
+
|
|
293
|
+
from attocode_intel.db.models import Embedding
|
|
294
|
+
|
|
295
|
+
stmt = select(Embedding.id).where(
|
|
296
|
+
Embedding.content_sha == content_sha,
|
|
297
|
+
Embedding.embedding_model == model,
|
|
298
|
+
)
|
|
299
|
+
if hasattr(Embedding, 'vector'):
|
|
300
|
+
stmt = stmt.where(Embedding.vector.isnot(None))
|
|
301
|
+
result = await self._session.execute(stmt.limit(1))
|
|
302
|
+
return result.scalar_one_or_none() is not None
|
|
303
|
+
|
|
304
|
+
async def find_similar_by_sha(
|
|
305
|
+
self,
|
|
306
|
+
branch_id: uuid.UUID,
|
|
307
|
+
content_sha: str,
|
|
308
|
+
top_k: int = 10,
|
|
309
|
+
model: str = "default",
|
|
310
|
+
) -> list[dict]:
|
|
311
|
+
"""Find files similar to the given content_sha within a branch.
|
|
312
|
+
|
|
313
|
+
Retrieves the embedding vector(s) for the source content_sha, averages
|
|
314
|
+
them if multiple chunks exist, then runs a cosine similarity search
|
|
315
|
+
excluding the source itself.
|
|
316
|
+
"""
|
|
317
|
+
from sqlalchemy import text
|
|
318
|
+
|
|
319
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
320
|
+
|
|
321
|
+
# 1. Get vectors for the source content_sha
|
|
322
|
+
result = await self._session.execute(
|
|
323
|
+
text("""
|
|
324
|
+
SELECT vector::text FROM embeddings
|
|
325
|
+
WHERE content_sha = :sha AND embedding_model = :model AND vector IS NOT NULL
|
|
326
|
+
"""),
|
|
327
|
+
{"sha": content_sha, "model": model},
|
|
328
|
+
)
|
|
329
|
+
rows = result.fetchall()
|
|
330
|
+
if not rows:
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
# 2. Parse and average vectors
|
|
334
|
+
def parse_vector(vec_str: str) -> list[float]:
|
|
335
|
+
return [float(x) for x in vec_str.strip("[]").split(",")]
|
|
336
|
+
|
|
337
|
+
vectors = [parse_vector(row[0]) for row in rows]
|
|
338
|
+
dim = len(vectors[0])
|
|
339
|
+
if len(vectors) == 1:
|
|
340
|
+
avg_vector = vectors[0]
|
|
341
|
+
else:
|
|
342
|
+
avg_vector = [sum(v[i] for v in vectors) / len(vectors) for i in range(dim)]
|
|
343
|
+
|
|
344
|
+
# 3. Resolve branch manifest
|
|
345
|
+
overlay = BranchOverlay(self._session)
|
|
346
|
+
manifest = await overlay.resolve_manifest(branch_id)
|
|
347
|
+
content_shas = set(manifest.values())
|
|
348
|
+
content_shas.discard(content_sha) # exclude source
|
|
349
|
+
|
|
350
|
+
if not content_shas:
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
sha_to_path = {sha: path for path, sha in manifest.items()}
|
|
354
|
+
|
|
355
|
+
# 4. Run cosine similarity search
|
|
356
|
+
result = await self._session.execute(
|
|
357
|
+
text("""
|
|
358
|
+
SELECT content_sha, chunk_text, chunk_type, embedding_model,
|
|
359
|
+
1 - (vector <=> CAST(:qv AS vector)) AS score
|
|
360
|
+
FROM embeddings
|
|
361
|
+
WHERE content_sha = ANY(:shas)
|
|
362
|
+
AND embedding_model = :model
|
|
363
|
+
AND vector IS NOT NULL
|
|
364
|
+
ORDER BY vector <=> CAST(:qv AS vector)
|
|
365
|
+
LIMIT :top_k
|
|
366
|
+
"""),
|
|
367
|
+
{
|
|
368
|
+
"qv": "[" + ",".join(str(v) for v in avg_vector) + "]",
|
|
369
|
+
"shas": list(content_shas),
|
|
370
|
+
"model": model,
|
|
371
|
+
"top_k": top_k,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
results = []
|
|
376
|
+
for row in result:
|
|
377
|
+
results.append({
|
|
378
|
+
"file": sha_to_path.get(row.content_sha, "unknown"),
|
|
379
|
+
"content_sha": row.content_sha,
|
|
380
|
+
"chunk_text": row.chunk_text,
|
|
381
|
+
"score": float(row.score),
|
|
382
|
+
})
|
|
383
|
+
return results
|
|
384
|
+
|
|
385
|
+
async def similarity_search(
|
|
386
|
+
self,
|
|
387
|
+
branch_id: uuid.UUID,
|
|
388
|
+
query_vector: list[float],
|
|
389
|
+
top_k: int = 10,
|
|
390
|
+
model: str = "default",
|
|
391
|
+
file_filter: str = "",
|
|
392
|
+
) -> list[dict]:
|
|
393
|
+
"""Find most similar content within a branch context using pgvector cosine distance.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
branch_id: Branch to scope results to.
|
|
397
|
+
query_vector: Embedded query vector.
|
|
398
|
+
top_k: Number of results to return.
|
|
399
|
+
model: Embedding model name to filter by.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
List of dicts with file, content_sha, chunk_text, chunk_type, model, score.
|
|
403
|
+
"""
|
|
404
|
+
from sqlalchemy import text
|
|
405
|
+
|
|
406
|
+
from attocode_intel.storage.branch_overlay import BranchOverlay
|
|
407
|
+
|
|
408
|
+
overlay = BranchOverlay(self._session)
|
|
409
|
+
manifest = await overlay.resolve_manifest(branch_id)
|
|
410
|
+
|
|
411
|
+
if file_filter:
|
|
412
|
+
import fnmatch
|
|
413
|
+
manifest = {p: s for p, s in manifest.items() if fnmatch.fnmatch(p, file_filter)}
|
|
414
|
+
|
|
415
|
+
content_shas = set(manifest.values())
|
|
416
|
+
|
|
417
|
+
if not content_shas:
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
sha_to_path = {sha: path for path, sha in manifest.items()}
|
|
421
|
+
|
|
422
|
+
# pgvector cosine distance query — score = 1 - distance (higher = more similar)
|
|
423
|
+
result = await self._session.execute(
|
|
424
|
+
text("""
|
|
425
|
+
SELECT content_sha, chunk_text, chunk_type, embedding_model,
|
|
426
|
+
1 - (vector <=> CAST(:qv AS vector)) AS score
|
|
427
|
+
FROM embeddings
|
|
428
|
+
WHERE content_sha = ANY(:shas)
|
|
429
|
+
AND embedding_model = :model
|
|
430
|
+
AND vector IS NOT NULL
|
|
431
|
+
ORDER BY vector <=> CAST(:qv AS vector)
|
|
432
|
+
LIMIT :top_k
|
|
433
|
+
"""),
|
|
434
|
+
{
|
|
435
|
+
"qv": "[" + ",".join(str(v) for v in query_vector) + "]",
|
|
436
|
+
"shas": list(content_shas),
|
|
437
|
+
"model": model,
|
|
438
|
+
"top_k": top_k,
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
results = []
|
|
443
|
+
for row in result:
|
|
444
|
+
results.append({
|
|
445
|
+
"file": sha_to_path.get(row.content_sha, "unknown"),
|
|
446
|
+
"content_sha": row.content_sha,
|
|
447
|
+
"chunk_text": row.chunk_text,
|
|
448
|
+
"chunk_type": row.chunk_type,
|
|
449
|
+
"model": row.embedding_model,
|
|
450
|
+
"score": float(row.score),
|
|
451
|
+
})
|
|
452
|
+
return results
|
|
453
|
+
|
|
454
|
+
async def multi_branch_similarity_search(
|
|
455
|
+
self,
|
|
456
|
+
session: AsyncSession,
|
|
457
|
+
branch_manifests: dict[str, dict[str, str]],
|
|
458
|
+
query_vector: list[float],
|
|
459
|
+
top_k: int = 20,
|
|
460
|
+
model: str = "",
|
|
461
|
+
file_filter: str = "",
|
|
462
|
+
) -> list[dict]:
|
|
463
|
+
"""Find most similar content across multiple repo branch manifests.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
session: Async DB session (uses self._session if same, but kept
|
|
467
|
+
for API symmetry with callers that pass it explicitly).
|
|
468
|
+
branch_manifests: Mapping of repo_id (str) → manifest (path → content_sha).
|
|
469
|
+
query_vector: Embedded query vector.
|
|
470
|
+
top_k: Number of results to return globally.
|
|
471
|
+
model: Embedding model name to filter by.
|
|
472
|
+
file_filter: Optional glob pattern to filter file paths.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
List of dicts with repo_id, file, content_sha, chunk_text,
|
|
476
|
+
chunk_type, model, score — sorted by score descending.
|
|
477
|
+
"""
|
|
478
|
+
import fnmatch
|
|
479
|
+
|
|
480
|
+
from sqlalchemy import text
|
|
481
|
+
|
|
482
|
+
# 1. Union all content_shas across repos, building reverse lookup
|
|
483
|
+
all_content_shas: set[str] = set()
|
|
484
|
+
# sha → (repo_id, path) for mapping results back
|
|
485
|
+
sha_to_context: dict[str, tuple[str, str]] = {}
|
|
486
|
+
|
|
487
|
+
for repo_id, manifest in branch_manifests.items():
|
|
488
|
+
for path, sha in manifest.items():
|
|
489
|
+
if file_filter and not fnmatch.fnmatch(path, file_filter):
|
|
490
|
+
continue
|
|
491
|
+
all_content_shas.add(sha)
|
|
492
|
+
# First repo wins for a given sha (content-addressed dedup)
|
|
493
|
+
if sha not in sha_to_context:
|
|
494
|
+
sha_to_context[sha] = (repo_id, path)
|
|
495
|
+
|
|
496
|
+
if not all_content_shas:
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
# 2. Single pgvector query across all repos
|
|
500
|
+
result = await self._session.execute(
|
|
501
|
+
text("""
|
|
502
|
+
SELECT content_sha, chunk_text, chunk_type, embedding_model,
|
|
503
|
+
1 - (vector <=> CAST(:qv AS vector)) AS score
|
|
504
|
+
FROM embeddings
|
|
505
|
+
WHERE content_sha = ANY(:shas)
|
|
506
|
+
AND embedding_model = :model
|
|
507
|
+
AND vector IS NOT NULL
|
|
508
|
+
ORDER BY vector <=> CAST(:qv AS vector)
|
|
509
|
+
LIMIT :top_k
|
|
510
|
+
"""),
|
|
511
|
+
{
|
|
512
|
+
"qv": "[" + ",".join(str(v) for v in query_vector) + "]",
|
|
513
|
+
"shas": list(all_content_shas),
|
|
514
|
+
"model": model,
|
|
515
|
+
"top_k": top_k,
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# 3. Map results back to repo context
|
|
520
|
+
results = []
|
|
521
|
+
for row in result:
|
|
522
|
+
ctx = sha_to_context.get(row.content_sha)
|
|
523
|
+
repo_id = ctx[0] if ctx else "unknown"
|
|
524
|
+
file_path = ctx[1] if ctx else "unknown"
|
|
525
|
+
results.append({
|
|
526
|
+
"repo_id": repo_id,
|
|
527
|
+
"file": file_path,
|
|
528
|
+
"content_sha": row.content_sha,
|
|
529
|
+
"chunk_text": row.chunk_text,
|
|
530
|
+
"chunk_type": row.chunk_type,
|
|
531
|
+
"model": row.embedding_model,
|
|
532
|
+
"score": float(row.score),
|
|
533
|
+
})
|
|
534
|
+
return results
|
|
535
|
+
|
|
536
|
+
async def gc_orphaned(
|
|
537
|
+
self,
|
|
538
|
+
min_age_minutes: int = 60,
|
|
539
|
+
*,
|
|
540
|
+
repo_id: uuid.UUID | None = None,
|
|
541
|
+
) -> int:
|
|
542
|
+
"""Delete embeddings whose content_sha is not in any branch manifest.
|
|
543
|
+
|
|
544
|
+
Only deletes embeddings older than *min_age_minutes* to avoid racing
|
|
545
|
+
with concurrent indexers that are still writing references.
|
|
546
|
+
|
|
547
|
+
When ``repo_id`` is provided, restricts the DELETE to embeddings
|
|
548
|
+
whose content_sha was ever tracked by a branch of the target
|
|
549
|
+
repo AND is no longer tracked anywhere. ``repo_id=None`` runs
|
|
550
|
+
global GC (used by background workers).
|
|
551
|
+
|
|
552
|
+
Returns count of deleted rows.
|
|
553
|
+
"""
|
|
554
|
+
from sqlalchemy import text
|
|
555
|
+
|
|
556
|
+
if repo_id is None:
|
|
557
|
+
result = await self._session.execute(
|
|
558
|
+
text("""
|
|
559
|
+
DELETE FROM embeddings
|
|
560
|
+
WHERE created_at < NOW() - make_interval(mins => :age)
|
|
561
|
+
AND content_sha NOT IN (
|
|
562
|
+
SELECT DISTINCT content_sha FROM branch_files WHERE content_sha IS NOT NULL
|
|
563
|
+
)
|
|
564
|
+
""").bindparams(age=min_age_minutes)
|
|
565
|
+
)
|
|
566
|
+
else:
|
|
567
|
+
result = await self._session.execute(
|
|
568
|
+
text("""
|
|
569
|
+
DELETE FROM embeddings
|
|
570
|
+
WHERE created_at < NOW() - make_interval(mins => :age)
|
|
571
|
+
AND content_sha IN (
|
|
572
|
+
SELECT DISTINCT bf.content_sha
|
|
573
|
+
FROM branch_files bf
|
|
574
|
+
JOIN branches b ON b.id = bf.branch_id
|
|
575
|
+
WHERE b.repo_id = :repo_id AND bf.content_sha IS NOT NULL
|
|
576
|
+
)
|
|
577
|
+
AND content_sha NOT IN (
|
|
578
|
+
SELECT DISTINCT content_sha FROM branch_files WHERE content_sha IS NOT NULL
|
|
579
|
+
)
|
|
580
|
+
""").bindparams(age=min_age_minutes, repo_id=str(repo_id))
|
|
581
|
+
)
|
|
582
|
+
count = result.rowcount
|
|
583
|
+
if count:
|
|
584
|
+
if repo_id is None:
|
|
585
|
+
logger.info(
|
|
586
|
+
"GC: removed %d orphaned embeddings (older than %dm, global)",
|
|
587
|
+
count, min_age_minutes,
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
logger.info(
|
|
591
|
+
"GC: removed %d orphaned embeddings (older than %dm, repo=%s)",
|
|
592
|
+
count, min_age_minutes, repo_id,
|
|
593
|
+
)
|
|
594
|
+
return count
|