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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../embedding/provider'
|
|
4
|
+
require_relative '../retry_after'
|
|
4
5
|
require_relative 'circuit_breaker'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
@@ -26,6 +27,17 @@ module Woods
|
|
|
26
27
|
class RetryableProvider
|
|
27
28
|
include Woods::Embedding::Provider::Interface
|
|
28
29
|
|
|
30
|
+
# @return [#embed, #embed_batch] the wrapped (raw) embedding provider.
|
|
31
|
+
# Exposed so builders can reach the concrete class for tokenizer
|
|
32
|
+
# calibration (see Builder#chars_per_token_for) without unwinding
|
|
33
|
+
# the resilience stack by hand.
|
|
34
|
+
attr_reader :provider
|
|
35
|
+
|
|
36
|
+
# @return [CircuitBreaker, nil] the breaker guarding this provider's
|
|
37
|
+
# calls, or nil when none was configured. Per-instance by design —
|
|
38
|
+
# never share one breaker across unrelated components.
|
|
39
|
+
attr_reader :circuit_breaker
|
|
40
|
+
|
|
29
41
|
# @param provider [#embed, #embed_batch, #dimensions, #model_name] The underlying embedding provider
|
|
30
42
|
# @param max_retries [Integer] Maximum number of retry attempts
|
|
31
43
|
# @param circuit_breaker [CircuitBreaker, nil] Optional circuit breaker instance
|
|
@@ -71,13 +83,20 @@ module Woods
|
|
|
71
83
|
|
|
72
84
|
# Delegate the per-provider input cap. The retry wrapper does not
|
|
73
85
|
# change the provider's budget, so just hand through whatever the
|
|
74
|
-
# inner provider reports.
|
|
75
|
-
#
|
|
86
|
+
# inner provider reports. `respond_to?` alone is the wrong guard
|
|
87
|
+
# here: {Woods::Embedding::Provider::Interface} *defines*
|
|
88
|
+
# +max_input_tokens+ as a +NotImplementedError+ stub, so a provider
|
|
89
|
+
# that merely includes the interface without overriding it still
|
|
90
|
+
# answers +respond_to?+ with +true+ (B-108) and raises when called.
|
|
91
|
+
# A provider with no such method at all still needs the
|
|
92
|
+
# +respond_to?+ guard to avoid a bare +NoMethodError+.
|
|
76
93
|
#
|
|
77
94
|
# @return [Integer, nil]
|
|
78
95
|
def max_input_tokens
|
|
79
|
-
return
|
|
96
|
+
return nil unless @provider.respond_to?(:max_input_tokens)
|
|
80
97
|
|
|
98
|
+
@provider.max_input_tokens
|
|
99
|
+
rescue NotImplementedError
|
|
81
100
|
nil
|
|
82
101
|
end
|
|
83
102
|
|
|
@@ -90,18 +109,27 @@ module Woods
|
|
|
90
109
|
# `BACKOFF_BASE * 2**attempt` with full jitter applied on top.
|
|
91
110
|
BACKOFF_BASE = 0.1
|
|
92
111
|
|
|
112
|
+
# Upper bound honored for a server-supplied +Retry-After+. Bounds a
|
|
113
|
+
# hostile or misconfigured header (e.g. an HTTP-date hours away) so a
|
|
114
|
+
# single throttled call cannot stall the embed pipeline indefinitely;
|
|
115
|
+
# real rate-limit windows sit well under this.
|
|
116
|
+
MAX_RETRY_AFTER_SECONDS = 120.0
|
|
117
|
+
|
|
93
118
|
private
|
|
94
119
|
|
|
95
120
|
# Execute a block with retry logic, exponential backoff, and jitter.
|
|
96
121
|
#
|
|
97
122
|
# Argument errors surface immediately (non-retryable — they indicate
|
|
98
123
|
# a programming mistake or invalid input, not a transient failure).
|
|
124
|
+
# Errors carrying a non-retryable HTTP status (see {#retryable_error?})
|
|
125
|
+
# also surface immediately — retrying a 401 only delays the real error.
|
|
99
126
|
#
|
|
100
127
|
# @yield The block to execute
|
|
101
128
|
# @return [Object] The return value of the block
|
|
102
129
|
# @raise [CircuitOpenError] immediately without retrying
|
|
103
130
|
# @raise [ArgumentError] immediately without retrying
|
|
104
|
-
# @raise [StandardError] the last error if all retries are exhausted
|
|
131
|
+
# @raise [StandardError] the last error if all retries are exhausted,
|
|
132
|
+
# or the first error when it is classified non-retryable
|
|
105
133
|
def with_retries
|
|
106
134
|
attempt = 0
|
|
107
135
|
begin
|
|
@@ -110,13 +138,50 @@ module Woods
|
|
|
110
138
|
rescue CircuitOpenError, ArgumentError
|
|
111
139
|
raise
|
|
112
140
|
rescue StandardError => e
|
|
113
|
-
raise e if attempt > @max_retries
|
|
141
|
+
raise e if attempt > @max_retries || !retryable_error?(e)
|
|
114
142
|
|
|
115
|
-
sleep(
|
|
143
|
+
sleep(retry_delay(e, attempt))
|
|
116
144
|
retry
|
|
117
145
|
end
|
|
118
146
|
end
|
|
119
147
|
|
|
148
|
+
# Decide whether an error is worth retrying.
|
|
149
|
+
#
|
|
150
|
+
# Errors that expose an HTTP status (duck-typed on +#http_status+ —
|
|
151
|
+
# {Woods::Embedding::Provider::RequestError} is the canonical shape)
|
|
152
|
+
# are classified precisely: 429 (rate limit), 408 (request timeout),
|
|
153
|
+
# and 5xx are transient; every other 4xx is a request or credential
|
|
154
|
+
# problem no retry can fix, so burning the retry budget (and sleeping
|
|
155
|
+
# between attempts) only postpones the real error. Status-less errors
|
|
156
|
+
# — connection resets, socket timeouts, legacy Woods::Error strings —
|
|
157
|
+
# keep the historical retry-everything behavior.
|
|
158
|
+
#
|
|
159
|
+
# @param error [StandardError]
|
|
160
|
+
# @return [Boolean] true when the error should be retried
|
|
161
|
+
def retryable_error?(error)
|
|
162
|
+
status = error.respond_to?(:http_status) ? error.http_status : nil
|
|
163
|
+
return true unless status.is_a?(Integer)
|
|
164
|
+
|
|
165
|
+
status == 429 || status == 408 || status >= 500
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Seconds to sleep before the next retry. A server-supplied
|
|
169
|
+
# +Retry-After+ (parsed via {Woods::RetryAfter}, which handles both
|
|
170
|
+
# the delta-seconds and HTTP-date forms) takes precedence over the
|
|
171
|
+
# local backoff schedule, capped at {MAX_RETRY_AFTER_SECONDS};
|
|
172
|
+
# without one, full-jitter exponential backoff applies.
|
|
173
|
+
#
|
|
174
|
+
# @param error [StandardError] the error that triggered the retry
|
|
175
|
+
# @param attempt [Integer] 1-based attempt counter
|
|
176
|
+
# @return [Float] seconds to sleep before the next retry
|
|
177
|
+
def retry_delay(error, attempt)
|
|
178
|
+
fallback = backoff_seconds(attempt)
|
|
179
|
+
header = error.respond_to?(:retry_after) ? error.retry_after : nil
|
|
180
|
+
return fallback if header.nil?
|
|
181
|
+
|
|
182
|
+
RetryAfter.seconds(header, fallback: fallback, max: MAX_RETRY_AFTER_SECONDS)
|
|
183
|
+
end
|
|
184
|
+
|
|
120
185
|
# Full-jitter exponential backoff with a hard cap. See "Exponential
|
|
121
186
|
# Backoff and Jitter", AWS Architecture Blog (Marc Brooker, 2015):
|
|
122
187
|
# a uniformly random delay in [0, base*2**attempt] de-correlates
|
|
@@ -44,6 +44,9 @@ module Woods
|
|
|
44
44
|
# @return [Hash] Store types — :vector_store, :metadata_store, :graph_store (Symbols)
|
|
45
45
|
attr_reader :stores
|
|
46
46
|
|
|
47
|
+
# @return [Hash] Non-secret options required to reopen durable stores
|
|
48
|
+
attr_reader :store_options
|
|
49
|
+
|
|
47
50
|
# Parse a +woods.json+ hash into a {ResolvedConfig}.
|
|
48
51
|
#
|
|
49
52
|
# @param raw [Hash] Parsed JSON hash (string or symbol keys)
|
|
@@ -58,7 +61,8 @@ module Woods
|
|
|
58
61
|
gem_version: data[:gem_version].to_s,
|
|
59
62
|
created_at: parse_time(data[:created_at]),
|
|
60
63
|
embedding_provider: parse_provider(data[:embedding_provider] || {}),
|
|
61
|
-
stores: parse_stores(data[:stores] || {})
|
|
64
|
+
stores: parse_stores(data[:stores] || {}),
|
|
65
|
+
store_options: parse_store_options(data[:store_options] || {})
|
|
62
66
|
)
|
|
63
67
|
end
|
|
64
68
|
|
|
@@ -82,17 +86,18 @@ module Woods
|
|
|
82
86
|
def self.from_configuration(config, gem_version: nil, provider: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
83
87
|
require_relative 'version'
|
|
84
88
|
|
|
85
|
-
opts = config.embedding_options || {}
|
|
86
|
-
declared_dim = opts[:
|
|
87
|
-
dim =
|
|
89
|
+
opts = (config.embedding_options || {}).transform_keys(&:to_sym)
|
|
90
|
+
declared_dim = opts[:dimensions] || opts[:dimension] || opts[:dims]
|
|
91
|
+
dim = provider.respond_to?(:dimensions) ? provider.dimensions : declared_dim
|
|
92
|
+
model = provider.respond_to?(:model_name) ? provider.model_name : (opts[:model] || config.embedding_model)
|
|
88
93
|
|
|
89
94
|
provider_hash = {
|
|
90
95
|
class: resolve_provider_class(config.embedding_provider),
|
|
91
|
-
model:
|
|
96
|
+
model: model.to_s,
|
|
92
97
|
dimension: dim.to_i,
|
|
93
|
-
host: opts[:host]
|
|
94
|
-
num_ctx: opts[:num_ctx]
|
|
95
|
-
read_timeout: opts[:read_timeout]
|
|
98
|
+
host: opts[:host],
|
|
99
|
+
num_ctx: opts[:num_ctx],
|
|
100
|
+
read_timeout: opts[:read_timeout]
|
|
96
101
|
}.compact
|
|
97
102
|
|
|
98
103
|
new(
|
|
@@ -104,7 +109,8 @@ module Woods
|
|
|
104
109
|
vector_store: config.vector_store,
|
|
105
110
|
metadata_store: config.metadata_store,
|
|
106
111
|
graph_store: config.graph_store
|
|
107
|
-
}
|
|
112
|
+
},
|
|
113
|
+
store_options: durable_store_options(config)
|
|
108
114
|
)
|
|
109
115
|
end
|
|
110
116
|
|
|
@@ -113,12 +119,13 @@ module Woods
|
|
|
113
119
|
# @param created_at [Time]
|
|
114
120
|
# @param embedding_provider [Hash]
|
|
115
121
|
# @param stores [Hash]
|
|
116
|
-
def initialize(schema_version:, gem_version:, created_at:, embedding_provider:, stores:)
|
|
122
|
+
def initialize(schema_version:, gem_version:, created_at:, embedding_provider:, stores:, store_options: {}) # rubocop:disable Metrics/ParameterLists
|
|
117
123
|
@schema_version = schema_version
|
|
118
124
|
@gem_version = gem_version.to_s.freeze
|
|
119
125
|
@created_at = created_at
|
|
120
126
|
@embedding_provider = deep_freeze(embedding_provider)
|
|
121
127
|
@stores = deep_freeze(stores)
|
|
128
|
+
@store_options = deep_freeze(store_options)
|
|
122
129
|
freeze
|
|
123
130
|
end
|
|
124
131
|
|
|
@@ -127,6 +134,20 @@ module Woods
|
|
|
127
134
|
embedding_provider[:dimension].to_i
|
|
128
135
|
end
|
|
129
136
|
|
|
137
|
+
# Model recorded in +embedding_provider[:model]+.
|
|
138
|
+
#
|
|
139
|
+
# {Storage::Snapshotter::Vector.dump} guards its call with
|
|
140
|
+
# +resolved_config.respond_to?(:model_name)+ before writing the WVF1
|
|
141
|
+
# header's model field — that guard passed for every {ResolvedConfig}
|
|
142
|
+
# even with no +#model_name+ defined, so the header's model was always
|
|
143
|
+
# +''+ and no dump could say which model produced it.
|
|
144
|
+
#
|
|
145
|
+
# @return [String, nil] the model name, or nil when absent
|
|
146
|
+
def model_name
|
|
147
|
+
model = embedding_provider[:model].to_s
|
|
148
|
+
model.empty? ? nil : model
|
|
149
|
+
end
|
|
150
|
+
|
|
130
151
|
# Short string identifying this provider configuration, useful for log
|
|
131
152
|
# messages and {ConfigMismatch} error text.
|
|
132
153
|
#
|
|
@@ -175,7 +196,8 @@ module Woods
|
|
|
175
196
|
'gem_version' => gem_version,
|
|
176
197
|
'created_at' => created_at.iso8601,
|
|
177
198
|
'embedding_provider' => embedding_provider.transform_keys(&:to_s),
|
|
178
|
-
'stores' => stores.transform_keys(&:to_s).transform_values(&:to_s)
|
|
199
|
+
'stores' => stores.transform_keys(&:to_s).transform_values(&:to_s),
|
|
200
|
+
'store_options' => stringify_nested_keys(store_options)
|
|
179
201
|
}
|
|
180
202
|
end
|
|
181
203
|
|
|
@@ -186,6 +208,13 @@ module Woods
|
|
|
186
208
|
|
|
187
209
|
private
|
|
188
210
|
|
|
211
|
+
def stringify_nested_keys(value)
|
|
212
|
+
return value.transform_keys(&:to_s).transform_values { |item| stringify_nested_keys(item) } if value.is_a?(Hash)
|
|
213
|
+
return value.map { |item| stringify_nested_keys(item) } if value.is_a?(Array)
|
|
214
|
+
|
|
215
|
+
value
|
|
216
|
+
end
|
|
217
|
+
|
|
189
218
|
# Recursively freeze a Hash and every Hash/Array/String it transitively
|
|
190
219
|
# holds. The previous shallow `.freeze` left nested Hash values mutable
|
|
191
220
|
# — a caller reaching `config.embedding_provider[:options][:foo] = …`
|
|
@@ -279,6 +308,20 @@ module Woods
|
|
|
279
308
|
}
|
|
280
309
|
end
|
|
281
310
|
|
|
311
|
+
DURABLE_VECTOR_OPTION_KEYS = %i[collection dimensions distance allow_private_hosts table schema].freeze
|
|
312
|
+
private_constant :DURABLE_VECTOR_OPTION_KEYS
|
|
313
|
+
|
|
314
|
+
def durable_store_options(config)
|
|
315
|
+
vector = normalize_keys(config.vector_store_options || {}).slice(*DURABLE_VECTOR_OPTION_KEYS)
|
|
316
|
+
vector.empty? ? {} : { vector_store: vector }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def parse_store_options(raw)
|
|
320
|
+
data = normalize_keys(raw)
|
|
321
|
+
vector = normalize_keys(data[:vector_store] || {}).slice(*DURABLE_VECTOR_OPTION_KEYS)
|
|
322
|
+
vector.empty? ? {} : { vector_store: vector }
|
|
323
|
+
end
|
|
324
|
+
|
|
282
325
|
def parse_time(value)
|
|
283
326
|
value ? Time.parse(value.to_s) : Time.now.utc
|
|
284
327
|
end
|
|
@@ -291,6 +334,7 @@ module Woods
|
|
|
291
334
|
case provider
|
|
292
335
|
when :openai then 'Woods::Embedding::Provider::OpenAI'
|
|
293
336
|
when :ollama then 'Woods::Embedding::Provider::Ollama'
|
|
337
|
+
when :fake then 'Woods::Embedding::Provider::Fake'
|
|
294
338
|
when String then provider
|
|
295
339
|
when Class then provider.name
|
|
296
340
|
when nil then ''
|
|
@@ -8,14 +8,12 @@ module Woods
|
|
|
8
8
|
# Transforms ranked search candidates into a token-budgeted context string
|
|
9
9
|
# for LLM consumption.
|
|
10
10
|
#
|
|
11
|
-
# Allocates a fixed token budget
|
|
12
|
-
# - Structural (10%): Always-included codebase overview
|
|
13
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# When framework context is not needed, primary and supporting sections
|
|
18
|
-
# receive the framework allocation proportionally.
|
|
11
|
+
# Allocates a fixed token budget in two passes:
|
|
12
|
+
# - Structural (10% of the total budget): Always-included codebase overview
|
|
13
|
+
# - Everything else splits what's LEFT after structural is carved out
|
|
14
|
+
# ({#compute_section_budgets}), not the total budget directly:
|
|
15
|
+
# - Framework context active: Primary 55%, Supporting 25%, Framework 20%
|
|
16
|
+
# - Framework context inactive: Primary 65%, Supporting 35%, Framework 0%
|
|
19
17
|
#
|
|
20
18
|
# @example
|
|
21
19
|
# assembler = ContextAssembler.new(metadata_store: store)
|
|
@@ -27,11 +25,30 @@ module Woods
|
|
|
27
25
|
class ContextAssembler
|
|
28
26
|
DEFAULT_BUDGET = 8000 # tokens
|
|
29
27
|
|
|
28
|
+
# Only +:structural+ is read directly ({#add_structural_section}) — it
|
|
29
|
+
# is a fraction of the TOTAL budget. The other sections split what
|
|
30
|
+
# remains after structural is carved out and have two different
|
|
31
|
+
# fraction sets depending on +classification.framework_context+, which
|
|
32
|
+
# a single flat hash can't express; see {FRAMEWORK_ACTIVE_ALLOCATION}
|
|
33
|
+
# and {FRAMEWORK_INACTIVE_ALLOCATION}.
|
|
30
34
|
BUDGET_ALLOCATION = {
|
|
31
|
-
structural: 0.10
|
|
32
|
-
|
|
35
|
+
structural: 0.10
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Fractions of the REMAINING (post-structural) budget when the query
|
|
39
|
+
# has framework context. Read by {#compute_section_budgets}.
|
|
40
|
+
FRAMEWORK_ACTIVE_ALLOCATION = {
|
|
41
|
+
primary: 0.55,
|
|
33
42
|
supporting: 0.25,
|
|
34
|
-
framework: 0.
|
|
43
|
+
framework: 0.20
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Fractions of the REMAINING (post-structural) budget when the query
|
|
47
|
+
# has no framework context — the framework section gets nothing, and
|
|
48
|
+
# its share isn't proportionally added to primary/supporting.
|
|
49
|
+
FRAMEWORK_INACTIVE_ALLOCATION = {
|
|
50
|
+
primary: 0.65,
|
|
51
|
+
supporting: 0.35
|
|
35
52
|
}.freeze
|
|
36
53
|
|
|
37
54
|
# Minimum token count for a section to be worth including.
|
|
@@ -91,6 +108,7 @@ module Woods
|
|
|
91
108
|
sections = []
|
|
92
109
|
sources = []
|
|
93
110
|
tokens_used = 0
|
|
111
|
+
@skipped_missing_metadata = 0
|
|
94
112
|
|
|
95
113
|
# Collapse +User#chunk_0+, +User#chunk_1+, … back to their base unit
|
|
96
114
|
# BEFORE metadata lookup and section assembly. Chunk IDs are an
|
|
@@ -109,16 +127,9 @@ module Woods
|
|
|
109
127
|
budgets = compute_section_budgets(effective_budget - tokens_used, classification)
|
|
110
128
|
|
|
111
129
|
# 3. Primary, supporting, and framework sections
|
|
112
|
-
|
|
113
|
-
candidates.reject { |c| c.source == :graph_expansion }, budgets[:primary])
|
|
114
|
-
add_candidate_section(sections, sources, :supporting,
|
|
115
|
-
candidates.select { |c| c.source == :graph_expansion }, budgets[:supporting])
|
|
116
|
-
if budgets[:framework].positive?
|
|
117
|
-
add_candidate_section(sections, sources, :framework,
|
|
118
|
-
candidates.select { |c| framework_candidate?(c) }, budgets[:framework])
|
|
119
|
-
end
|
|
130
|
+
add_result_sections(sections, sources, candidates, budgets)
|
|
120
131
|
|
|
121
|
-
build_result(sections, sources, effective_budget)
|
|
132
|
+
build_result(sections, sources, effective_budget, @skipped_missing_metadata)
|
|
122
133
|
end
|
|
123
134
|
|
|
124
135
|
private
|
|
@@ -159,7 +170,8 @@ module Woods
|
|
|
159
170
|
identifier: new_identifier,
|
|
160
171
|
score: candidate.score,
|
|
161
172
|
source: candidate.source,
|
|
162
|
-
metadata: candidate.metadata
|
|
173
|
+
metadata: candidate.metadata,
|
|
174
|
+
matched_fields: candidate.matched_fields
|
|
163
175
|
)
|
|
164
176
|
end
|
|
165
177
|
|
|
@@ -175,6 +187,54 @@ module Woods
|
|
|
175
187
|
tokens_used + estimate_tokens(text)
|
|
176
188
|
end
|
|
177
189
|
|
|
190
|
+
# Partition candidates into the primary/supporting/framework sections
|
|
191
|
+
# and append each section's content.
|
|
192
|
+
#
|
|
193
|
+
# The three sections are a strict PARTITION, not overlapping selects.
|
|
194
|
+
# When the framework budget is active, framework-source candidates
|
|
195
|
+
# (rails_source / gem_source) are formatted ONLY in the framework
|
|
196
|
+
# section — previously a vector-sourced rails_source hit landed in
|
|
197
|
+
# primary AND framework, spending both budgets on the same source
|
|
198
|
+
# text (#186 / B-074). When the framework budget is zero (no
|
|
199
|
+
# framework context), framework candidates stay in their natural
|
|
200
|
+
# primary/supporting bucket, preserving the pre-fix behaviour.
|
|
201
|
+
#
|
|
202
|
+
# @param sections [Array<Hash>] Section accumulator (mutated)
|
|
203
|
+
# @param sources [Array<Hash>] Source-attribution accumulator (mutated)
|
|
204
|
+
# @param candidates [Array<Candidate>] Ranked, chunk-collapsed candidates
|
|
205
|
+
# @param budgets [Hash<Symbol, Integer>] Per-section token budgets
|
|
206
|
+
# @return [void]
|
|
207
|
+
def add_result_sections(sections, sources, candidates, budgets)
|
|
208
|
+
framework_active = budgets[:framework].positive?
|
|
209
|
+
framework, local = if framework_active
|
|
210
|
+
candidates.partition { |c| framework_candidate?(c) }
|
|
211
|
+
else
|
|
212
|
+
[[], candidates]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
supporting_candidates, primary_candidates = local.partition { |c| c.source == :graph_expansion }
|
|
216
|
+
budgets = reclaim_empty_supporting_budget(budgets, supporting_candidates)
|
|
217
|
+
|
|
218
|
+
add_candidate_section(sections, sources, :primary, primary_candidates, budgets[:primary])
|
|
219
|
+
add_candidate_section(sections, sources, :supporting, supporting_candidates, budgets[:supporting])
|
|
220
|
+
add_candidate_section(sections, sources, :framework, framework, budgets[:framework]) if framework_active
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The supporting section only ever holds :graph_expansion candidates
|
|
224
|
+
# ({#add_result_sections}), so a query where no graph expansion ran
|
|
225
|
+
# reserves a supporting budget it can never spend. Rolling that
|
|
226
|
+
# reservation into primary before assembly recovers it instead of
|
|
227
|
+
# stranding it.
|
|
228
|
+
#
|
|
229
|
+
# @param budgets [Hash<Symbol, Integer>]
|
|
230
|
+
# @param supporting_candidates [Array<Candidate>]
|
|
231
|
+
# @return [Hash<Symbol, Integer>]
|
|
232
|
+
def reclaim_empty_supporting_budget(budgets, supporting_candidates)
|
|
233
|
+
return budgets unless supporting_candidates.empty?
|
|
234
|
+
|
|
235
|
+
budgets.merge(primary: budgets[:primary] + budgets[:supporting], supporting: 0)
|
|
236
|
+
end
|
|
237
|
+
|
|
178
238
|
# Add a candidate-based section if candidates produce content.
|
|
179
239
|
#
|
|
180
240
|
# @return [void]
|
|
@@ -188,24 +248,18 @@ module Woods
|
|
|
188
248
|
sources.concat(section_sources)
|
|
189
249
|
end
|
|
190
250
|
|
|
191
|
-
# Compute token budgets for primary/supporting/framework sections
|
|
251
|
+
# Compute token budgets for primary/supporting/framework sections from
|
|
252
|
+
# {FRAMEWORK_ACTIVE_ALLOCATION} / {FRAMEWORK_INACTIVE_ALLOCATION}.
|
|
192
253
|
#
|
|
193
254
|
# @param remaining [Integer] Tokens available after structural
|
|
194
255
|
# @param classification [QueryClassifier::Classification]
|
|
195
256
|
# @return [Hash<Symbol, Integer>]
|
|
196
257
|
def compute_section_budgets(remaining, classification)
|
|
197
258
|
if classification.framework_context
|
|
198
|
-
{
|
|
199
|
-
primary: (remaining * 0.55).to_i,
|
|
200
|
-
supporting: (remaining * 0.25).to_i,
|
|
201
|
-
framework: (remaining * 0.20).to_i
|
|
202
|
-
}
|
|
259
|
+
FRAMEWORK_ACTIVE_ALLOCATION.transform_values { |fraction| (remaining * fraction).to_i }
|
|
203
260
|
else
|
|
204
|
-
{
|
|
205
|
-
|
|
206
|
-
supporting: (remaining * 0.35).to_i,
|
|
207
|
-
framework: 0
|
|
208
|
-
}
|
|
261
|
+
FRAMEWORK_INACTIVE_ALLOCATION.transform_values { |fraction| (remaining * fraction).to_i }
|
|
262
|
+
.merge(framework: 0)
|
|
209
263
|
end
|
|
210
264
|
end
|
|
211
265
|
|
|
@@ -230,7 +284,14 @@ module Woods
|
|
|
230
284
|
# Append a single candidate to the section. Returns updated tokens_used, or nil to stop.
|
|
231
285
|
def append_candidate(parts, sources, candidate, budget, tokens_used)
|
|
232
286
|
unit = @unit_cache[candidate.identifier]
|
|
233
|
-
|
|
287
|
+
unless unit
|
|
288
|
+
# Vector search matched an identifier the metadata store no longer
|
|
289
|
+
# has (a stale vector for a deleted/renamed unit). Counted so
|
|
290
|
+
# callers can distinguish "nothing matched" from "matches existed
|
|
291
|
+
# but the index is stale" instead of a silent empty section.
|
|
292
|
+
@skipped_missing_metadata += 1
|
|
293
|
+
return tokens_used
|
|
294
|
+
end
|
|
234
295
|
|
|
235
296
|
text = format_unit(unit, candidate)
|
|
236
297
|
tokens = estimate_tokens(text)
|
|
@@ -271,7 +332,7 @@ module Woods
|
|
|
271
332
|
# @return [Hash]
|
|
272
333
|
def build_source_attribution(candidate, unit, truncated: false)
|
|
273
334
|
attribution = {
|
|
274
|
-
identifier: candidate.identifier,
|
|
335
|
+
identifier: unit_field(unit, :identifier) || candidate.identifier,
|
|
275
336
|
type: unit_field(unit, :type),
|
|
276
337
|
score: candidate.score,
|
|
277
338
|
file_path: unit_field(unit, :file_path)
|
|
@@ -291,14 +352,36 @@ module Woods
|
|
|
291
352
|
|
|
292
353
|
# Check if a candidate is framework source.
|
|
293
354
|
#
|
|
355
|
+
# Reads +candidate.metadata+ first, then falls back to the assembled
|
|
356
|
+
# +@unit_cache+ entry. A real graph-expansion candidate always carries
|
|
357
|
+
# +metadata: {}+ (see {SearchExecutor#execute_graph}), so relying on
|
|
358
|
+
# +candidate.metadata+ alone silently misses every graph-expanded
|
|
359
|
+
# framework unit — they'd never partition out of primary/supporting
|
|
360
|
+
# into the framework section.
|
|
361
|
+
#
|
|
294
362
|
# @param candidate [Candidate]
|
|
295
363
|
# @return [Boolean]
|
|
296
364
|
def framework_candidate?(candidate)
|
|
365
|
+
type = type_from_candidate_metadata(candidate) || type_from_unit_cache(candidate)
|
|
366
|
+
%w[rails_source gem_source].include?(type.to_s)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# @param candidate [Candidate]
|
|
370
|
+
# @return [String, Symbol, nil]
|
|
371
|
+
def type_from_candidate_metadata(candidate)
|
|
297
372
|
metadata = candidate.metadata
|
|
298
|
-
return
|
|
373
|
+
return nil unless metadata
|
|
299
374
|
|
|
300
|
-
|
|
301
|
-
|
|
375
|
+
metadata[:type] || metadata['type']
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# @param candidate [Candidate]
|
|
379
|
+
# @return [String, Symbol, nil]
|
|
380
|
+
def type_from_unit_cache(candidate)
|
|
381
|
+
unit = @unit_cache[candidate.identifier]
|
|
382
|
+
return nil unless unit
|
|
383
|
+
|
|
384
|
+
unit_field(unit, :type)
|
|
302
385
|
end
|
|
303
386
|
|
|
304
387
|
# Truncate text to fit within a token budget.
|
|
@@ -349,20 +432,29 @@ module Woods
|
|
|
349
432
|
# @param sections [Array<Hash>] Assembled sections
|
|
350
433
|
# @param sources [Array<Hash>] Source attributions
|
|
351
434
|
# @param effective_budget [Integer] The budget actually used for assembly
|
|
435
|
+
# @param skipped_missing_metadata [Integer] Candidates dropped because the
|
|
436
|
+
# metadata store had no record for their identifier (stale vector)
|
|
352
437
|
# @return [AssembledContext]
|
|
353
|
-
def build_result(sections, sources, effective_budget)
|
|
438
|
+
def build_result(sections, sources, effective_budget, skipped_missing_metadata)
|
|
354
439
|
context = sections.map { |s| s[:content] }.join("\n\n---\n\n")
|
|
355
440
|
AssembledContext.new(
|
|
356
441
|
context: context,
|
|
357
442
|
tokens_used: estimate_tokens(context),
|
|
358
443
|
budget: effective_budget,
|
|
359
444
|
sources: sources.uniq,
|
|
360
|
-
sections: sections.map { |s| s[:section] }
|
|
445
|
+
sections: sections.map { |s| s[:section] },
|
|
446
|
+
skipped_missing_metadata: skipped_missing_metadata
|
|
361
447
|
)
|
|
362
448
|
end
|
|
363
449
|
end
|
|
364
450
|
|
|
365
451
|
# Result of context assembly.
|
|
366
|
-
|
|
452
|
+
#
|
|
453
|
+
# +skipped_missing_metadata+ counts candidates whose identifier had no
|
|
454
|
+
# entry in the metadata store — a stale vector left behind by a deleted
|
|
455
|
+
# or renamed unit. A nonzero count alongside empty +sources+ means the
|
|
456
|
+
# index needs re-embedding, not that the query had no matches.
|
|
457
|
+
AssembledContext = Struct.new(:context, :tokens_used, :budget, :sources, :sections,
|
|
458
|
+
:skipped_missing_metadata, keyword_init: true)
|
|
367
459
|
end
|
|
368
460
|
end
|
|
@@ -25,14 +25,17 @@ module Woods
|
|
|
25
25
|
|
|
26
26
|
# Intent patterns — order matters (first match wins)
|
|
27
27
|
INTENT_PATTERNS = {
|
|
28
|
-
locate:
|
|
28
|
+
# :trace is checked BEFORE :locate: a query like "find who calls X"
|
|
29
|
+
# matches both patterns ("find" vs "who calls"), and the tracing
|
|
30
|
+
# intent is the one the agent means. :locate first misrouted these
|
|
31
|
+
# to keyword/direct location handling, skipping graph-based tracing.
|
|
29
32
|
trace: /\b(trace|follow|track|call(s|ed by)|depends on|used by|who calls|what calls)\b/i,
|
|
33
|
+
locate: /\b(where|find|which file|locate|look for|search for)\b/i,
|
|
30
34
|
debug: /\b(bug|error|fix|broken|failing|wrong|issue|problem|crash|exception)\b/i,
|
|
31
35
|
implement: /\b(implement|add|create|build|write|make|generate)\b/i,
|
|
32
36
|
compare: /\b(compare|difference|vs|versus|between|contrast)\b/i,
|
|
33
|
-
# rubocop:disable Layout/LineLength
|
|
37
|
+
# rubocop:disable-next Layout/LineLength
|
|
34
38
|
framework: /\b(how does rails|what does rails|rails .+ work|work.+\brails\b|in rails\b|activerecord|actioncontroller|activejob|actionmailer|actioncable|actiontext|activestorage|solid_queue|solid_cache|solid_cable|kamal|propshaft|importmap|hotwire|turbo|stimulus|zeitwerk)\b/i,
|
|
35
|
-
# rubocop:enable Layout/LineLength
|
|
36
39
|
reference: /\b(show me|what is|what are|list|options for|api|interface|signature)\b/i,
|
|
37
40
|
understand: /\b(how|why|explain|understand|what happens|describe|overview)\b/i
|
|
38
41
|
}.freeze
|
|
@@ -44,16 +47,31 @@ module Woods
|
|
|
44
47
|
exploratory: /\b(related|around|near|similar|like|associated)\b/i
|
|
45
48
|
}.freeze
|
|
46
49
|
|
|
47
|
-
# Target type patterns
|
|
50
|
+
# Target type patterns.
|
|
51
|
+
#
|
|
52
|
+
# Deliberately conservative (#184): +target_type+ is a heuristic that
|
|
53
|
+
# feeds the Ranker's soft +type_match+ signal, so every word here
|
|
54
|
+
# nudges the ranking of any query that contains it. Ordinary
|
|
55
|
+
# conversational words — bare HTTP verbs (get/post/put/patch/delete),
|
|
56
|
+
# "query", "type", "field", "email", "perform" — are not type signals
|
|
57
|
+
# on their own; they only count with disambiguating context (an
|
|
58
|
+
# uppercase HTTP verb, "notification email", "perform_later"). Add
|
|
59
|
+
# new words only when they are genuinely discriminative.
|
|
48
60
|
TARGET_PATTERNS = {
|
|
49
61
|
model: /\b(model|activerecord|association|schema|table|column|scope|validation)\b/i,
|
|
50
62
|
controller: /\b(controller|action|route|endpoint|api|request|response|filter|callback)\b/i,
|
|
51
63
|
service: /\b(service|interactor|operation|command|use.?case|business.?logic)\b/i,
|
|
52
|
-
job: /\b(job|worker|background|async|sidekiq|queue|perform)\b/i,
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
job: /\b(job|worker|background|async|sidekiq|queue|perform.?(later|now|async))\b/i,
|
|
65
|
+
# rubocop:disable-next Layout/LineLength
|
|
66
|
+
mailer: /\b(mailer|mail|send.?mail|deliver.?(later|now)|notification\s+email|email\s+(template|notification|deliver\w*)|send\w*\s+(an\s+)?email)\b/i,
|
|
67
|
+
graphql: /\b(graphql|mutation|resolver)\b/i,
|
|
55
68
|
concern: /\b(concern|mixin|module|included|extend)\b/i,
|
|
56
|
-
|
|
69
|
+
# Lowercase get/post/put/patch/delete are ordinary verbs ("how do we
|
|
70
|
+
# get the current user?") — only the unambiguous uppercase HTTP form
|
|
71
|
+
# counts. Verb-plus-context phrases ("GET request", "delete
|
|
72
|
+
# endpoint") already classify :controller via that pattern's
|
|
73
|
+
# request/endpoint/route words, which run first.
|
|
74
|
+
route: Regexp.union(/\b(route|path|url|uri|http)\b/i, /\b(GET|POST|PUT|PATCH|DELETE)\b/),
|
|
57
75
|
middleware: /\b(middleware|rack|request.?pipeline|before.?action)\b/i,
|
|
58
76
|
i18n: /\b(i18n|translation|locale|internationalization|t\(|translate)\b/i,
|
|
59
77
|
pundit_policy: /\b(pundit|authorize|policy|allowed|permitted)\b/i,
|