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/atomic_file.rb
CHANGED
|
@@ -19,10 +19,36 @@ module Woods
|
|
|
19
19
|
|
|
20
20
|
# Atomically write +content+ to +path+, creating parent directories.
|
|
21
21
|
#
|
|
22
|
+
# Permissions are explicit per artifact (O1): the default is the
|
|
23
|
+
# restrictive 0600 Tempfile already uses, and only an artifact with a
|
|
24
|
+
# documented cross-boundary consumer passes a wider mode (today exactly
|
|
25
|
+
# one: the watch daemon's +watch_status.json+, read by host-side hooks
|
|
26
|
+
# through a bind mount).
|
|
27
|
+
#
|
|
28
|
+
# +durable:+ chooses when the bytes are forced to the disk, never whether
|
|
29
|
+
# the write is atomic. Both paths go tempfile, chmod, rename, so a reader
|
|
30
|
+
# sees the old content or the new content and never a torn partial.
|
|
31
|
+
# +durable: false+ drops the two forced flushes (the temp file's own
|
|
32
|
+
# +fsync+ and the containing directory's), which is the whole cost of the
|
|
33
|
+
# write on a journalling filesystem: roughly 8.9ms per file against
|
|
34
|
+
# 0.11ms without, measured on btrfs.
|
|
35
|
+
#
|
|
36
|
+
# It is only safe for a file with no reader until something else commits
|
|
37
|
+
# it. Woods' payload files qualify: every reader resolves through
|
|
38
|
+
# +generation.json+, and that pointer is written durably after
|
|
39
|
+
# {.sync_directory_tree} has flushed the payload it names. A file whose
|
|
40
|
+
# readers do not go through the pointer (the watch daemon's status, the
|
|
41
|
+
# update check's cache, an export, an embedding checkpoint) must stay
|
|
42
|
+
# durable.
|
|
43
|
+
#
|
|
22
44
|
# @param path [String, Pathname] destination path
|
|
23
45
|
# @param content [String] file content
|
|
46
|
+
# @param mode [Integer] permissions for the written file (default 0600)
|
|
47
|
+
# @param durable [Boolean] force the bytes to disk before returning
|
|
48
|
+
# (default true). Pass false only for a file that something else makes
|
|
49
|
+
# durable before any reader can resolve it.
|
|
24
50
|
# @return [void]
|
|
25
|
-
def write(path, content)
|
|
51
|
+
def write(path, content, mode: 0o600, durable: true)
|
|
26
52
|
path = path.to_s
|
|
27
53
|
FileUtils.mkdir_p(File.dirname(path))
|
|
28
54
|
tmp = Tempfile.new('.woods-', File.dirname(path))
|
|
@@ -31,13 +57,156 @@ module Woods
|
|
|
31
57
|
tmp.binmode
|
|
32
58
|
tmp.write(content)
|
|
33
59
|
tmp.flush
|
|
34
|
-
tmp.fsync
|
|
60
|
+
tmp.fsync if durable
|
|
35
61
|
tmp.close
|
|
62
|
+
# Chmod the temp file so the destination is born with its final
|
|
63
|
+
# permissions — never observed more open or more closed in between.
|
|
64
|
+
File.chmod(mode, tmp.path)
|
|
36
65
|
File.rename(tmp.path, path)
|
|
66
|
+
fsync_directory(File.dirname(path)) if durable
|
|
37
67
|
rescue StandardError
|
|
38
68
|
tmp&.close
|
|
39
69
|
tmp&.unlink
|
|
40
70
|
raise
|
|
41
71
|
end
|
|
72
|
+
|
|
73
|
+
# Make every file under +directory+ durable with one filesystem flush.
|
|
74
|
+
#
|
|
75
|
+
# The counterpart to {.write}'s +durable: false+. Thousands of per-file
|
|
76
|
+
# +fsync+ calls and one +syncfs+ buy the same guarantee for a payload that
|
|
77
|
+
# is published all at once, and cost 71.3s against 1.0s for 8000 files on
|
|
78
|
+
# btrfs.
|
|
79
|
+
#
|
|
80
|
+
# Strategies, in order, first one that works wins:
|
|
81
|
+
#
|
|
82
|
+
# 1. +syncfs(2)+ on a descriptor for +directory+, through Fiddle. Linux
|
|
83
|
+
# only, and flushes exactly the one filesystem the payload is on.
|
|
84
|
+
# 2. +sync -f <dir>+ (GNU coreutils), the same call through a subprocess.
|
|
85
|
+
# 3. +sync+ with no arguments (BSD/macOS), which flushes everything
|
|
86
|
+
# mounted rather than one filesystem, but is still one call.
|
|
87
|
+
# 4. an +fsync+ on every file and directory in the tree.
|
|
88
|
+
#
|
|
89
|
+
# The last resort is what keeps the guarantee honest: the chain never
|
|
90
|
+
# silently does nothing, it only ever gets slower.
|
|
91
|
+
#
|
|
92
|
+
# Fiddle is a default gem through Ruby 3.4 and a bundled gem from 3.5, so
|
|
93
|
+
# the require lives inside a rescue and Fiddle is deliberately not in the
|
|
94
|
+
# gemspec. A host without it lands on +sync -f+.
|
|
95
|
+
#
|
|
96
|
+
# @param directory [String, Pathname] the tree to flush
|
|
97
|
+
# @return [Symbol, nil] the strategy that ran (+:syncfs+, +:sync_f+,
|
|
98
|
+
# +:sync+, +:fsync_pass+), or nil when the directory does not exist
|
|
99
|
+
def sync_directory_tree(directory)
|
|
100
|
+
directory = directory.to_s
|
|
101
|
+
return nil unless File.directory?(directory)
|
|
102
|
+
|
|
103
|
+
strategy = syncfs(directory) || sync_f(directory) || plain_sync || fsync_pass(directory)
|
|
104
|
+
log_sync_strategy(strategy, directory)
|
|
105
|
+
strategy
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol, nil] +:syncfs+ when the libc call succeeded
|
|
109
|
+
def syncfs(directory)
|
|
110
|
+
call = syncfs_function
|
|
111
|
+
return nil unless call
|
|
112
|
+
|
|
113
|
+
succeeded = File.open(directory, File::RDONLY) { |dir| call.call(dir.fileno).zero? }
|
|
114
|
+
succeeded ? :syncfs : nil
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolved once per process, and nil for the whole process when it cannot
|
|
120
|
+
# be. `dlopen` is not free, and a failed `require 'fiddle'` prints a
|
|
121
|
+
# bundled-gem warning on every attempt from Ruby 3.5 onward, and a publish
|
|
122
|
+
# must not emit one line of noise per generation.
|
|
123
|
+
#
|
|
124
|
+
# @return [Fiddle::Function, nil]
|
|
125
|
+
def syncfs_function
|
|
126
|
+
return @syncfs_function if defined?(@syncfs_function)
|
|
127
|
+
|
|
128
|
+
@syncfs_function = begin
|
|
129
|
+
require 'fiddle'
|
|
130
|
+
Fiddle::Function.new(Fiddle.dlopen(nil)['syncfs'], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
131
|
+
rescue LoadError, StandardError
|
|
132
|
+
# No Fiddle in the bundle, no libc symbol (macOS has none), or the
|
|
133
|
+
# handle would not open. `sync -f` is next in the chain.
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Symbol, nil] +:sync_f+ when `sync -f` exited 0
|
|
139
|
+
def sync_f(directory)
|
|
140
|
+
:sync_f if system('sync', '-f', directory, out: File::NULL, err: File::NULL)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Symbol, nil] +:sync+ when a bare `sync` exited 0
|
|
146
|
+
def plain_sync
|
|
147
|
+
:sync if system('sync', out: File::NULL, err: File::NULL)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Symbol] always +:fsync_pass+; this is the floor of the chain
|
|
153
|
+
def fsync_pass(directory)
|
|
154
|
+
Dir.glob(File.join(directory, '**', '*'), File::FNM_DOTMATCH).each do |entry|
|
|
155
|
+
next if %w[. ..].include?(File.basename(entry))
|
|
156
|
+
|
|
157
|
+
fsync_path(entry)
|
|
158
|
+
end
|
|
159
|
+
fsync_path(directory)
|
|
160
|
+
# The directory entry that names this tree lives in the parent, so a
|
|
161
|
+
# freshly created (or renamed) payload directory is only durable once
|
|
162
|
+
# the parent is flushed too. The three faster strategies flush a whole
|
|
163
|
+
# filesystem and cover this for free; only the scoped pass has to say
|
|
164
|
+
# it.
|
|
165
|
+
fsync_path(File.dirname(directory))
|
|
166
|
+
:fsync_pass
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param path [String] file or directory to flush
|
|
170
|
+
# @return [void]
|
|
171
|
+
def fsync_path(path)
|
|
172
|
+
File.open(path, File::RDONLY, &:fsync)
|
|
173
|
+
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR, Errno::ENOENT, Errno::EACCES
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [void]
|
|
178
|
+
def log_sync_strategy(strategy, directory)
|
|
179
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
180
|
+
|
|
181
|
+
Rails.logger.debug { "[Woods] payload sync via #{strategy} on #{directory}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def fsync_directory(directory)
|
|
185
|
+
File.open(directory, File::RDONLY, &:fsync)
|
|
186
|
+
rescue Errno::EINVAL, Errno::ENOTSUP, Errno::EISDIR
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Read a file Woods wrote, as UTF-8.
|
|
191
|
+
#
|
|
192
|
+
# The counterpart to {.write}, and not a nicety. {.write} goes through
|
|
193
|
+
# `binmode` so the content's bytes land verbatim — but a plain `File.read`
|
|
194
|
+
# tags what comes back with the process's *default external encoding*, and
|
|
195
|
+
# a container with no locale set (`LANG=C`, the default in a plain Docker
|
|
196
|
+
# image — precisely where the watch daemon is documented to run) makes that
|
|
197
|
+
# US-ASCII. Any byte above 0x7F then raises
|
|
198
|
+
# `Encoding::InvalidByteSequenceError` on the first `JSON.parse`.
|
|
199
|
+
#
|
|
200
|
+
# That is not hypothetical for Woods' own artifacts: the daemon writes
|
|
201
|
+
# status reasons containing em dashes, so one ordinary lock contention
|
|
202
|
+
# under `LANG=C` used to break `woods:watch_status`, the hook sync's
|
|
203
|
+
# daemon-deference check and the `woods_status` tool until something
|
|
204
|
+
# rewrote the file with an ASCII-only reason.
|
|
205
|
+
#
|
|
206
|
+
# @param path [String, Pathname] file to read
|
|
207
|
+
# @return [String] UTF-8 content
|
|
208
|
+
def read(path)
|
|
209
|
+
File.read(path.to_s, encoding: Encoding::UTF_8)
|
|
210
|
+
end
|
|
42
211
|
end
|
|
43
212
|
end
|
data/lib/woods/builder.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative 'storage/metadata_store'
|
|
|
8
8
|
require_relative 'storage/graph_store'
|
|
9
9
|
require_relative 'embedding/provider'
|
|
10
10
|
require_relative 'embedding/openai'
|
|
11
|
+
require_relative 'embedding/fake'
|
|
12
|
+
require_relative 'resilience/retryable_provider'
|
|
11
13
|
require_relative 'embedding/text_preparer'
|
|
12
14
|
require_relative 'embedding/token_counter'
|
|
13
15
|
require_relative 'token_utils'
|
|
@@ -69,7 +71,8 @@ module Woods
|
|
|
69
71
|
|
|
70
72
|
# Build a {Configuration} populated with the named preset's adapter types.
|
|
71
73
|
#
|
|
72
|
-
# @param name [Symbol] Preset name — one of :local, :
|
|
74
|
+
# @param name [Symbol] Preset name — one of :local, :shared_filesystem,
|
|
75
|
+
# :postgresql, or :production
|
|
73
76
|
# @return [Configuration] A new Configuration with preset values applied
|
|
74
77
|
# @raise [ArgumentError] if the preset name is not recognized
|
|
75
78
|
def self.preset_config(name)
|
|
@@ -107,13 +110,13 @@ module Woods
|
|
|
107
110
|
# on disk and passes the populated store here.
|
|
108
111
|
# @return [Retriever, Cache::CachedRetriever] A fully wired retriever
|
|
109
112
|
def build_retriever(vector_store: nil, metadata_store: nil, graph_store: nil)
|
|
110
|
-
provider =
|
|
113
|
+
provider = build_resilient_embedding_provider
|
|
111
114
|
cache = build_cache_store
|
|
112
115
|
|
|
113
116
|
provider = wrap_with_embedding_cache(provider, cache) if cache
|
|
114
117
|
|
|
115
118
|
retriever = Retriever.new(
|
|
116
|
-
vector_store: vector_store || build_vector_store,
|
|
119
|
+
vector_store: vector_store || build_vector_store(dimensions: vector_dimensions(provider)),
|
|
117
120
|
metadata_store: metadata_store || build_metadata_store,
|
|
118
121
|
graph_store: graph_store || build_graph_store,
|
|
119
122
|
embedding_provider: provider
|
|
@@ -124,19 +127,56 @@ module Woods
|
|
|
124
127
|
|
|
125
128
|
# Instantiate the vector store adapter specified by the configuration.
|
|
126
129
|
#
|
|
130
|
+
# The :pgvector branch also ensures the adapter's own schema exists —
|
|
131
|
+
# see {#build_pgvector_store}.
|
|
132
|
+
#
|
|
127
133
|
# @return [Storage::VectorStore::Interface] Vector store adapter instance
|
|
128
134
|
# @raise [ArgumentError] if the configured type is not recognized
|
|
129
|
-
|
|
135
|
+
# @raise [Woods::Error] if the pgvector schema cannot be created
|
|
136
|
+
def build_vector_store(dimensions: nil)
|
|
130
137
|
case @config.vector_store
|
|
131
138
|
when :in_memory then Storage::VectorStore::InMemory.new
|
|
132
|
-
when :pgvector then
|
|
133
|
-
when :qdrant then
|
|
139
|
+
when :pgvector then build_pgvector_store(dimensions)
|
|
140
|
+
when :qdrant then build_qdrant_store(dimensions)
|
|
134
141
|
else raise ArgumentError, "Unknown vector_store: #{@config.vector_store}"
|
|
135
142
|
end
|
|
136
143
|
end
|
|
137
144
|
|
|
145
|
+
# The provider's advertised dimension count, when a durable store needs
|
|
146
|
+
# one to size its column or collection.
|
|
147
|
+
#
|
|
148
|
+
# Probed exactly like {#safe_max_input_tokens}: {#provider_object?}'s
|
|
149
|
+
# contract is that only +#embed+/+#embed_batch+ are required of an
|
|
150
|
+
# injected provider and the rest of the interface is checked at each call
|
|
151
|
+
# site, and a provider that merely includes {Embedding::Provider::Interface}
|
|
152
|
+
# answers +respond_to?+ with a +NotImplementedError+ stub (B-108). Either
|
|
153
|
+
# way the answer is "no dimensions", which lets
|
|
154
|
+
# {#resolve_pgvector_dimensions}/{#resolve_qdrant_dimensions} fall back to
|
|
155
|
+
# the explicit +vector_store_options[:dimensions]+ they document (STO-6).
|
|
156
|
+
#
|
|
157
|
+
# @param provider [Embedding::Provider::Interface]
|
|
158
|
+
# @return [Integer, nil]
|
|
159
|
+
def vector_dimensions(provider)
|
|
160
|
+
return nil unless %i[pgvector qdrant].include?(@config.vector_store)
|
|
161
|
+
return nil unless provider.respond_to?(:dimensions)
|
|
162
|
+
|
|
163
|
+
provider.dimensions
|
|
164
|
+
rescue NotImplementedError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
private :vector_dimensions
|
|
168
|
+
|
|
138
169
|
# Instantiate the embedding provider specified by the configuration.
|
|
139
170
|
#
|
|
171
|
+
# `embedding_provider` accepts three shapes (#178):
|
|
172
|
+
# - +:openai+ / +:ollama+ — the network-backed adapters.
|
|
173
|
+
# - +:fake+ — {Embedding::Provider::Fake}: deterministic, offline, for
|
|
174
|
+
# CI/smoke runs. See {#build_fake_provider} for dimension resolution.
|
|
175
|
+
# - an already-constructed provider *object* — anything responding to
|
|
176
|
+
# +#embed+ and +#embed_batch+ is returned as-is, so hosts can plug in
|
|
177
|
+
# their own implementation without patching the Builder. It flows
|
|
178
|
+
# through {#build_resilient_embedding_provider} like the built-ins.
|
|
179
|
+
#
|
|
140
180
|
# Strips `embedding_options` keys that belong to the ResolvedConfig layer
|
|
141
181
|
# (like `:dimension`) before splatting into the provider's constructor —
|
|
142
182
|
# those keys are useful for the Snapshotter's schema header but
|
|
@@ -145,27 +185,148 @@ module Woods
|
|
|
145
185
|
# @return [Embedding::Provider::Interface] Embedding provider instance
|
|
146
186
|
# @raise [ArgumentError] if the configured type is not recognized
|
|
147
187
|
def build_embedding_provider
|
|
148
|
-
|
|
149
|
-
|
|
188
|
+
configured = @config.embedding_provider
|
|
189
|
+
return configured if provider_object?(configured)
|
|
190
|
+
|
|
191
|
+
opts = provider_kwargs(configured)
|
|
192
|
+
case configured
|
|
150
193
|
when :openai then Embedding::Provider::OpenAI.new(**opts)
|
|
151
194
|
when :ollama then Embedding::Provider::Ollama.new(**opts)
|
|
152
|
-
|
|
195
|
+
when :fake then build_fake_provider(opts)
|
|
196
|
+
else
|
|
197
|
+
raise ArgumentError,
|
|
198
|
+
"Unknown embedding_provider: #{configured}. Valid: :openai, :ollama, :fake, " \
|
|
199
|
+
'or a provider object responding to #embed and #embed_batch'
|
|
153
200
|
end
|
|
154
201
|
end
|
|
155
202
|
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
203
|
+
# Wrap an embedding provider in the resilience stack: retry with
|
|
204
|
+
# full-jitter exponential backoff (Retry-After aware) plus a dedicated
|
|
205
|
+
# {Resilience::CircuitBreaker}.
|
|
206
|
+
#
|
|
207
|
+
# This is the provider every pipeline entry point must hand to the
|
|
208
|
+
# Indexer or Retriever — with it, a transient 429/5xx burst degrades a
|
|
209
|
+
# run instead of aborting it (#188 / B-076). {#build_embedding_provider}
|
|
210
|
+
# deliberately keeps returning the *raw* provider: the MCP boot path
|
|
211
|
+
# ({MCP::ProviderProbe}) dispatches on the provider's concrete class and
|
|
212
|
+
# reads its internals, so the wrap happens here, one layer up.
|
|
213
|
+
#
|
|
214
|
+
# Each call constructs a fresh breaker — breaker state is per-instance
|
|
215
|
+
# and must never be shared across unrelated components.
|
|
216
|
+
#
|
|
217
|
+
# The wrap is harmless for providers that never raise transient
|
|
218
|
+
# failures ({Embedding::Provider::Fake}, most injected provider
|
|
219
|
+
# objects): nothing retryable ever fires, so the wrapper is a
|
|
220
|
+
# transparent pass-through.
|
|
221
|
+
#
|
|
222
|
+
# @param provider [Embedding::Provider::Interface] raw provider to wrap;
|
|
223
|
+
# defaults to a freshly built one from the configuration
|
|
224
|
+
# @return [Resilience::RetryableProvider] the wrapped provider
|
|
225
|
+
def build_resilient_embedding_provider(provider = build_embedding_provider)
|
|
226
|
+
Resilience::RetryableProvider.new(
|
|
227
|
+
provider: provider,
|
|
228
|
+
circuit_breaker: Resilience::CircuitBreaker.new
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
PROVIDER_OPTION_KEYS = {
|
|
233
|
+
openai: %i[api_key model dimension dimensions],
|
|
234
|
+
ollama: %i[model host num_ctx read_timeout dimension dimensions],
|
|
235
|
+
fake: %i[model dims dimension dimensions]
|
|
236
|
+
}.freeze
|
|
237
|
+
private_constant :PROVIDER_OPTION_KEYS
|
|
161
238
|
|
|
162
|
-
def provider_kwargs
|
|
239
|
+
def provider_kwargs(configured)
|
|
163
240
|
opts = (@config.embedding_options || {}).transform_keys(&:to_sym)
|
|
164
|
-
|
|
241
|
+
validate_provider_options!(configured, opts)
|
|
242
|
+
apply_embedding_model!(opts)
|
|
243
|
+
normalize_dimension_option!(configured, opts)
|
|
244
|
+
validate_required_provider_options!(configured, opts)
|
|
165
245
|
opts
|
|
166
246
|
end
|
|
167
247
|
private :provider_kwargs
|
|
168
248
|
|
|
249
|
+
def validate_provider_options!(configured, opts)
|
|
250
|
+
allowed = PROVIDER_OPTION_KEYS[configured]
|
|
251
|
+
return unless allowed
|
|
252
|
+
|
|
253
|
+
unknown = opts.keys - allowed
|
|
254
|
+
return if unknown.empty?
|
|
255
|
+
|
|
256
|
+
provider_name = configured.to_s.capitalize
|
|
257
|
+
noun = unknown.one? ? 'option' : 'options'
|
|
258
|
+
raise ConfigurationError,
|
|
259
|
+
"Unknown #{provider_name} embedding #{noun}: #{unknown.join(', ')}. " \
|
|
260
|
+
"Valid options: #{allowed.join(', ')}"
|
|
261
|
+
end
|
|
262
|
+
private :validate_provider_options!
|
|
263
|
+
|
|
264
|
+
def validate_required_provider_options!(configured, opts)
|
|
265
|
+
return unless configured == :openai
|
|
266
|
+
return unless opts[:api_key].nil? || opts[:api_key].to_s.empty?
|
|
267
|
+
|
|
268
|
+
raise ConfigurationError,
|
|
269
|
+
'OpenAI requires embedding_options[:api_key]. Set it explicitly, typically from OPENAI_API_KEY.'
|
|
270
|
+
end
|
|
271
|
+
private :validate_required_provider_options!
|
|
272
|
+
|
|
273
|
+
def apply_embedding_model!(opts)
|
|
274
|
+
return if opts.key?(:model)
|
|
275
|
+
return unless @config.respond_to?(:embedding_model_explicit?) && @config.embedding_model_explicit?
|
|
276
|
+
|
|
277
|
+
opts[:model] = @config.embedding_model
|
|
278
|
+
end
|
|
279
|
+
private :apply_embedding_model!
|
|
280
|
+
|
|
281
|
+
def normalize_dimension_option!(configured, opts)
|
|
282
|
+
legacy_dimension = opts.delete(:dimension)
|
|
283
|
+
dimensions = opts.delete(:dimensions)
|
|
284
|
+
if conflicting_dimensions?(legacy_dimension, dimensions)
|
|
285
|
+
raise ConfigurationError, 'embedding_options dimension and dimensions must match when both are provided'
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
dimension = dimensions || legacy_dimension
|
|
289
|
+
return unless dimension
|
|
290
|
+
|
|
291
|
+
opts[configured == :fake ? :dims : :dimensions] ||= dimension
|
|
292
|
+
end
|
|
293
|
+
private :normalize_dimension_option!
|
|
294
|
+
|
|
295
|
+
def conflicting_dimensions?(legacy_dimension, dimensions)
|
|
296
|
+
!legacy_dimension.nil? && !dimensions.nil? && legacy_dimension != dimensions
|
|
297
|
+
end
|
|
298
|
+
private :conflicting_dimensions?
|
|
299
|
+
|
|
300
|
+
# True when the configured `embedding_provider` is not a Symbol naming a
|
|
301
|
+
# built-in adapter but an already-constructed provider object (#178).
|
|
302
|
+
# Duck-typed on the two methods every pipeline consumer calls; the rest
|
|
303
|
+
# of {Embedding::Provider::Interface} (+#dimensions+, +#model_name+,
|
|
304
|
+
# +#max_input_tokens+) is probed with +respond_to?+ at each call site,
|
|
305
|
+
# so an object that omits them still works where they are optional.
|
|
306
|
+
#
|
|
307
|
+
# @param candidate [Object]
|
|
308
|
+
# @return [Boolean]
|
|
309
|
+
def provider_object?(candidate)
|
|
310
|
+
!candidate.is_a?(Symbol) && candidate.respond_to?(:embed) && candidate.respond_to?(:embed_batch)
|
|
311
|
+
end
|
|
312
|
+
private :provider_object?
|
|
313
|
+
|
|
314
|
+
# Build the deterministic fake provider (#178).
|
|
315
|
+
#
|
|
316
|
+
# Dimension resolution: `embedding_options[:dims]` maps directly onto
|
|
317
|
+
# the {Embedding::Provider::Fake} constructor; failing that, the
|
|
318
|
+
# ResolvedConfig-level `embedding_options[:dimension]` key — normally
|
|
319
|
+
# snapshot-only bookkeeping stripped by {#provider_kwargs} — is
|
|
320
|
+
# honoured, so hosts that declare their dimension there (and the MCP
|
|
321
|
+
# boot path, which restores exactly that key from woods.json) get
|
|
322
|
+
# vectors of the recorded dimension.
|
|
323
|
+
#
|
|
324
|
+
# @return [Embedding::Provider::Fake]
|
|
325
|
+
def build_fake_provider(opts)
|
|
326
|
+
Embedding::Provider::Fake.new(**opts)
|
|
327
|
+
end
|
|
328
|
+
private :build_fake_provider
|
|
329
|
+
|
|
169
330
|
# Build a {Embedding::TextPreparer} calibrated to a given provider.
|
|
170
331
|
#
|
|
171
332
|
# OpenAI embedders use tiktoken (cl100k_base) — 4.0 chars/token is a
|
|
@@ -184,7 +345,7 @@ module Woods
|
|
|
184
345
|
# @return [Embedding::TextPreparer]
|
|
185
346
|
def build_text_preparer(provider)
|
|
186
347
|
chars_per_token = chars_per_token_for(provider)
|
|
187
|
-
budget = provider
|
|
348
|
+
budget = safe_max_input_tokens(provider)
|
|
188
349
|
max_tokens = budget || Embedding::TextPreparer::DEFAULT_MAX_TOKENS
|
|
189
350
|
|
|
190
351
|
Embedding::TextPreparer.new(max_tokens: max_tokens, chars_per_token: chars_per_token)
|
|
@@ -215,7 +376,7 @@ module Woods
|
|
|
215
376
|
# @param provider [Embedding::Provider::Interface]
|
|
216
377
|
# @return [Chunking::SemanticChunker]
|
|
217
378
|
def build_chunker(provider)
|
|
218
|
-
budget = provider
|
|
379
|
+
budget = safe_max_input_tokens(provider)
|
|
219
380
|
max_chars = ((budget * chars_per_token_for(provider)).floor - CHUNKER_PREFIX_ALLOWANCE if budget)
|
|
220
381
|
|
|
221
382
|
# Guard against a budget so small that the prefix allowance leaves
|
|
@@ -256,7 +417,7 @@ module Woods
|
|
|
256
417
|
# @param provider [Embedding::Provider::Interface]
|
|
257
418
|
# @return [Embedding::TokenCounter, nil]
|
|
258
419
|
def token_counter_for(provider)
|
|
259
|
-
return unless provider.is_a?(Embedding::Provider::Ollama)
|
|
420
|
+
return unless unwrap_provider(provider).is_a?(Embedding::Provider::Ollama)
|
|
260
421
|
|
|
261
422
|
Embedding::TokenCounter.new
|
|
262
423
|
end
|
|
@@ -268,16 +429,50 @@ module Woods
|
|
|
268
429
|
# @param provider [Embedding::Provider::Interface]
|
|
269
430
|
# @return [Float]
|
|
270
431
|
def chars_per_token_for(provider)
|
|
271
|
-
symbol = case provider
|
|
432
|
+
symbol = case unwrap_provider(provider)
|
|
272
433
|
when Embedding::Provider::Ollama then :ollama
|
|
273
434
|
else :openai
|
|
274
435
|
end
|
|
275
436
|
TokenUtils.chars_per_token_for(symbol)
|
|
276
437
|
end
|
|
277
438
|
|
|
439
|
+
# Provider input-token budget, or nil when the provider has none.
|
|
440
|
+
# `respond_to?` alone is the wrong guard here: {Embedding::Provider::Interface}
|
|
441
|
+
# *defines* +max_input_tokens+ as a +NotImplementedError+ stub, so a
|
|
442
|
+
# provider that merely includes the interface without overriding it
|
|
443
|
+
# still answers +respond_to?+ with +true+ (B-108) and raises when
|
|
444
|
+
# called. A provider with no such method at all still needs the
|
|
445
|
+
# +respond_to?+ guard to avoid a bare +NoMethodError+.
|
|
446
|
+
#
|
|
447
|
+
# @param provider [Embedding::Provider::Interface]
|
|
448
|
+
# @return [Integer, nil]
|
|
449
|
+
def safe_max_input_tokens(provider)
|
|
450
|
+
return nil unless provider.respond_to?(:max_input_tokens)
|
|
451
|
+
|
|
452
|
+
provider.max_input_tokens
|
|
453
|
+
rescue NotImplementedError
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Reach the concrete provider through the resilience wrapper.
|
|
458
|
+
# Tokenizer calibration dispatches on the provider's real class, so a
|
|
459
|
+
# {Resilience::RetryableProvider} handed to {#build_text_preparer} or
|
|
460
|
+
# {#build_chunker} must calibrate exactly like its inner provider —
|
|
461
|
+
# without this, a wrapped Ollama silently got OpenAI ratios.
|
|
462
|
+
#
|
|
463
|
+
# @param provider [Embedding::Provider::Interface]
|
|
464
|
+
# @return [Embedding::Provider::Interface] the innermost provider
|
|
465
|
+
def unwrap_provider(provider)
|
|
466
|
+
provider.is_a?(Resilience::RetryableProvider) ? provider.provider : provider
|
|
467
|
+
end
|
|
468
|
+
|
|
278
469
|
# Diagnostic for the build_chunker budget guard.
|
|
470
|
+
#
|
|
471
|
+
# @param provider [Embedding::Provider::Interface]
|
|
472
|
+
# @param budget [Integer]
|
|
473
|
+
# @return [String]
|
|
279
474
|
def chunker_budget_message(provider, budget)
|
|
280
|
-
"embedding model '#{provider.respond_to?(:
|
|
475
|
+
"embedding model '#{provider.respond_to?(:model_name) ? provider.model_name : provider.class}' " \
|
|
281
476
|
"reports a max_input_tokens of #{budget}, which leaves no room for " \
|
|
282
477
|
"the chunk prefix (#{CHUNKER_PREFIX_ALLOWANCE} chars). Configure a " \
|
|
283
478
|
'model with a larger native context, or set num_ctx explicitly.'
|
|
@@ -292,11 +487,18 @@ module Woods
|
|
|
292
487
|
def build_metadata_store
|
|
293
488
|
case @config.metadata_store
|
|
294
489
|
when :in_memory then Storage::MetadataStore::InMemory.new
|
|
295
|
-
when :sqlite then Storage::MetadataStore::SQLite.new(**
|
|
490
|
+
when :sqlite then Storage::MetadataStore::SQLite.new(**sqlite_metadata_options)
|
|
296
491
|
else raise ArgumentError, "Unknown metadata_store: #{@config.metadata_store}"
|
|
297
492
|
end
|
|
298
493
|
end
|
|
299
494
|
|
|
495
|
+
def sqlite_metadata_options
|
|
496
|
+
opts = (@config.metadata_store_options || {}).transform_keys(&:to_sym)
|
|
497
|
+
opts[:database] ||= File.join(@config.output_dir.to_s, 'metadata.sqlite3')
|
|
498
|
+
opts
|
|
499
|
+
end
|
|
500
|
+
private :sqlite_metadata_options
|
|
501
|
+
|
|
300
502
|
# Instantiate the graph store adapter specified by the configuration.
|
|
301
503
|
#
|
|
302
504
|
# @return [Storage::GraphStore::Interface] Graph store adapter instance
|
|
@@ -310,6 +512,92 @@ module Woods
|
|
|
310
512
|
|
|
311
513
|
private
|
|
312
514
|
|
|
515
|
+
# Construct the pgvector adapter and ensure its schema exists.
|
|
516
|
+
#
|
|
517
|
+
# The adapter reads and writes its own `woods_vectors` table. The
|
|
518
|
+
# `woods:pgvector` generator can create it via a migration, but nothing
|
|
519
|
+
# guarantees that migration ran — so the builder calls the adapter's
|
|
520
|
+
# idempotent {Storage::VectorStore::Pgvector#ensure_schema!}
|
|
521
|
+
# (CREATE ... IF NOT EXISTS DDL) after construction. Without this, the
|
|
522
|
+
# first embed against a bare database fails with PG::UndefinedTable
|
|
523
|
+
# (#187 / B-075). Schema/connection failures are re-raised as
|
|
524
|
+
# {Woods::Error} with the original error preserved as the cause.
|
|
525
|
+
#
|
|
526
|
+
# @return [Storage::VectorStore::Pgvector]
|
|
527
|
+
# @raise [Woods::Error] when the schema cannot be created
|
|
528
|
+
def build_pgvector_store(provider_dimensions)
|
|
529
|
+
opts = (@config.vector_store_options || {}).transform_keys(&:to_sym)
|
|
530
|
+
validate_required_store_options!(:pgvector, opts, :connection)
|
|
531
|
+
opts[:dimensions] = resolve_pgvector_dimensions(provider_dimensions, opts[:dimensions])
|
|
532
|
+
store = Storage::VectorStore::Pgvector.new(**opts)
|
|
533
|
+
begin
|
|
534
|
+
store.ensure_schema!
|
|
535
|
+
verify_pgvector_dimensions!(store, opts[:dimensions])
|
|
536
|
+
rescue ConfigurationError
|
|
537
|
+
raise
|
|
538
|
+
rescue StandardError => e
|
|
539
|
+
raise Woods::Error,
|
|
540
|
+
"pgvector schema setup failed (#{e.class}: #{e.message}). " \
|
|
541
|
+
'Verify vector_store_options[:connection] is a live PostgreSQL ' \
|
|
542
|
+
'connection and that the pgvector extension is available ' \
|
|
543
|
+
'(`rails generate woods:pgvector && rails db:migrate` sets it up via migration).'
|
|
544
|
+
end
|
|
545
|
+
store
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def verify_pgvector_dimensions!(store, expected)
|
|
549
|
+
actual = store.stored_dimensions
|
|
550
|
+
return if actual.nil? || actual == expected
|
|
551
|
+
|
|
552
|
+
raise ConfigurationError,
|
|
553
|
+
"Stored pgvector dimensions #{actual} do not match embedding provider dimensions #{expected}. " \
|
|
554
|
+
'Use a compatible table or rebuild the index.'
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def resolve_pgvector_dimensions(provider_dimensions, configured_dimensions)
|
|
558
|
+
if provider_dimensions && configured_dimensions && provider_dimensions != configured_dimensions
|
|
559
|
+
raise ConfigurationError,
|
|
560
|
+
"pgvector dimensions #{configured_dimensions} do not match embedding provider dimensions " \
|
|
561
|
+
"#{provider_dimensions}"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
provider_dimensions || configured_dimensions || raise(
|
|
565
|
+
ConfigurationError,
|
|
566
|
+
'pgvector requires vector_store_options[:dimensions] when built without an embedding provider'
|
|
567
|
+
)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def build_qdrant_store(provider_dimensions)
|
|
571
|
+
opts = (@config.vector_store_options || {}).transform_keys(&:to_sym)
|
|
572
|
+
validate_required_store_options!(:qdrant, opts, :url, :collection)
|
|
573
|
+
dimensions = resolve_qdrant_dimensions(provider_dimensions, opts[:dimensions])
|
|
574
|
+
opts[:dimensions] = dimensions
|
|
575
|
+
store = Storage::VectorStore::Qdrant.new(**opts)
|
|
576
|
+
store.ensure_collection!(dimensions: dimensions)
|
|
577
|
+
store
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def validate_required_store_options!(adapter, opts, *keys)
|
|
581
|
+
missing = keys.select { |key| opts[key].nil? || opts[key].to_s.empty? }
|
|
582
|
+
return if missing.empty?
|
|
583
|
+
|
|
584
|
+
requirements = missing.map { |key| "vector_store_options[:#{key}]" }.join(' and ')
|
|
585
|
+
raise ConfigurationError, "#{adapter} requires #{requirements}"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def resolve_qdrant_dimensions(provider_dimensions, configured_dimensions)
|
|
589
|
+
if provider_dimensions && configured_dimensions && provider_dimensions != configured_dimensions
|
|
590
|
+
raise ConfigurationError,
|
|
591
|
+
"Qdrant dimensions #{configured_dimensions} do not match embedding provider dimensions " \
|
|
592
|
+
"#{provider_dimensions}"
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
provider_dimensions || configured_dimensions || raise(
|
|
596
|
+
ConfigurationError,
|
|
597
|
+
'Qdrant requires vector_store_options[:dimensions] when built without an embedding provider'
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
|
|
313
601
|
# Build a cache store from configuration, or nil if caching is disabled.
|
|
314
602
|
#
|
|
315
603
|
# @return [Cache::CacheStore, nil]
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
4
|
require_relative 'cache_store'
|
|
5
|
+
# CachedEmbeddingProvider includes Embedding::Provider::Interface at load
|
|
6
|
+
# time, so the interface must be defined before this file's class bodies run
|
|
7
|
+
# — otherwise a narrow `require 'woods/cache/cache_middleware'` raises
|
|
8
|
+
# NameError (STO-5).
|
|
9
|
+
require_relative '../embedding/provider'
|
|
5
10
|
|
|
6
11
|
module Woods
|
|
7
12
|
module Cache
|
|
@@ -364,8 +369,8 @@ module Woods
|
|
|
364
369
|
# persistent shared backend (Redis/SolidCache) returns the previous
|
|
365
370
|
# model's vector after a model switch or upgrade — different dimensions
|
|
366
371
|
# error mid-batch, same dimensions silently corrupt similarity scores
|
|
367
|
-
# (which
|
|
368
|
-
#
|
|
372
|
+
# (which the provider-vs-store dimension check can't detect, since it
|
|
373
|
+
# compares declared widths, not cache contents).
|
|
369
374
|
#
|
|
370
375
|
# model_name (a plain attribute) is used rather than dimensions on
|
|
371
376
|
# purpose: for every supported provider the model uniquely determines
|
|
@@ -25,7 +25,11 @@ module Woods
|
|
|
25
25
|
# @param parts [Array<String>] Key components (will be SHA256-hashed if long)
|
|
26
26
|
# @return [String] Namespaced key
|
|
27
27
|
def self.cache_key(domain, *parts)
|
|
28
|
-
raw = parts.
|
|
28
|
+
raw = if parts.one?
|
|
29
|
+
parts.first.to_s
|
|
30
|
+
else
|
|
31
|
+
parts.map { |part| "#{part.to_s.bytesize}:#{part}" }.join
|
|
32
|
+
end
|
|
29
33
|
suffix = raw.length > 64 ? Digest::SHA256.hexdigest(raw) : raw
|
|
30
34
|
"woods:cache:#{domain}:#{suffix}"
|
|
31
35
|
end
|
|
@@ -148,6 +152,10 @@ module Woods
|
|
|
148
152
|
# @param max_entries [Integer] Maximum cached entries before LRU eviction
|
|
149
153
|
def initialize(max_entries: 500)
|
|
150
154
|
super()
|
|
155
|
+
unless max_entries.is_a?(Integer) && max_entries.positive?
|
|
156
|
+
raise ArgumentError, 'max_entries must be a positive Integer'
|
|
157
|
+
end
|
|
158
|
+
|
|
151
159
|
@max_entries = max_entries
|
|
152
160
|
@entries = {}
|
|
153
161
|
@access_order = []
|