woods 1.6.1 → 2.0.0.beta2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
data/lib/woods/storage/qdrant.rb
CHANGED
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
require 'ipaddr'
|
|
4
4
|
require 'net/http'
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'openssl'
|
|
6
7
|
require 'socket'
|
|
7
8
|
require 'uri'
|
|
8
9
|
require_relative 'vector_store'
|
|
9
10
|
require_relative '../util/host_guard'
|
|
11
|
+
require_relative '../util/uuid5'
|
|
10
12
|
|
|
11
13
|
module Woods
|
|
14
|
+
# Same conditional-define pattern used elsewhere in the gem (pgvector,
|
|
15
|
+
# metadata_store) so this file can be required in isolation without
|
|
16
|
+
# tripping NameError on the RequestError superclass below.
|
|
17
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
18
|
+
|
|
12
19
|
module Storage
|
|
13
20
|
module VectorStore
|
|
14
21
|
# Qdrant adapter for vector storage and similarity search via HTTP API.
|
|
@@ -25,6 +32,26 @@ module Woods
|
|
|
25
32
|
class Qdrant # rubocop:disable Metrics/ClassLength
|
|
26
33
|
include Interface
|
|
27
34
|
|
|
35
|
+
class RequestError < Woods::Error
|
|
36
|
+
attr_reader :http_status, :retry_after
|
|
37
|
+
|
|
38
|
+
def initialize(message, http_status: nil, retry_after: nil, retryable: false, ambiguous: false)
|
|
39
|
+
super(message)
|
|
40
|
+
@http_status = http_status
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
@retryable = retryable
|
|
43
|
+
@ambiguous = ambiguous
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def retryable?
|
|
47
|
+
@retryable
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ambiguous?
|
|
51
|
+
@ambiguous
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
28
55
|
# URL schemes allowed for the Qdrant endpoint. `file://`, `gopher://`,
|
|
29
56
|
# and anything else would let a misconfigured or attacker-controlled
|
|
30
57
|
# config value turn the adapter into an SSRF vector against the host
|
|
@@ -67,6 +94,54 @@ module Woods
|
|
|
67
94
|
# Hostnames that always map to loopback regardless of DNS.
|
|
68
95
|
PRIVATE_HOSTNAMES = %w[localhost localhost. ip6-localhost ip6-loopback].freeze
|
|
69
96
|
|
|
97
|
+
# Fixed UUIDv5 namespace for Woods → Qdrant point ids.
|
|
98
|
+
#
|
|
99
|
+
# Qdrant only accepts an unsigned integer or a UUID as a point id;
|
|
100
|
+
# a Woods identifier ("User", "Api::V1::UsersController",
|
|
101
|
+
# "User#chunk_0") is neither, and sending one back is a 400 on
|
|
102
|
+
# every upsert. Point ids are therefore UUIDv5 values derived from
|
|
103
|
+
# the identifier, with the identifier itself carried in the payload
|
|
104
|
+
# under {IDENTIFIER_KEY} so search and delete can round-trip.
|
|
105
|
+
#
|
|
106
|
+
# Derived once as
|
|
107
|
+
# Util::UUID5.generate(Util::UUID5::NAMESPACE_DNS, 'woods.qdrant.point-id')
|
|
108
|
+
# and pinned as a literal here. `spec/util/uuid5_spec.rb` asserts
|
|
109
|
+
# the literal still equals that derivation.
|
|
110
|
+
#
|
|
111
|
+
# **This value must never change.** The whole point of a v5 id is
|
|
112
|
+
# that re-embedding an unchanged unit lands on the same point and
|
|
113
|
+
# *replaces* it. A new namespace makes every existing point
|
|
114
|
+
# unreachable — orphaned vectors that no delete can name and a
|
|
115
|
+
# silently doubled collection.
|
|
116
|
+
POINT_ID_NAMESPACE = '7eb8ae2b-670b-55ee-a474-36bd1a8dc6b4'
|
|
117
|
+
|
|
118
|
+
# Query string appended to every mutating point operation.
|
|
119
|
+
#
|
|
120
|
+
# Qdrant defaults these endpoints to `wait=false`: the API returns
|
|
121
|
+
# `status: "acknowledged"` as soon as the change is queued, before it is
|
|
122
|
+
# readable. Every caller in this gem assumes otherwise — the embed
|
|
123
|
+
# pipeline writes vectors and then dumps/verifies, and the prune paths
|
|
124
|
+
# delete and then re-count — so an un-awaited mutation reads back as
|
|
125
|
+
# stale data (a deleted unit still answering searches, a fresh count
|
|
126
|
+
# showing the pre-write total). Correctness beats the throughput the
|
|
127
|
+
# async default buys.
|
|
128
|
+
WAIT_FOR_WRITE = '?wait=true'
|
|
129
|
+
|
|
130
|
+
# Points per page when scrolling ids in {#each_id}. Large enough that a
|
|
131
|
+
# sizable index costs few round trips, small enough that one response
|
|
132
|
+
# stays comfortably in memory (ids and one payload key only).
|
|
133
|
+
SCROLL_PAGE_SIZE = 1_000
|
|
134
|
+
DISTANCES = %w[Cosine Dot Euclid Manhattan].freeze
|
|
135
|
+
|
|
136
|
+
# Payload key holding the original Woods identifier for a point.
|
|
137
|
+
#
|
|
138
|
+
# Deliberately NOT `identifier`: the embedding Indexer already
|
|
139
|
+
# writes an `identifier` key holding the unit's *base* identifier,
|
|
140
|
+
# while a point id is derived from the possibly chunk-suffixed
|
|
141
|
+
# embed id ("User#chunk_0"). Reusing the key would clobber one
|
|
142
|
+
# with the other.
|
|
143
|
+
IDENTIFIER_KEY = 'woods_identifier'
|
|
144
|
+
|
|
70
145
|
# @param url [String] Qdrant server URL
|
|
71
146
|
# @param collection [String] Collection name
|
|
72
147
|
# @param api_key [String, nil] Optional API key for authentication
|
|
@@ -80,12 +155,13 @@ module Woods
|
|
|
80
155
|
# by default to block the common SSRF footgun. Set to true when the
|
|
81
156
|
# operator intentionally runs Qdrant on `localhost:6333` or inside
|
|
82
157
|
# a private network.
|
|
83
|
-
def initialize(url:, collection:, api_key: nil, dimensions: nil, allow_private_hosts: false)
|
|
158
|
+
def initialize(url:, collection:, api_key: nil, dimensions: nil, distance: 'Cosine', allow_private_hosts: false) # rubocop:disable Metrics/ParameterLists
|
|
84
159
|
@uri = self.class.validate_url!(url, allow_private_hosts: allow_private_hosts)
|
|
85
160
|
@url = url
|
|
86
161
|
@collection = collection
|
|
87
162
|
@api_key = api_key
|
|
88
163
|
@dimensions = dimensions
|
|
164
|
+
@distance = normalize_distance(distance)
|
|
89
165
|
end
|
|
90
166
|
|
|
91
167
|
# Validate a Qdrant endpoint URL — scheme in {ALLOWED_SCHEMES} and,
|
|
@@ -185,34 +261,49 @@ module Woods
|
|
|
185
261
|
#
|
|
186
262
|
# @param dimensions [Integer] Vector dimensionality
|
|
187
263
|
def ensure_collection!(dimensions:)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
264
|
+
dimensions = normalize_dimensions(dimensions)
|
|
265
|
+
validate_configured_dimensions!(dimensions)
|
|
266
|
+
@dimensions = dimensions
|
|
267
|
+
existing = request(:get, "/collections/#{@collection}")
|
|
268
|
+
verify_collection_dimensions!(existing, dimensions)
|
|
269
|
+
true
|
|
270
|
+
rescue RequestError => e
|
|
271
|
+
raise unless e.http_status == 404
|
|
272
|
+
|
|
273
|
+
create_collection!(dimensions)
|
|
274
|
+
true
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Deterministic Qdrant point id for a Woods identifier.
|
|
278
|
+
#
|
|
279
|
+
# Integers and canonical UUIDs pass through untouched — those are
|
|
280
|
+
# already native Qdrant point ids, so a caller holding one (from a
|
|
281
|
+
# raw scroll, say) can address the point directly. Everything else
|
|
282
|
+
# is a Woods identifier and becomes its UUIDv5.
|
|
283
|
+
#
|
|
284
|
+
# @param identifier [String, Integer]
|
|
285
|
+
# @return [String, Integer]
|
|
286
|
+
def self.point_id(identifier)
|
|
287
|
+
return identifier if identifier.is_a?(Integer)
|
|
288
|
+
return identifier if Util::UUID5.uuid?(identifier)
|
|
289
|
+
|
|
290
|
+
Util::UUID5.generate(POINT_ID_NAMESPACE, identifier)
|
|
196
291
|
end
|
|
197
292
|
|
|
198
293
|
# Store or update a vector with metadata payload.
|
|
199
294
|
#
|
|
295
|
+
# The point id sent to Qdrant is {.point_id}(id), not +id+ itself;
|
|
296
|
+
# the original identifier travels in the payload under
|
|
297
|
+
# {IDENTIFIER_KEY} so {#search} can map results back.
|
|
298
|
+
#
|
|
200
299
|
# @param id [String] Unique identifier
|
|
201
300
|
# @param vector [Array<Float>] The embedding vector
|
|
202
301
|
# @param metadata [Hash] Optional payload metadata
|
|
203
302
|
# @see Interface#store
|
|
204
303
|
def store(id, vector, metadata = {})
|
|
205
304
|
validate_dimensions!(vector) if @dimensions
|
|
206
|
-
body = {
|
|
207
|
-
|
|
208
|
-
{
|
|
209
|
-
id: id,
|
|
210
|
-
vector: vector,
|
|
211
|
-
payload: metadata
|
|
212
|
-
}
|
|
213
|
-
]
|
|
214
|
-
}
|
|
215
|
-
request(:put, "/collections/#{@collection}/points", body)
|
|
305
|
+
body = { points: [build_point(id, vector, metadata)] }
|
|
306
|
+
request(:put, "/collections/#{@collection}/points#{WAIT_FOR_WRITE}", body)
|
|
216
307
|
end
|
|
217
308
|
|
|
218
309
|
# Store multiple vectors in a single batch upsert request.
|
|
@@ -236,21 +327,29 @@ module Woods
|
|
|
236
327
|
end
|
|
237
328
|
|
|
238
329
|
body = {
|
|
239
|
-
points: entries.map
|
|
240
|
-
{ id: entry[:id], vector: entry[:vector], payload: entry[:metadata] || {} }
|
|
241
|
-
end
|
|
330
|
+
points: entries.map { |entry| build_point(entry[:id], entry[:vector], entry[:metadata] || {}) }
|
|
242
331
|
}
|
|
243
|
-
request(:put, "/collections/#{@collection}/points", body)
|
|
332
|
+
request(:put, "/collections/#{@collection}/points#{WAIT_FOR_WRITE}", body)
|
|
244
333
|
end
|
|
245
334
|
|
|
246
335
|
# Search for similar vectors.
|
|
247
336
|
#
|
|
337
|
+
# The query vector is dimension-checked before the request, mirroring
|
|
338
|
+
# the upsert path: otherwise a wrong-dimension query surfaces as a
|
|
339
|
+
# Qdrant 400 instead of the typed Woods::Error callers already handle
|
|
340
|
+
# from {#store}/{#store_batch}.
|
|
341
|
+
#
|
|
248
342
|
# @param query_vector [Array<Float>] The query embedding
|
|
249
343
|
# @param limit [Integer] Maximum results to return
|
|
250
344
|
# @param filters [Hash] Metadata key-value filters
|
|
251
|
-
# @return [Array<SearchResult>] Results sorted by descending similarity
|
|
345
|
+
# @return [Array<SearchResult>] Results sorted by descending similarity,
|
|
346
|
+
# with +id+ carrying the Woods identifier (not the UUID point id)
|
|
347
|
+
# so the adapter is interchangeable with pgvector downstream.
|
|
348
|
+
# @raise [Woods::Error] if the query vector's length disagrees with the
|
|
349
|
+
# configured dimension
|
|
252
350
|
# @see Interface#search
|
|
253
351
|
def search(query_vector, limit: 10, filters: {})
|
|
352
|
+
validate_dimensions!(query_vector) if @dimensions
|
|
254
353
|
body = {
|
|
255
354
|
vector: query_vector,
|
|
256
355
|
limit: limit,
|
|
@@ -262,24 +361,88 @@ module Woods
|
|
|
262
361
|
results = response['result'] || []
|
|
263
362
|
|
|
264
363
|
results.map do |hit|
|
|
364
|
+
payload = hit['payload'] || {}
|
|
265
365
|
SearchResult.new(
|
|
266
|
-
id
|
|
366
|
+
# Reverse-map the UUID point id back to the Woods identifier.
|
|
367
|
+
# Falls back to the raw point id for points written by
|
|
368
|
+
# something other than this adapter (or before the UUID
|
|
369
|
+
# mapping existed) — a hit with no reverse mapping is still
|
|
370
|
+
# more useful than nil.
|
|
371
|
+
id: payload[IDENTIFIER_KEY] || hit['id'],
|
|
267
372
|
score: hit['score'],
|
|
268
|
-
metadata:
|
|
373
|
+
metadata: payload
|
|
269
374
|
)
|
|
270
375
|
end
|
|
271
376
|
end
|
|
272
377
|
|
|
378
|
+
# The vector width the collection was actually created with.
|
|
379
|
+
#
|
|
380
|
+
# `ensure_collection!` is a PUT that Qdrant treats as idempotent, so an
|
|
381
|
+
# existing collection keeps whatever width it was built with; a
|
|
382
|
+
# dimension change then surfaces as a 400 on every upsert. Reading it
|
|
383
|
+
# back lets the pipeline refuse up front with a re-index remedy (#214).
|
|
384
|
+
#
|
|
385
|
+
# @return [Integer, nil] the collection's vector size, or nil when the
|
|
386
|
+
# collection does not exist or the shape is unrecognized (named
|
|
387
|
+
# vectors, for instance, which this adapter does not write)
|
|
388
|
+
def stored_dimensions
|
|
389
|
+
extract_dimensions(request(:get, "/collections/#{@collection}"))
|
|
390
|
+
rescue RequestError => e
|
|
391
|
+
raise unless e.http_status == 404
|
|
392
|
+
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Iterate over every stored id, yielding the Woods identifier.
|
|
397
|
+
#
|
|
398
|
+
# Uses the scroll API with `with_vector: false` and only the identifier
|
|
399
|
+
# key requested, so reconciliation costs payload-sized pages rather
|
|
400
|
+
# than whole vectors. Yields the same value {#search} returns as `id`
|
|
401
|
+
# (the Woods identifier, reverse-mapped from the payload) so callers
|
|
402
|
+
# can compare against extraction output directly, and so any id yielded
|
|
403
|
+
# here can be handed straight back to {#delete}.
|
|
404
|
+
#
|
|
405
|
+
# Points written by something else — no {IDENTIFIER_KEY} in their
|
|
406
|
+
# payload — are **skipped**, not yielded (STO-2). A collection may be
|
|
407
|
+
# shared with another writer; an unattributable point can never appear
|
|
408
|
+
# in extraction output, so yielding it would make the embed pipeline's
|
|
409
|
+
# reconciliation sweep read it as a vanished unit and delete another
|
|
410
|
+
# system's vector on every run. Enumerating only what Woods wrote is
|
|
411
|
+
# the property callers depend on. {#search} still falls back to the raw
|
|
412
|
+
# id, because there the point was matched, not swept.
|
|
413
|
+
#
|
|
414
|
+
# @see Interface#each_id
|
|
415
|
+
def each_id(&block)
|
|
416
|
+
return enum_for(:each_id) unless block
|
|
417
|
+
|
|
418
|
+
offset = nil
|
|
419
|
+
loop do
|
|
420
|
+
points, offset = scroll_page(offset)
|
|
421
|
+
points.each do |point|
|
|
422
|
+
identifier = woods_identifier_for(point)
|
|
423
|
+
yield(identifier) if identifier
|
|
424
|
+
end
|
|
425
|
+
break if offset.nil?
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Delete a single point by Woods identifier.
|
|
430
|
+
#
|
|
431
|
+
# Translates through {.point_id}, so it necessarily computes the
|
|
432
|
+
# same id {#store}/{#store_batch} wrote. Deleting by the raw Woods
|
|
433
|
+
# string would 400 (or, worse, succeed against nothing) and leave
|
|
434
|
+
# the vector live — silent data retention.
|
|
435
|
+
#
|
|
273
436
|
# @see Interface#delete
|
|
274
437
|
def delete(id)
|
|
275
|
-
body = { points: [id] }
|
|
276
|
-
request(:post, "/collections/#{@collection}/points/delete", body)
|
|
438
|
+
body = { points: [self.class.point_id(id)] }
|
|
439
|
+
request(:post, "/collections/#{@collection}/points/delete#{WAIT_FOR_WRITE}", body)
|
|
277
440
|
end
|
|
278
441
|
|
|
279
442
|
# @see Interface#delete_by_filter
|
|
280
443
|
def delete_by_filter(filters)
|
|
281
444
|
body = { filter: build_filter(filters) }
|
|
282
|
-
request(:post, "/collections/#{@collection}/points/delete", body)
|
|
445
|
+
request(:post, "/collections/#{@collection}/points/delete#{WAIT_FOR_WRITE}", body)
|
|
283
446
|
end
|
|
284
447
|
|
|
285
448
|
# @see Interface#count
|
|
@@ -290,6 +453,105 @@ module Woods
|
|
|
290
453
|
|
|
291
454
|
private
|
|
292
455
|
|
|
456
|
+
def normalize_dimensions(value)
|
|
457
|
+
dimensions = Integer(value)
|
|
458
|
+
raise ArgumentError, 'dimensions must be positive' unless dimensions.positive?
|
|
459
|
+
|
|
460
|
+
dimensions
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def normalize_distance(value)
|
|
464
|
+
distance = DISTANCES.find { |candidate| candidate.casecmp?(value.to_s) }
|
|
465
|
+
return distance if distance
|
|
466
|
+
|
|
467
|
+
raise ArgumentError, "distance must be one of #{DISTANCES.join(', ')}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def validate_configured_dimensions!(dimensions)
|
|
471
|
+
return unless @dimensions && Integer(@dimensions) != dimensions
|
|
472
|
+
|
|
473
|
+
raise Woods::ConfigurationError,
|
|
474
|
+
"Qdrant dimension mismatch: Builder requested #{dimensions}, " \
|
|
475
|
+
"but vector_store_options configured #{@dimensions}"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def verify_collection_dimensions!(response, dimensions)
|
|
479
|
+
vectors = response.dig('result', 'config', 'params', 'vectors')
|
|
480
|
+
unless vectors.is_a?(Hash) && vectors['size']
|
|
481
|
+
raise Woods::ConfigurationError,
|
|
482
|
+
"Qdrant collection #{@collection.inspect} uses named vectors; named vectors are not supported " \
|
|
483
|
+
'by this unnamed-vector adapter.'
|
|
484
|
+
end
|
|
485
|
+
existing_dimensions = vectors['size']
|
|
486
|
+
if existing_dimensions != dimensions
|
|
487
|
+
raise Woods::ConfigurationError,
|
|
488
|
+
"Qdrant collection dimension mismatch: configured #{dimensions}, existing #{existing_dimensions}. " \
|
|
489
|
+
'Use a new collection or rebuild the existing collection.'
|
|
490
|
+
end
|
|
491
|
+
return if vectors['distance'] == @distance
|
|
492
|
+
|
|
493
|
+
raise Woods::ConfigurationError,
|
|
494
|
+
"Qdrant collection distance mismatch: configured #{@distance}, existing #{vectors['distance']}. " \
|
|
495
|
+
'Use a new collection or rebuild the existing collection.'
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def create_collection!(dimensions)
|
|
499
|
+
request(
|
|
500
|
+
:put,
|
|
501
|
+
"/collections/#{@collection}",
|
|
502
|
+
vectors: { size: dimensions, distance: @distance }
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def extract_dimensions(response)
|
|
507
|
+
config = response.dig('result', 'config', 'params', 'vectors')
|
|
508
|
+
return unless config.is_a?(Hash)
|
|
509
|
+
|
|
510
|
+
size = config['size']
|
|
511
|
+
size if size.is_a?(Integer) && size.positive?
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Fetch one page of the scroll cursor.
|
|
515
|
+
#
|
|
516
|
+
# @param offset [Object, nil] the cursor from the previous page
|
|
517
|
+
# @return [Array(Array<Hash>, Object)] the page's points and the next
|
|
518
|
+
# offset (nil when the scroll is exhausted)
|
|
519
|
+
def scroll_page(offset)
|
|
520
|
+
body = { limit: SCROLL_PAGE_SIZE, with_payload: [IDENTIFIER_KEY], with_vector: false }
|
|
521
|
+
body[:offset] = offset if offset
|
|
522
|
+
|
|
523
|
+
result = request(:post, "/collections/#{@collection}/points/scroll", body)['result'] || {}
|
|
524
|
+
[result['points'] || [], result['next_page_offset']]
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# The Woods identifier a scrolled point carries, or nil when the point
|
|
528
|
+
# was not written by this adapter.
|
|
529
|
+
#
|
|
530
|
+
# @param point [Hash] a scroll-result point
|
|
531
|
+
# @return [String, nil]
|
|
532
|
+
def woods_identifier_for(point)
|
|
533
|
+
(point['payload'] || {})[IDENTIFIER_KEY]
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Build one Qdrant point: UUIDv5 id, vector, and a payload carrying
|
|
537
|
+
# the original Woods identifier alongside the caller's metadata.
|
|
538
|
+
#
|
|
539
|
+
# The identifier is merged in rather than replacing the payload, and
|
|
540
|
+
# written last so a metadata hash that already carries the key can't
|
|
541
|
+
# break the reverse mapping. Symbol and string metadata keys both
|
|
542
|
+
# serialize to JSON strings, so the string key here is what Qdrant
|
|
543
|
+
# stores either way.
|
|
544
|
+
#
|
|
545
|
+
# @param id [String, Integer] Woods identifier (or native point id)
|
|
546
|
+
# @param vector [Array<Float>]
|
|
547
|
+
# @param metadata [Hash]
|
|
548
|
+
# @return [Hash]
|
|
549
|
+
def build_point(id, vector, metadata)
|
|
550
|
+
{ id: self.class.point_id(id),
|
|
551
|
+
vector: vector,
|
|
552
|
+
payload: (metadata || {}).merge(IDENTIFIER_KEY => id) }
|
|
553
|
+
end
|
|
554
|
+
|
|
293
555
|
# Cap interpolated response bodies so misconfigured Qdrant responses
|
|
294
556
|
# (e.g. proxied HTML error pages) don't unbounded-leak into logs or
|
|
295
557
|
# re-raised error messages.
|
|
@@ -341,22 +603,70 @@ module Woods
|
|
|
341
603
|
# @raise [Woods::Error] if the API returns a non-success status
|
|
342
604
|
def request(method, path, body = nil)
|
|
343
605
|
req = build_request(method, path, body)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
606
|
+
attempt = 0
|
|
607
|
+
|
|
608
|
+
begin
|
|
609
|
+
attempt += 1
|
|
610
|
+
response = http_client.request(req)
|
|
611
|
+
parse_response(response, path)
|
|
612
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
613
|
+
discard_http_client
|
|
614
|
+
raise RequestError, "Qdrant TLS error: #{e.message}"
|
|
615
|
+
rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
|
|
616
|
+
discard_http_client
|
|
617
|
+
retry if attempt == 1
|
|
618
|
+
|
|
619
|
+
raise transport_error(e, ambiguous: false)
|
|
620
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::WriteTimeout, IOError => e
|
|
621
|
+
discard_http_client
|
|
622
|
+
retry if attempt == 1 && !write_request?(method, path)
|
|
623
|
+
|
|
624
|
+
raise transport_error(e, ambiguous: write_request?(method, path))
|
|
348
625
|
end
|
|
626
|
+
end
|
|
349
627
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
# Connection dropped — reset and retry once
|
|
353
|
-
@http_client = nil
|
|
354
|
-
response = http_client.request(req)
|
|
355
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
356
|
-
raise Woods::Error, "Qdrant API error: #{response.code} #{truncate_response_body(response.body)}"
|
|
357
|
-
end
|
|
628
|
+
def parse_response(response, path)
|
|
629
|
+
raise response_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
358
630
|
|
|
359
631
|
JSON.parse(response.body)
|
|
632
|
+
rescue JSON::ParserError => e
|
|
633
|
+
raise RequestError.new(
|
|
634
|
+
"Qdrant API returned malformed JSON for #{path}: #{e.message}",
|
|
635
|
+
http_status: response.code.to_i
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def response_error(response)
|
|
640
|
+
status = response.code.to_i
|
|
641
|
+
RequestError.new(
|
|
642
|
+
"Qdrant API error: #{status} #{truncate_response_body(response.body)}",
|
|
643
|
+
http_status: status,
|
|
644
|
+
retry_after: response['Retry-After'],
|
|
645
|
+
retryable: status == 408 || status == 429 || status >= 500
|
|
646
|
+
)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def transport_error(error, ambiguous:)
|
|
650
|
+
RequestError.new(
|
|
651
|
+
"Qdrant transport error: #{error.class}: #{error.message}",
|
|
652
|
+
retryable: true,
|
|
653
|
+
ambiguous: ambiguous
|
|
654
|
+
)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Only GET and read-only POST (search/count/scroll) retry a
|
|
658
|
+
# mid-exchange network failure — every other verb, and every other
|
|
659
|
+
# POST (upsert, delete), may already have committed server-side, so
|
|
660
|
+
# a blind retry risks double-applying it.
|
|
661
|
+
def write_request?(method, path)
|
|
662
|
+
return false if method == :get
|
|
663
|
+
return false if method == :post && read_only_post?(path)
|
|
664
|
+
|
|
665
|
+
true
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def read_only_post?(path)
|
|
669
|
+
%w[/search /count /scroll].any? { |suffix| path.split('?').first.end_with?(suffix) }
|
|
360
670
|
end
|
|
361
671
|
|
|
362
672
|
# Return a reusable, started HTTP client for the Qdrant server.
|
|
@@ -376,6 +686,22 @@ module Woods
|
|
|
376
686
|
@http_client = http
|
|
377
687
|
end
|
|
378
688
|
|
|
689
|
+
# Close a persistent connection before dropping the reference. Nil-ing
|
|
690
|
+
# {#http_client} alone abandons the open socket — the descriptor stays
|
|
691
|
+
# allocated until GC finalizes the object — so finish it first. Finish
|
|
692
|
+
# raises IOError on a session that was never started, and a connection
|
|
693
|
+
# that died mid-request can refuse to close cleanly; the rescue keeps
|
|
694
|
+
# the discard best-effort and only guarantees the reference is dropped.
|
|
695
|
+
#
|
|
696
|
+
# @return [void]
|
|
697
|
+
def discard_http_client
|
|
698
|
+
@http_client&.finish
|
|
699
|
+
rescue StandardError
|
|
700
|
+
nil
|
|
701
|
+
ensure
|
|
702
|
+
@http_client = nil
|
|
703
|
+
end
|
|
704
|
+
|
|
379
705
|
# Build an HTTP request with headers and body.
|
|
380
706
|
#
|
|
381
707
|
# @param method [Symbol] HTTP method
|