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,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../storage_identity'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module Retrieval
|
|
5
7
|
# SearchExecutor maps a query classification to a retrieval strategy and
|
|
@@ -26,7 +28,12 @@ module Woods
|
|
|
26
28
|
#
|
|
27
29
|
class SearchExecutor
|
|
28
30
|
# A single search candidate with provenance tracking.
|
|
29
|
-
|
|
31
|
+
#
|
|
32
|
+
# +matched_fields+ is populated only on the keyword path — the list of
|
|
33
|
+
# metadata field names whose values matched a query keyword. It feeds
|
|
34
|
+
# {Ranker#keyword_score} (0.25 per field, capped at 1.0). Defaults to
|
|
35
|
+
# nil for every other source (vector/graph/direct), which scores 0.0.
|
|
36
|
+
Candidate = Struct.new(:identifier, :score, :source, :metadata, :matched_fields, keyword_init: true)
|
|
30
37
|
|
|
31
38
|
# The result of a search execution.
|
|
32
39
|
ExecutionResult = Struct.new(:candidates, :strategy, :query, keyword_init: true)
|
|
@@ -60,7 +67,8 @@ module Woods
|
|
|
60
67
|
# strategies push this down into the vector store's metadata
|
|
61
68
|
# filter — used by {Retriever#retrieve} to rank-within-type when
|
|
62
69
|
# the unfiltered global top-K had no candidate of the requested type.
|
|
63
|
-
#
|
|
70
|
+
# This explicit caller filter is the ONLY source of vector type
|
|
71
|
+
# filters — the classifier's +target_type+ never becomes one (#184).
|
|
64
72
|
# @param strategy [Symbol, nil] Override the classifier-selected strategy.
|
|
65
73
|
# {Retriever#within_type_fallback} passes +:vector+ here because the
|
|
66
74
|
# vector path is the only one that honors +type_filter+; if the
|
|
@@ -79,7 +87,7 @@ module Woods
|
|
|
79
87
|
)
|
|
80
88
|
|
|
81
89
|
ExecutionResult.new(
|
|
82
|
-
candidates: candidates
|
|
90
|
+
candidates: bounded_candidates(expand_graph_candidates(candidates), limit, strategy),
|
|
83
91
|
strategy: strategy,
|
|
84
92
|
query: query
|
|
85
93
|
)
|
|
@@ -87,6 +95,23 @@ module Woods
|
|
|
87
95
|
|
|
88
96
|
private
|
|
89
97
|
|
|
98
|
+
def expand_graph_candidates(candidates)
|
|
99
|
+
candidates.flat_map do |candidate|
|
|
100
|
+
next candidate unless %i[graph graph_expansion].include?(candidate.source)
|
|
101
|
+
next candidate if @metadata_store.find(candidate.identifier)
|
|
102
|
+
|
|
103
|
+
matches = @metadata_store.search(candidate.identifier).select do |record|
|
|
104
|
+
record['identifier'] == candidate.identifier
|
|
105
|
+
end
|
|
106
|
+
next candidate if matches.empty?
|
|
107
|
+
|
|
108
|
+
matches.map do |record|
|
|
109
|
+
Candidate.new(identifier: record['id'], score: candidate.score,
|
|
110
|
+
source: candidate.source, metadata: record)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
90
115
|
# Select the best retrieval strategy for a classification.
|
|
91
116
|
#
|
|
92
117
|
# @param classification [QueryClassifier::Classification]
|
|
@@ -126,7 +151,7 @@ module Woods
|
|
|
126
151
|
def run_strategy(strategy, query:, classification:, limit:, type_filter: nil)
|
|
127
152
|
case strategy
|
|
128
153
|
when :vector
|
|
129
|
-
execute_vector(query,
|
|
154
|
+
execute_vector(query, limit: limit, type_filter: type_filter)
|
|
130
155
|
when :keyword
|
|
131
156
|
execute_keyword(classification: classification, limit: limit)
|
|
132
157
|
when :graph
|
|
@@ -140,10 +165,13 @@ module Woods
|
|
|
140
165
|
|
|
141
166
|
# Vector strategy: embed the query and search by similarity.
|
|
142
167
|
#
|
|
168
|
+
# Takes no classification — the vector path is filtered only by the
|
|
169
|
+
# caller's explicit +type_filter+; see {#build_vector_filters} (#184).
|
|
170
|
+
#
|
|
143
171
|
# @return [Array<Candidate>]
|
|
144
|
-
def execute_vector(query,
|
|
172
|
+
def execute_vector(query, limit:, type_filter: nil)
|
|
145
173
|
query_vector = @embedding_provider.embed(query)
|
|
146
|
-
filters = build_vector_filters(
|
|
174
|
+
filters = build_vector_filters(type_filter)
|
|
147
175
|
|
|
148
176
|
results = @vector_store.search(query_vector, limit: limit, filters: filters)
|
|
149
177
|
results.map do |r|
|
|
@@ -153,8 +181,8 @@ module Woods
|
|
|
153
181
|
|
|
154
182
|
# Keyword strategy: search metadata store by extracted keywords.
|
|
155
183
|
#
|
|
156
|
-
# Searches each keyword individually and merges results
|
|
157
|
-
#
|
|
184
|
+
# Searches each keyword individually and merges results by identifier,
|
|
185
|
+
# scoring each merged hit by match quality (see {#keyword_match_score}).
|
|
158
186
|
#
|
|
159
187
|
# @return [Array<Candidate>]
|
|
160
188
|
def execute_keyword(classification:, limit:)
|
|
@@ -165,33 +193,126 @@ module Woods
|
|
|
165
193
|
rank_keyword_results(all_results, limit)
|
|
166
194
|
end
|
|
167
195
|
|
|
168
|
-
# Search each keyword individually and merge
|
|
196
|
+
# Search each keyword individually and merge by identifier, unioning
|
|
197
|
+
# +matched_fields+ across keywords, then score each merged hit once
|
|
198
|
+
# from its complete matched-field set (see {#keyword_match_score}).
|
|
169
199
|
#
|
|
170
200
|
# @param keywords [Array<String>]
|
|
171
|
-
# @return [Hash<String, Hash>] id => { score:, metadata: }
|
|
201
|
+
# @return [Hash<String, Hash>] id => { score:, metadata:, matched_fields: }
|
|
172
202
|
def merge_keyword_results(keywords)
|
|
173
203
|
results_by_id = {}
|
|
174
204
|
keywords.each do |keyword|
|
|
175
205
|
results = @metadata_store.search(keyword)
|
|
176
|
-
results.
|
|
177
|
-
|
|
178
|
-
score = 1.0 - (index.to_f / [results.size, 10].max)
|
|
179
|
-
results_by_id[id] = { score: score, metadata: r } if !results_by_id[id] || score > results_by_id[id][:score]
|
|
206
|
+
results.each do |r|
|
|
207
|
+
merge_keyword_hit(results_by_id, r['id'], r, matched_fields_for(r, keyword))
|
|
180
208
|
end
|
|
181
209
|
end
|
|
182
|
-
results_by_id
|
|
210
|
+
score_keyword_hits(results_by_id)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Fold a single keyword hit into the merged results: matched fields
|
|
214
|
+
# union across keywords, metadata kept from the first hit seen.
|
|
215
|
+
#
|
|
216
|
+
# @param results_by_id [Hash<String, Hash>] Accumulator (mutated)
|
|
217
|
+
# @param id [String] Candidate identifier
|
|
218
|
+
# @param record [Hash] The metadata record returned by the store
|
|
219
|
+
# @param matched [Array<String>] Fields that matched this keyword
|
|
220
|
+
# @return [void]
|
|
221
|
+
def merge_keyword_hit(results_by_id, id, record, matched)
|
|
222
|
+
entry = results_by_id[id]
|
|
223
|
+
if entry.nil?
|
|
224
|
+
results_by_id[id] = { metadata: record, matched_fields: matched }
|
|
225
|
+
else
|
|
226
|
+
entry[:matched_fields] |= matched
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Score each merged hit by match QUALITY (its final matched-field
|
|
231
|
+
# count) once every keyword has been folded in, rather than by the
|
|
232
|
+
# metadata store's result-row position.
|
|
233
|
+
#
|
|
234
|
+
# The store's +#search+ has no ORDER BY (SQLite runs a LIKE scan;
|
|
235
|
+
# InMemory iterates a Hash), so row order is arbitrary — scoring by
|
|
236
|
+
# index made that arbitrary order the dominant ranking signal once
|
|
237
|
+
# fed into the Ranker at semantic weight 0.40, ahead of the keyword
|
|
238
|
+
# evidence it was meant to represent.
|
|
239
|
+
#
|
|
240
|
+
# @param results_by_id [Hash<String, Hash>] id => { metadata:, matched_fields: }
|
|
241
|
+
# @return [Hash<String, Hash>] Same shape, each entry gaining +score+
|
|
242
|
+
def score_keyword_hits(results_by_id)
|
|
243
|
+
results_by_id.transform_values do |entry|
|
|
244
|
+
entry.merge(score: keyword_match_score(entry[:matched_fields]))
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Weight per matched field — mirrors {Ranker#keyword_score}'s own
|
|
249
|
+
# 0.25-per-field scale so the two stages agree on what "many fields
|
|
250
|
+
# matched" means. A hit with zero matched fields (shouldn't happen —
|
|
251
|
+
# the store only returned the record because something matched) still
|
|
252
|
+
# scores a positive floor rather than 0, keeping every keyword score
|
|
253
|
+
# in the documented (0.0, 1.0] range.
|
|
254
|
+
#
|
|
255
|
+
# @param matched_fields [Array<String>]
|
|
256
|
+
# @return [Float] Always in (0.0, 1.0]
|
|
257
|
+
def keyword_match_score(matched_fields)
|
|
258
|
+
return KEYWORD_FIELD_SCORE_WEIGHT if matched_fields.empty?
|
|
259
|
+
|
|
260
|
+
[matched_fields.size * KEYWORD_FIELD_SCORE_WEIGHT, 1.0].min
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @see #keyword_match_score
|
|
264
|
+
KEYWORD_FIELD_SCORE_WEIGHT = 0.25
|
|
265
|
+
private_constant :KEYWORD_FIELD_SCORE_WEIGHT
|
|
266
|
+
|
|
267
|
+
# Metadata record fields never counted as keyword matches: +id+
|
|
268
|
+
# duplicates +identifier+ (the store injects it), and +updated_at+
|
|
269
|
+
# is bookkeeping.
|
|
270
|
+
KEYWORD_MATCH_SKIPPED_FIELDS = %w[id updated_at].freeze
|
|
271
|
+
private_constant :KEYWORD_MATCH_SKIPPED_FIELDS
|
|
272
|
+
# Approximate which fields of a metadata record matched a keyword.
|
|
273
|
+
#
|
|
274
|
+
# The metadata store's +search+ probes fields but does not report
|
|
275
|
+
# which ones hit (SQLite runs a LIKE over the JSON blob; InMemory
|
|
276
|
+
# scans serialized records) — so per-candidate matched fields are not
|
|
277
|
+
# recoverable from the store without changing its interface. Instead,
|
|
278
|
+
# the executor re-checks the returned record: every top-level field
|
|
279
|
+
# whose stringified value contains the keyword (case-insensitively,
|
|
280
|
+
# matching SQLite's ASCII LIKE semantics) counts as matched. This is
|
|
281
|
+
# the input to {Ranker#keyword_score}'s 0.25-per-field table.
|
|
282
|
+
#
|
|
283
|
+
# @param record [Hash] Metadata record from the store (string keys)
|
|
284
|
+
# @param keyword [String] The keyword that produced this record
|
|
285
|
+
# @return [Array<String>] Names of fields whose values matched
|
|
286
|
+
def matched_fields_for(record, keyword)
|
|
287
|
+
return [] unless record.is_a?(Hash)
|
|
288
|
+
|
|
289
|
+
needle = keyword.to_s.downcase
|
|
290
|
+
return [] if needle.empty?
|
|
291
|
+
|
|
292
|
+
record.filter_map do |field, value|
|
|
293
|
+
next if KEYWORD_MATCH_SKIPPED_FIELDS.include?(field.to_s)
|
|
294
|
+
|
|
295
|
+
field.to_s if value.to_s.downcase.include?(needle)
|
|
296
|
+
end
|
|
183
297
|
end
|
|
184
298
|
|
|
185
299
|
# Rank merged keyword results into Candidate objects.
|
|
186
300
|
#
|
|
301
|
+
# Ties (equal matched-field counts) break on identifier, ascending —
|
|
302
|
+
# deterministic output instead of depending on the store's arbitrary
|
|
303
|
+
# row order for candidates {#keyword_match_score} can't distinguish.
|
|
304
|
+
#
|
|
187
305
|
# @param results [Hash<String, Hash>]
|
|
188
306
|
# @param limit [Integer]
|
|
189
307
|
# @return [Array<Candidate>]
|
|
190
308
|
def rank_keyword_results(results, limit)
|
|
191
309
|
scored = results.map do |id, data|
|
|
192
|
-
|
|
310
|
+
matched = data[:matched_fields]
|
|
311
|
+
Candidate.new(identifier: id, score: data[:score], source: :keyword,
|
|
312
|
+
metadata: data[:metadata],
|
|
313
|
+
matched_fields: matched && !matched.empty? ? matched : nil)
|
|
193
314
|
end
|
|
194
|
-
scored.sort_by { |c| -c.score }.first(limit)
|
|
315
|
+
scored.sort_by { |c| [-c.score, c.identifier.to_s] }.first(limit)
|
|
195
316
|
end
|
|
196
317
|
|
|
197
318
|
# Graph strategy: find related units via dependency traversal.
|
|
@@ -206,13 +327,13 @@ module Woods
|
|
|
206
327
|
|
|
207
328
|
seeds.each do |seed_id|
|
|
208
329
|
# Forward dependencies
|
|
209
|
-
deps = @graph_store.dependencies_of(seed_id)
|
|
330
|
+
deps = @graph_store.dependencies_of(StorageIdentity.identifier(seed_id))
|
|
210
331
|
deps.each do |dep|
|
|
211
332
|
candidates << Candidate.new(identifier: dep, score: 0.8, source: :graph, metadata: {})
|
|
212
333
|
end
|
|
213
334
|
|
|
214
335
|
# Reverse dependencies (dependents)
|
|
215
|
-
dependents = @graph_store.dependents_of(seed_id)
|
|
336
|
+
dependents = @graph_store.dependents_of(StorageIdentity.identifier(seed_id))
|
|
216
337
|
dependents.each do |dep|
|
|
217
338
|
candidates << Candidate.new(identifier: dep, score: 0.7, source: :graph, metadata: {})
|
|
218
339
|
end
|
|
@@ -224,19 +345,39 @@ module Woods
|
|
|
224
345
|
deduplicate(candidates).first(limit)
|
|
225
346
|
end
|
|
226
347
|
|
|
348
|
+
# How far past +limit+ to fetch from each source before handing the
|
|
349
|
+
# merged list to the Ranker's RRF fusion (#apply_rrf). A candidate
|
|
350
|
+
# found by two sources near either source's own cutoff needs BOTH
|
|
351
|
+
# hits to survive into the merged list for RRF to see them — fetching
|
|
352
|
+
# only +limit+ per source (the old behavior) starved that overlap.
|
|
353
|
+
# Bounded (not unbounded) so a large index doesn't turn one hybrid
|
|
354
|
+
# query into three full-index scans.
|
|
355
|
+
HYBRID_PER_SOURCE_LIMIT_MULTIPLIER = 3
|
|
356
|
+
private_constant :HYBRID_PER_SOURCE_LIMIT_MULTIPLIER
|
|
357
|
+
|
|
227
358
|
# Hybrid strategy: combine vector, keyword, and graph expansion.
|
|
228
359
|
#
|
|
360
|
+
# Deliberately does NOT deduplicate across sources — a candidate
|
|
361
|
+
# found by both vector and keyword search must reach the Ranker as
|
|
362
|
+
# two separate entries (one per source) so RRF (Ranker#apply_rrf)
|
|
363
|
+
# can accumulate their per-source rank-based scores. Collapsing to
|
|
364
|
+
# one entry per identifier here, before ranking, discarded that
|
|
365
|
+
# cross-source consensus entirely: "hybrid" degraded into plain
|
|
366
|
+
# concatenation. Deduplication now happens exactly once, inside
|
|
367
|
+
# RRF's merge, which is where cross-source agreement is computed.
|
|
368
|
+
#
|
|
229
369
|
# @return [Array<Candidate>]
|
|
230
370
|
def execute_hybrid(query, classification:, limit:, type_filter: nil)
|
|
371
|
+
per_source_limit = limit * HYBRID_PER_SOURCE_LIMIT_MULTIPLIER
|
|
372
|
+
|
|
231
373
|
# Gather from all three sources
|
|
232
|
-
vector_candidates = execute_vector(query,
|
|
233
|
-
|
|
234
|
-
keyword_candidates = execute_keyword(classification: classification, limit: limit)
|
|
374
|
+
vector_candidates = execute_vector(query, limit: per_source_limit, type_filter: type_filter)
|
|
375
|
+
keyword_candidates = execute_keyword(classification: classification, limit: per_source_limit)
|
|
235
376
|
|
|
236
377
|
# Graph expansion on top vector results
|
|
237
378
|
graph_candidates = []
|
|
238
379
|
vector_candidates.first(3).each do |candidate|
|
|
239
|
-
deps = @graph_store.dependencies_of(candidate.identifier)
|
|
380
|
+
deps = @graph_store.dependencies_of(StorageIdentity.identifier(candidate.identifier.sub(/#chunk_\d+\z/, '')))
|
|
240
381
|
deps.each do |dep|
|
|
241
382
|
graph_candidates << Candidate.new(
|
|
242
383
|
identifier: dep, score: 0.5, source: :graph_expansion, metadata: {}
|
|
@@ -244,8 +385,10 @@ module Woods
|
|
|
244
385
|
end
|
|
245
386
|
end
|
|
246
387
|
|
|
247
|
-
|
|
248
|
-
|
|
388
|
+
# Sorted (not deduplicated) so the caller's later `.first(limit)`
|
|
389
|
+
# keeps the strongest candidates across all sources instead of
|
|
390
|
+
# exhausting the budget on whichever source is concatenated first.
|
|
391
|
+
(vector_candidates + keyword_candidates + graph_candidates).sort_by { |c| -c.score }
|
|
249
392
|
end
|
|
250
393
|
|
|
251
394
|
# Direct strategy: look up specific identifiers from keywords.
|
|
@@ -285,24 +428,25 @@ module Woods
|
|
|
285
428
|
candidates
|
|
286
429
|
end
|
|
287
430
|
|
|
288
|
-
# Build metadata filters for vector search
|
|
289
|
-
#
|
|
431
|
+
# Build metadata filters for vector search from the caller's
|
|
432
|
+
# explicit type filter.
|
|
290
433
|
#
|
|
291
|
-
# The
|
|
292
|
-
#
|
|
293
|
-
#
|
|
434
|
+
# The classifier-derived +target_type+ is deliberately NOT pushed
|
|
435
|
+
# down here (#184). It is a heuristic, and hard-filtering on it had
|
|
436
|
+
# no fallback — a mainline query like "How do we get the current
|
|
437
|
+
# user?" classified target :route and the vector search excluded
|
|
438
|
+
# everything but route units. The heuristic still reaches the
|
|
439
|
+
# {Ranker} as the soft +type_match+ signal, which boosts matching
|
|
440
|
+
# types without excluding anything. A caller-supplied +type_filter+
|
|
441
|
+
# keeps hard-filter semantics: the caller opted into specific types,
|
|
442
|
+
# and {Retriever#retrieve} owns the within-type fallback for that path.
|
|
294
443
|
#
|
|
295
|
-
# @param classification [QueryClassifier::Classification]
|
|
296
444
|
# @param type_filter [Array<String>, nil]
|
|
297
445
|
# @return [Hash]
|
|
298
|
-
def build_vector_filters(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
elsif classification.target_type
|
|
303
|
-
filters[:type] = classification.target_type.to_s
|
|
304
|
-
end
|
|
305
|
-
filters
|
|
446
|
+
def build_vector_filters(type_filter)
|
|
447
|
+
return {} if type_filter.nil? || type_filter.empty?
|
|
448
|
+
|
|
449
|
+
{ type: type_filter.map(&:to_s) }
|
|
306
450
|
end
|
|
307
451
|
|
|
308
452
|
# Find seed identifiers from classification keywords via metadata search.
|
|
@@ -340,6 +484,29 @@ module Woods
|
|
|
340
484
|
end
|
|
341
485
|
best.values.sort_by { |c| -c.score }
|
|
342
486
|
end
|
|
487
|
+
|
|
488
|
+
# Chunk-stripped unit identity, matching Ranker#base_identifier —
|
|
489
|
+
# hybrid truncation must treat `User#chunk_0` and `User` as one unit.
|
|
490
|
+
CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
|
|
491
|
+
private_constant :CHUNK_SUFFIX_PATTERN
|
|
492
|
+
|
|
493
|
+
# Bound strategy output to +limit+ results. For hybrid, +limit+ counts
|
|
494
|
+
# UNIQUE units and cross-source duplicates of an already-kept unit ride
|
|
495
|
+
# along free: the Ranker's RRF consensus needs every source's entry for
|
|
496
|
+
# a unit, and a raw-score cut dropped the weaker source's duplicate
|
|
497
|
+
# exactly when the two sources' score scales disagreed.
|
|
498
|
+
def bounded_candidates(candidates, limit, strategy)
|
|
499
|
+
return candidates.first(limit) unless strategy == :hybrid
|
|
500
|
+
|
|
501
|
+
kept = {}
|
|
502
|
+
candidates.select do |candidate|
|
|
503
|
+
base = candidate.identifier.sub(CHUNK_SUFFIX_PATTERN, '')
|
|
504
|
+
next true if kept.key?(base)
|
|
505
|
+
|
|
506
|
+
kept[base] = true if kept.size < limit
|
|
507
|
+
kept.key?(base)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
343
510
|
end
|
|
344
511
|
end
|
|
345
512
|
end
|