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/retriever.rb
CHANGED
|
@@ -12,6 +12,15 @@ require_relative 'embedding/token_counter'
|
|
|
12
12
|
require_relative 'token_utils'
|
|
13
13
|
|
|
14
14
|
module Woods
|
|
15
|
+
# Raised by {Retriever#retrieve} when +query+ fails the validation
|
|
16
|
+
# boundary at the top of the pipeline: nil, blank, non-String, or over
|
|
17
|
+
# {Retriever::MAX_QUERY_BYTES}. One shared boundary for every strategy
|
|
18
|
+
# (keyword/vector/graph/hybrid) — pre-fix, a blank query surfaced as a
|
|
19
|
+
# raw provider ArgumentError, and only on the vector path; the other
|
|
20
|
+
# strategies tolerated it silently, and an oversized query went to the
|
|
21
|
+
# provider verbatim to fail as an opaque 400.
|
|
22
|
+
class InvalidQueryError < Error; end
|
|
23
|
+
|
|
15
24
|
# Retriever orchestrates the full retrieval pipeline: classify, execute,
|
|
16
25
|
# rank, and assemble context from a natural language query.
|
|
17
26
|
#
|
|
@@ -50,8 +59,14 @@ module Woods
|
|
|
50
59
|
).freeze
|
|
51
60
|
|
|
52
61
|
# Diagnostic trace for retrieval quality analysis.
|
|
62
|
+
#
|
|
63
|
+
# +skipped_missing_metadata+ carries {Retrieval::ContextAssembler}'s count
|
|
64
|
+
# of candidates dropped because the metadata store had no record for
|
|
65
|
+
# their identifier (a stale vector). See +RetrievalResult+'s docstring
|
|
66
|
+
# for how callers should read it alongside +sources+.
|
|
53
67
|
RetrievalTrace = Struct.new(:classification, :strategy, :candidate_count,
|
|
54
68
|
:ranked_count, :tokens_used, :elapsed_ms,
|
|
69
|
+
:skipped_missing_metadata,
|
|
55
70
|
keyword_init: true)
|
|
56
71
|
|
|
57
72
|
# The result of a retrieval operation.
|
|
@@ -85,18 +100,107 @@ module Woods
|
|
|
85
100
|
RetrievalResult = Struct.new(:context, :sources, :classification, :strategy, :tokens_used, :budget, :trace,
|
|
86
101
|
:type_rank_context, keyword_init: true)
|
|
87
102
|
|
|
103
|
+
# Raised when a metadata-store access fails during retrieval (M8). One
|
|
104
|
+
# shared typed error for every store call site: the retriever used to
|
|
105
|
+
# swallow these into misleading answers — +types:+ queries reported
|
|
106
|
+
# +:absent+, the rank-within-type fallback short-circuited to empty, and
|
|
107
|
+
# exclusion filtering silently no-op'd, each presenting a broken store as
|
|
108
|
+
# a clean empty result. Raising here lets the MCP server map the failure
|
|
109
|
+
# to tool-visible degraded metadata instead.
|
|
110
|
+
class StoreError < Error
|
|
111
|
+
# @return [String] which store failed (e.g. +"metadata"+)
|
|
112
|
+
attr_reader :store
|
|
113
|
+
|
|
114
|
+
# @param message [String] includes the underlying error class and message
|
|
115
|
+
# @param store [Symbol, String] the failing store component
|
|
116
|
+
def initialize(message, store: :metadata)
|
|
117
|
+
super(message)
|
|
118
|
+
@store = store.to_s
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Store adapter wrapper that turns ANY failure of a store call into the
|
|
123
|
+
# shared typed {StoreError}, naming the store that failed.
|
|
124
|
+
#
|
|
125
|
+
# M8 wrapped the three lookups the Retriever performs itself, but the
|
|
126
|
+
# store reads carrying most of the query traffic happen INSIDE the
|
|
127
|
+
# pipeline components — the ranker's and assembler's +find_batch+, and
|
|
128
|
+
# every executor store call. Those escaped raw: the SDK reported
|
|
129
|
+
# "Internal error calling tool codebase_retrieve", or {MCP::ToolContract}
|
|
130
|
+
# saw an IO-flavored cause and relabeled it +corrupt_artifact+ ("An Index
|
|
131
|
+
# artifact is unavailable or malformed") — the wrong diagnosis for, say, a
|
|
132
|
+
# SQLite metadata DB deleted mid-serve (MCP-6).
|
|
133
|
+
#
|
|
134
|
+
# Only the pipeline COMPONENTS get facades. The Pipeline struct's store
|
|
135
|
+
# members and the public +vector_store+ / +metadata_store+ / +graph_store+
|
|
136
|
+
# readers keep the raw adapters, so the reload transaction's capability
|
|
137
|
+
# and store-type checks ({MCP::Bootstrapper.refreshable_stores?},
|
|
138
|
+
# +implements_own?+) still see the real objects.
|
|
139
|
+
class TranslatedStore
|
|
140
|
+
# @param store [Object] the wrapped adapter
|
|
141
|
+
# @param name [Symbol] store component name (+:vector+, +:metadata+, +:graph+)
|
|
142
|
+
def initialize(store, name)
|
|
143
|
+
@store = store
|
|
144
|
+
@name = name
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Object] the wrapped adapter, for callers that need identity
|
|
148
|
+
attr_reader :store
|
|
149
|
+
|
|
150
|
+
def respond_to_missing?(name, include_private = false)
|
|
151
|
+
@store.respond_to?(name, include_private) || super
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def method_missing(name, ...)
|
|
155
|
+
return super unless @store.respond_to?(name)
|
|
156
|
+
|
|
157
|
+
@store.public_send(name, ...)
|
|
158
|
+
rescue StoreError
|
|
159
|
+
raise
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
raise StoreError.new("#{@name} store call #{name} failed: #{e.class}: #{e.message}", store: @name)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
88
165
|
# Unit types queried for the structural context overview.
|
|
89
166
|
STRUCTURAL_TYPES = %w[model controller service job mailer component graphql].freeze
|
|
90
167
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# {
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
168
|
+
# Immutable retrieval pipeline captured per store bundle. Every component
|
|
169
|
+
# that references a store lives here, so swapping the bundle is ONE
|
|
170
|
+
# assignment: {#swap_stores!} replaces +@pipeline+ and every query
|
|
171
|
+
# resolves the pipeline once at the top of {#retrieve}. An in-flight query
|
|
172
|
+
# keeps the struct it resolved and finishes entirely against the old
|
|
173
|
+
# stores; a new query sees only the complete new bundle (M7 — build-then-
|
|
174
|
+
# swap, never clear!+bulk_load on live stores).
|
|
175
|
+
Pipeline = Struct.new(:executor, :ranker, :assembler,
|
|
176
|
+
:vector_store, :metadata_store, :graph_store,
|
|
177
|
+
keyword_init: true)
|
|
178
|
+
private_constant :Pipeline
|
|
179
|
+
|
|
180
|
+
# The live store handles. These delegate to the current pipeline —
|
|
181
|
+
# callers that want store contents read through these accessors, exactly
|
|
182
|
+
# as before; the difference is that a swap retires the old store objects
|
|
183
|
+
# instead of mutating them in place.
|
|
184
|
+
def vector_store = @pipeline.vector_store
|
|
185
|
+
def metadata_store = @pipeline.metadata_store
|
|
186
|
+
def graph_store = @pipeline.graph_store
|
|
187
|
+
|
|
188
|
+
# Read-only view of the current store bundle and the components wired to
|
|
189
|
+
# it (executor, ranker, assembler). Diagnostics/specs used to poke
|
|
190
|
+
# +@executor+/-style ivars directly; this is the supported equivalent.
|
|
191
|
+
# The reload transaction swaps the whole struct via {#swap_stores!}.
|
|
192
|
+
#
|
|
193
|
+
# @return [Pipeline]
|
|
194
|
+
attr_reader :pipeline
|
|
195
|
+
|
|
196
|
+
# Optional callback invoked with the pipeline struct the moment
|
|
197
|
+
# {#retrieve} resolves it, before any pipeline work runs. Nil in
|
|
198
|
+
# production. Observability seam for the reload transaction's old-or-new
|
|
199
|
+
# guarantee (M7): a test can block an in-flight query AFTER it captured
|
|
200
|
+
# the old bundle but BEFORE the swap lands, deterministically.
|
|
201
|
+
#
|
|
202
|
+
# @return [Proc, nil]
|
|
203
|
+
attr_accessor :pipeline_observer
|
|
100
204
|
|
|
101
205
|
# @param vector_store [Storage::VectorStore::Interface] Vector store adapter
|
|
102
206
|
# @param metadata_store [Storage::MetadataStore::Interface] Metadata store adapter
|
|
@@ -104,30 +208,75 @@ module Woods
|
|
|
104
208
|
# @param embedding_provider [Embedding::Provider::Interface] Embedding provider
|
|
105
209
|
# @param formatter [#call, nil] Optional callable to post-process the context string
|
|
106
210
|
def initialize(vector_store:, metadata_store:, graph_store:, embedding_provider:, formatter: nil)
|
|
107
|
-
@
|
|
108
|
-
@metadata_store = metadata_store
|
|
109
|
-
@graph_store = graph_store
|
|
211
|
+
@embedding_provider = embedding_provider
|
|
110
212
|
@formatter = formatter
|
|
111
|
-
|
|
112
213
|
@classifier = Retrieval::QueryClassifier.new
|
|
113
|
-
@
|
|
214
|
+
@pipeline = build_pipeline(vector_store: vector_store,
|
|
215
|
+
metadata_store: metadata_store,
|
|
216
|
+
graph_store: graph_store)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Replace the store bundle atomically (M7).
|
|
220
|
+
#
|
|
221
|
+
# Builds a complete new pipeline from +stores+ (fresh executor, ranker and
|
|
222
|
+
# assembler — a fresh ranker also carries no memoized PageRank from the
|
|
223
|
+
# retired graph) and swaps it in with a single assignment. Queries already
|
|
224
|
+
# running resolved the old pipeline and finish against the old stores;
|
|
225
|
+
# queries started afterwards resolve the new bundle. No query can observe
|
|
226
|
+
# an empty or half-swapped store set, because no store is ever mutated in
|
|
227
|
+
# place.
|
|
228
|
+
#
|
|
229
|
+
# Called by the MCP reload transaction AFTER its candidate stores passed
|
|
230
|
+
# the generation recheck, under the exclusive swap lock.
|
|
231
|
+
#
|
|
232
|
+
# @param vector_store [Storage::VectorStore::Interface]
|
|
233
|
+
# @param metadata_store [Storage::MetadataStore::Interface]
|
|
234
|
+
# @param graph_store [Storage::GraphStore::Interface]
|
|
235
|
+
# @return [self]
|
|
236
|
+
def swap_stores!(vector_store:, metadata_store:, graph_store:)
|
|
237
|
+
@pipeline = build_pipeline(vector_store: vector_store,
|
|
238
|
+
metadata_store: metadata_store,
|
|
239
|
+
graph_store: graph_store)
|
|
240
|
+
self
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Build one immutable pipeline around a store bundle.
|
|
244
|
+
#
|
|
245
|
+
# Components read through {TranslatedStore} facades so every store call
|
|
246
|
+
# they make raises the typed {StoreError} instead of a raw adapter error
|
|
247
|
+
# (MCP-6); the struct keeps the raw adapters for identity and capability
|
|
248
|
+
# checks.
|
|
249
|
+
def build_pipeline(vector_store:, metadata_store:, graph_store:)
|
|
250
|
+
translated_vector = translate_store(vector_store, :vector)
|
|
251
|
+
translated_metadata = translate_store(metadata_store, :metadata)
|
|
252
|
+
translated_graph = translate_store(graph_store, :graph)
|
|
253
|
+
|
|
254
|
+
Pipeline.new(
|
|
255
|
+
executor: Retrieval::SearchExecutor.new(
|
|
256
|
+
vector_store: translated_vector,
|
|
257
|
+
metadata_store: translated_metadata,
|
|
258
|
+
graph_store: translated_graph,
|
|
259
|
+
embedding_provider: @embedding_provider
|
|
260
|
+
),
|
|
261
|
+
ranker: Retrieval::Ranker.new(metadata_store: translated_metadata, graph_store: translated_graph),
|
|
262
|
+
assembler: Retrieval::ContextAssembler.new(
|
|
263
|
+
metadata_store: translated_metadata,
|
|
264
|
+
chars_per_token: infer_chars_per_token(@embedding_provider),
|
|
265
|
+
token_counter: infer_token_counter(@embedding_provider)
|
|
266
|
+
),
|
|
114
267
|
vector_store: vector_store,
|
|
115
268
|
metadata_store: metadata_store,
|
|
116
|
-
graph_store: graph_store
|
|
117
|
-
embedding_provider: embedding_provider
|
|
118
|
-
)
|
|
119
|
-
@ranker = Retrieval::Ranker.new(metadata_store: metadata_store, graph_store: graph_store)
|
|
120
|
-
# Match truncation sizing to the embedding provider's tokenizer so
|
|
121
|
-
# Ollama-indexed corpora (ratio ~1.5) don't get over-truncated by
|
|
122
|
-
# an OpenAI-sized default (4.0). Unknown/missing providers fall
|
|
123
|
-
# back to the OpenAI-friendly default.
|
|
124
|
-
chars_per_token = infer_chars_per_token(embedding_provider)
|
|
125
|
-
@assembler = Retrieval::ContextAssembler.new(
|
|
126
|
-
metadata_store: metadata_store,
|
|
127
|
-
chars_per_token: chars_per_token,
|
|
128
|
-
token_counter: infer_token_counter(embedding_provider)
|
|
269
|
+
graph_store: graph_store
|
|
129
270
|
)
|
|
130
271
|
end
|
|
272
|
+
private :build_pipeline
|
|
273
|
+
|
|
274
|
+
# Wrap one store adapter for the pipeline components. Nil stays nil — a
|
|
275
|
+
# nil graph store is a supported bundle shape.
|
|
276
|
+
def translate_store(store, name)
|
|
277
|
+
store && TranslatedStore.new(store, name)
|
|
278
|
+
end
|
|
279
|
+
private :translate_store
|
|
131
280
|
|
|
132
281
|
# Infer the chars-per-token ratio from an embedding provider's model.
|
|
133
282
|
# Ollama WordPiece-style tokenizers (nomic-embed-text, bge-*,
|
|
@@ -183,6 +332,16 @@ module Woods
|
|
|
183
332
|
CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
|
|
184
333
|
private_constant :CHUNK_SUFFIX_PATTERN
|
|
185
334
|
|
|
335
|
+
# Maximum query size accepted by {#retrieve}, in bytes.
|
|
336
|
+
#
|
|
337
|
+
# Configuration/ResolvedConfig have no existing query-length knob
|
|
338
|
+
# (checked both) — +max_context_tokens+ bounds assembled OUTPUT
|
|
339
|
+
# context, not the input query. ~8KB comfortably covers any
|
|
340
|
+
# legitimate natural-language query and fails fast, in-process,
|
|
341
|
+
# instead of round-tripping an oversized string to an embedding or
|
|
342
|
+
# metadata provider just to get a 400 back.
|
|
343
|
+
MAX_QUERY_BYTES = 8 * 1024
|
|
344
|
+
|
|
186
345
|
# Execute the full retrieval pipeline for a natural language query.
|
|
187
346
|
#
|
|
188
347
|
# Pipeline: classify -> execute -> rank -> filter -> (fallback within-type
|
|
@@ -200,18 +359,25 @@ module Woods
|
|
|
200
359
|
# exclude. Applied on top of DEFAULT_EXCLUDE_TYPES unless +types:+ is set.
|
|
201
360
|
# @return [RetrievalResult] Complete retrieval result
|
|
202
361
|
def retrieve(query, budget: 8000, types: nil, exclude_types: nil)
|
|
362
|
+
validate_query!(query)
|
|
203
363
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
364
|
+
# One atomic read of the bundle reference: everything this query does
|
|
365
|
+
# from here on — execution, ranking, filtering, assembly — stays on the
|
|
366
|
+
# SAME store set even if a reload swaps the pipeline mid-flight (M7).
|
|
367
|
+
pipeline = @pipeline
|
|
368
|
+
@pipeline_observer&.call(pipeline)
|
|
204
369
|
classification = @classifier.classify(query)
|
|
205
|
-
execution_result =
|
|
206
|
-
ranked =
|
|
370
|
+
execution_result = pipeline.executor.execute(query: query, classification: classification)
|
|
371
|
+
ranked = pipeline.ranker.rank(execution_result.candidates, classification: classification)
|
|
207
372
|
|
|
208
373
|
type_list = normalize_type_list(types)
|
|
209
|
-
filtered, fallback_ran = apply_type_filter(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
type_rank_context =
|
|
374
|
+
filtered, fallback_ran = apply_type_filter(pipeline, ranked, query, classification,
|
|
375
|
+
types: types, type_list: type_list,
|
|
376
|
+
exclude_types: exclude_types)
|
|
377
|
+
type_rank_context = build_type_rank_context_for(ranked, pipeline, type_list, filtered,
|
|
378
|
+
fallback_ran: fallback_ran)
|
|
213
379
|
|
|
214
|
-
assembled = assemble_context(filtered, classification, budget)
|
|
380
|
+
assembled = assemble_context(pipeline, filtered, classification, budget)
|
|
215
381
|
trace = build_trace(classification, execution_result, filtered, assembled, start_time)
|
|
216
382
|
|
|
217
383
|
build_result(
|
|
@@ -222,6 +388,19 @@ module Woods
|
|
|
222
388
|
|
|
223
389
|
private
|
|
224
390
|
|
|
391
|
+
# Validate +query+ before any classify/execute/rank work happens.
|
|
392
|
+
#
|
|
393
|
+
# @param query [Object] The caller-supplied query
|
|
394
|
+
# @raise [InvalidQueryError] nil, non-String, blank, or over {MAX_QUERY_BYTES}
|
|
395
|
+
# @return [void]
|
|
396
|
+
def validate_query!(query)
|
|
397
|
+
raise InvalidQueryError, "query must be a String, got #{query.class}" unless query.is_a?(String)
|
|
398
|
+
raise InvalidQueryError, 'query cannot be blank' if query.strip.empty?
|
|
399
|
+
return unless query.bytesize > MAX_QUERY_BYTES
|
|
400
|
+
|
|
401
|
+
raise InvalidQueryError, "query exceeds max size of #{MAX_QUERY_BYTES} bytes (got #{query.bytesize})"
|
|
402
|
+
end
|
|
403
|
+
|
|
225
404
|
# Filter ranked candidates by type, using an include-list when +types+
|
|
226
405
|
# is set and an exclude-list otherwise (default: +DEFAULT_EXCLUDE_TYPES+,
|
|
227
406
|
# extended by any +exclude_types+ the caller adds).
|
|
@@ -231,18 +410,20 @@ module Woods
|
|
|
231
410
|
# filter still works on graph-expansion candidates that carry no
|
|
232
411
|
# vector-store metadata.
|
|
233
412
|
#
|
|
413
|
+
# @param pipeline [Pipeline] the store bundle this query resolved (M7:
|
|
414
|
+
# every store access in the query flows from this snapshot)
|
|
234
415
|
# @param candidates [Array<Candidate>]
|
|
235
416
|
# @param types [Array<String, Symbol>, nil]
|
|
236
417
|
# @param exclude_types [Array<String, Symbol>, nil]
|
|
237
418
|
# @return [Array<Candidate>]
|
|
238
|
-
def filter_by_type(candidates, types:, exclude_types:)
|
|
419
|
+
def filter_by_type(pipeline, candidates, types:, exclude_types:)
|
|
239
420
|
allowed = normalize_type_list(types)
|
|
240
|
-
return candidates.select { |c| allowed.include?(candidate_type(c)) } if allowed
|
|
421
|
+
return candidates.select { |c| allowed.include?(candidate_type(pipeline, c)) } if allowed
|
|
241
422
|
|
|
423
|
+
# DEFAULT_EXCLUDE_TYPES is always non-empty, so `excluded` here can
|
|
424
|
+
# never be empty — no early-return-candidates-unchanged branch exists.
|
|
242
425
|
excluded = (normalize_type_list(exclude_types) || Set.new) | DEFAULT_EXCLUDE_TYPES.to_set
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
candidates.reject { |c| excluded.include?(candidate_type(c)) }
|
|
426
|
+
candidates.reject { |c| excluded.include?(candidate_type(pipeline, c)) }
|
|
246
427
|
end
|
|
247
428
|
|
|
248
429
|
def normalize_type_list(list)
|
|
@@ -251,7 +432,7 @@ module Woods
|
|
|
251
432
|
list.to_set(&:to_s)
|
|
252
433
|
end
|
|
253
434
|
|
|
254
|
-
def candidate_type(candidate)
|
|
435
|
+
def candidate_type(pipeline, candidate)
|
|
255
436
|
inline = type_from_hash(candidate.metadata)
|
|
256
437
|
return inline if inline
|
|
257
438
|
|
|
@@ -262,9 +443,12 @@ module Woods
|
|
|
262
443
|
# missed lookup would let the candidate past the default-exclude
|
|
263
444
|
# (type resolves to '', which +excluded+ never contains).
|
|
264
445
|
lookup_id = candidate.identifier.to_s.sub(CHUNK_SUFFIX_PATTERN, '')
|
|
265
|
-
type_from_hash(
|
|
266
|
-
rescue StandardError
|
|
267
|
-
''
|
|
446
|
+
type_from_hash(pipeline.metadata_store.find(lookup_id)) || ''
|
|
447
|
+
rescue StandardError => e
|
|
448
|
+
# M8: a failed lookup must not read as "type is ''" — that silently
|
|
449
|
+
# disables exclusion filtering. Raise the shared typed store error.
|
|
450
|
+
raise StoreError,
|
|
451
|
+
"metadata store lookup failed while resolving a candidate type: #{e.class}: #{e.message}"
|
|
268
452
|
end
|
|
269
453
|
|
|
270
454
|
def type_from_hash(hash)
|
|
@@ -276,14 +460,15 @@ module Woods
|
|
|
276
460
|
|
|
277
461
|
# Assemble token-budgeted context from ranked candidates.
|
|
278
462
|
#
|
|
463
|
+
# @param pipeline [Pipeline] the resolved store bundle
|
|
279
464
|
# @param ranked [Array<Candidate>] Ranked search candidates
|
|
280
465
|
# @param classification [QueryClassifier::Classification] Query classification
|
|
281
466
|
# @return [AssembledContext]
|
|
282
|
-
def assemble_context(ranked, classification, budget)
|
|
283
|
-
|
|
467
|
+
def assemble_context(pipeline, ranked, classification, budget)
|
|
468
|
+
pipeline.assembler.assemble(
|
|
284
469
|
candidates: ranked,
|
|
285
470
|
classification: classification,
|
|
286
|
-
structural_context: build_structural_context,
|
|
471
|
+
structural_context: build_structural_context(pipeline.metadata_store),
|
|
287
472
|
budget: budget
|
|
288
473
|
)
|
|
289
474
|
end
|
|
@@ -315,11 +500,11 @@ module Woods
|
|
|
315
500
|
# passed a type filter and the global top-K had no candidate of the
|
|
316
501
|
# requested type(s). Returns +[filtered, fallback_ran]+ — the second
|
|
317
502
|
# element drives the :source field on type_rank_context.
|
|
318
|
-
def apply_type_filter(ranked, query, classification, types:, type_list:, exclude_types:)
|
|
319
|
-
filtered = filter_by_type(ranked, types: types, exclude_types: exclude_types)
|
|
503
|
+
def apply_type_filter(pipeline, ranked, query, classification, types:, type_list:, exclude_types:)
|
|
504
|
+
filtered = filter_by_type(pipeline, ranked, types: types, exclude_types: exclude_types)
|
|
320
505
|
return [filtered, false] unless type_list && filtered.empty?
|
|
321
506
|
|
|
322
|
-
[within_type_fallback(query, classification, type_list, exclude_types), true]
|
|
507
|
+
[within_type_fallback(pipeline, query, classification, type_list, exclude_types), true]
|
|
323
508
|
end
|
|
324
509
|
|
|
325
510
|
# Rank-within-type fallback query. Pushes the explicit type filter
|
|
@@ -337,16 +522,16 @@ module Woods
|
|
|
337
522
|
# Short-circuits to an empty Array when every requested type has
|
|
338
523
|
# zero units in the index — there is nothing for the fallback to
|
|
339
524
|
# find, so we skip the extra vector search.
|
|
340
|
-
def within_type_fallback(query, classification, type_list, exclude_types)
|
|
525
|
+
def within_type_fallback(pipeline, query, classification, type_list, exclude_types)
|
|
341
526
|
type_array = type_list.to_a
|
|
342
|
-
return [] if type_array.all? { |t| total_of_type(t).to_i.zero? }
|
|
527
|
+
return [] if type_array.all? { |t| total_of_type(pipeline.metadata_store, t).to_i.zero? }
|
|
343
528
|
|
|
344
|
-
fallback =
|
|
529
|
+
fallback = pipeline.executor.execute(
|
|
345
530
|
query: query, classification: classification,
|
|
346
531
|
type_filter: type_array, strategy: :vector
|
|
347
532
|
)
|
|
348
|
-
ranked =
|
|
349
|
-
filter_by_type(ranked, types: type_array, exclude_types: exclude_types)
|
|
533
|
+
ranked = pipeline.ranker.rank(fallback.candidates, classification: classification)
|
|
534
|
+
filter_by_type(pipeline, ranked, types: type_array, exclude_types: exclude_types)
|
|
350
535
|
end
|
|
351
536
|
|
|
352
537
|
def build_trace(classification, execution_result, filtered, assembled, start_time)
|
|
@@ -357,10 +542,26 @@ module Woods
|
|
|
357
542
|
candidate_count: execution_result.candidates.size,
|
|
358
543
|
ranked_count: filtered.size,
|
|
359
544
|
tokens_used: assembled.tokens_used,
|
|
360
|
-
elapsed_ms: elapsed_ms
|
|
545
|
+
elapsed_ms: elapsed_ms,
|
|
546
|
+
skipped_missing_metadata: assembled.skipped_missing_metadata.to_i
|
|
361
547
|
)
|
|
362
548
|
end
|
|
363
549
|
|
|
550
|
+
# Per-type rank metadata for +types:+ queries; nil on unfiltered queries.
|
|
551
|
+
#
|
|
552
|
+
# @param ranked [Array<Candidate>]
|
|
553
|
+
# @param pipeline [Pipeline] the resolved store bundle (M7)
|
|
554
|
+
# @param type_list [Set<String>, nil]
|
|
555
|
+
# @param filtered [Array<Candidate>] The post-fallback candidate list
|
|
556
|
+
# @param fallback_ran [Boolean] Whether rank-within-type fallback ran
|
|
557
|
+
# @return [Hash{String => Hash}, nil]
|
|
558
|
+
def build_type_rank_context_for(ranked, pipeline, type_list, filtered, fallback_ran:)
|
|
559
|
+
return nil unless type_list
|
|
560
|
+
|
|
561
|
+
build_type_rank_context(ranked, pipeline.metadata_store, type_list, filtered,
|
|
562
|
+
fallback_ran: fallback_ran)
|
|
563
|
+
end
|
|
564
|
+
|
|
364
565
|
# Build per-type rank metadata from the unfiltered global ranked list.
|
|
365
566
|
#
|
|
366
567
|
# +top_of_type_global_rank+ is the 1-based position of the first
|
|
@@ -372,19 +573,25 @@ module Woods
|
|
|
372
573
|
# see the RetrievalResult docstring for the four-value enum.
|
|
373
574
|
#
|
|
374
575
|
# @param ranked [Array<Candidate>]
|
|
576
|
+
# @param metadata_store [Storage::MetadataStore::Interface] the resolved
|
|
577
|
+
# bundle's metadata store (M7)
|
|
375
578
|
# @param type_list [Set<String>]
|
|
579
|
+
# @param filtered [Array<Candidate>] The post-fallback candidate list
|
|
580
|
+
# {#retrieve} is about to assemble — used to confirm the fallback
|
|
581
|
+
# actually surfaced a candidate OF this specific type, not just that
|
|
582
|
+
# fallback ran.
|
|
376
583
|
# @param fallback_ran [Boolean] Whether rank-within-type fallback ran
|
|
377
584
|
# @return [Hash{String => Hash}]
|
|
378
|
-
def build_type_rank_context(ranked, type_list, fallback_ran:)
|
|
585
|
+
def build_type_rank_context(ranked, metadata_store, type_list, filtered, fallback_ran:)
|
|
379
586
|
global_k = ranked.size
|
|
380
587
|
type_list.to_h do |type|
|
|
381
|
-
match_index = ranked.index { |c|
|
|
588
|
+
match_index = ranked.index { |c| candidate_type_from(metadata_store, c) == type }
|
|
382
589
|
top_rank = match_index ? match_index + 1 : nil
|
|
383
|
-
total = total_of_type(type)
|
|
590
|
+
total = total_of_type(metadata_store, type)
|
|
384
591
|
[
|
|
385
592
|
type,
|
|
386
593
|
{
|
|
387
|
-
source: type_source(top_rank, total, fallback_ran: fallback_ran),
|
|
594
|
+
source: type_source(top_rank, total, metadata_store, filtered, type, fallback_ran: fallback_ran),
|
|
388
595
|
top_of_type_global_rank: top_rank,
|
|
389
596
|
global_k: global_k,
|
|
390
597
|
total_of_type: total
|
|
@@ -395,18 +602,44 @@ module Woods
|
|
|
395
602
|
|
|
396
603
|
# Pick the :source enum value for a single type based on where its
|
|
397
604
|
# candidate ended up. See RetrievalResult's docstring for the enum.
|
|
398
|
-
|
|
605
|
+
#
|
|
606
|
+
# +:within_type_fallback+ requires the fallback to have actually
|
|
607
|
+
# returned a candidate of THIS type — a multi-type fallback (e.g.
|
|
608
|
+
# +types: %w[service mailer]+) can run and surface candidates for only
|
|
609
|
+
# some of the requested types, and the type(s) it missed are
|
|
610
|
+
# +:outside_top_k+, not falsely reported as a weak fallback match.
|
|
611
|
+
def type_source(top_rank, total, metadata_store, filtered, type, fallback_ran:)
|
|
399
612
|
return :in_top_k if top_rank
|
|
400
613
|
return :absent if total.to_i.zero?
|
|
401
|
-
return :within_type_fallback if fallback_ran
|
|
614
|
+
return :within_type_fallback if fallback_ran && filtered.any? do |c|
|
|
615
|
+
candidate_type_from(metadata_store, c) == type
|
|
616
|
+
end
|
|
402
617
|
|
|
403
618
|
:outside_top_k
|
|
404
619
|
end
|
|
405
620
|
|
|
406
|
-
def total_of_type(type)
|
|
407
|
-
|
|
408
|
-
rescue StandardError
|
|
409
|
-
|
|
621
|
+
def total_of_type(metadata_store, type)
|
|
622
|
+
metadata_store.find_by_type(type).size
|
|
623
|
+
rescue StandardError => e
|
|
624
|
+
# M8: a failed count must not read as zero — that reports :absent for
|
|
625
|
+
# a type that may exist and short-circuits the within-type fallback.
|
|
626
|
+
raise StoreError,
|
|
627
|
+
"metadata store count failed for type #{type.inspect}: #{e.class}: #{e.message}"
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# Chunk-suffix-stripping candidate-type probe against an explicit
|
|
631
|
+
# metadata store. Same lookup rules as {#candidate_type}; takes the
|
|
632
|
+
# store as an argument so a caller holding a resolved bundle snapshot
|
|
633
|
+
# (M7) probes the generation it started on.
|
|
634
|
+
def candidate_type_from(metadata_store, candidate)
|
|
635
|
+
inline = type_from_hash(candidate.metadata)
|
|
636
|
+
return inline if inline
|
|
637
|
+
|
|
638
|
+
lookup_id = candidate.identifier.to_s.sub(CHUNK_SUFFIX_PATTERN, '')
|
|
639
|
+
type_from_hash(metadata_store.find(lookup_id)) || ''
|
|
640
|
+
rescue StandardError => e
|
|
641
|
+
raise StoreError,
|
|
642
|
+
"metadata store lookup failed while resolving a candidate type: #{e.class}: #{e.message}"
|
|
410
643
|
end
|
|
411
644
|
|
|
412
645
|
# Append a compact markdown summary of +type_rank_context+ to the
|
|
@@ -439,20 +672,33 @@ module Woods
|
|
|
439
672
|
# spot the searchable-entries vs unit-count discrepancy know which
|
|
440
673
|
# tool carries the canonical unit totals (issue #105).
|
|
441
674
|
#
|
|
442
|
-
# @
|
|
443
|
-
|
|
444
|
-
|
|
675
|
+
# @param metadata_store [Storage::MetadataStore::Interface] the resolved
|
|
676
|
+
# bundle's metadata store (M7)
|
|
677
|
+
# @return [String, nil] Overview string, or nil if the store is empty
|
|
678
|
+
# @raise [StoreError] the metadata store failed
|
|
679
|
+
def build_structural_context(metadata_store)
|
|
680
|
+
total = metadata_store.count
|
|
445
681
|
return nil if total.zero?
|
|
446
682
|
|
|
447
683
|
type_counts = STRUCTURAL_TYPES.filter_map do |type|
|
|
448
|
-
count =
|
|
684
|
+
count = metadata_store.find_by_type(type).size
|
|
449
685
|
"#{count} #{type} entries" if count.positive?
|
|
450
686
|
end
|
|
451
687
|
|
|
452
688
|
"Codebase: #{total} searchable entries (#{type_counts.join(', ')}). " \
|
|
453
689
|
'Entries include per-chunk rows for chunked units; see `structure` for canonical unit counts.'
|
|
454
|
-
rescue
|
|
455
|
-
|
|
690
|
+
rescue StoreError
|
|
691
|
+
raise
|
|
692
|
+
rescue StandardError => e
|
|
693
|
+
# MCP-6: this used to rescue to nil — the last swallow-to-empty on the
|
|
694
|
+
# query path. A broken metadata store dropped the banner and every
|
|
695
|
+
# answer looked like a healthy codebase with nothing to say about
|
|
696
|
+
# itself. It is a metadata read like any other; raise the shared typed
|
|
697
|
+
# store error so the MCP boundary can report degraded_index.
|
|
698
|
+
raise StoreError.new(
|
|
699
|
+
"metadata store failed while building the structural overview: #{e.class}: #{e.message}",
|
|
700
|
+
store: :metadata
|
|
701
|
+
)
|
|
456
702
|
end
|
|
457
703
|
end
|
|
458
704
|
end
|
data/lib/woods/retry_after.rb
CHANGED
|
@@ -12,13 +12,33 @@ module Woods
|
|
|
12
12
|
# back off. This helper handles both forms and falls back to a caller-supplied
|
|
13
13
|
# value when the header is absent or unparseable.
|
|
14
14
|
module RetryAfter
|
|
15
|
+
# Ceiling on an honoured +Retry-After+, in seconds.
|
|
16
|
+
#
|
|
17
|
+
# The header is server-controlled, and nothing stops it saying `86400` or
|
|
18
|
+
# naming a date next week. A client that sleeps for exactly as long as it
|
|
19
|
+
# is told hands a buggy or hostile server the ability to park a sync
|
|
20
|
+
# indefinitely — an export run would sit in `sleep` for days with no
|
|
21
|
+
# output. Capping keeps the back-off honest without ignoring the server's
|
|
22
|
+
# intent: past this point, retrying later is the operator's call, not the
|
|
23
|
+
# process's.
|
|
24
|
+
#
|
|
25
|
+
# 120s matches the embedding providers' cap (#188).
|
|
26
|
+
MAX_SECONDS = 120.0
|
|
27
|
+
|
|
15
28
|
module_function
|
|
16
29
|
|
|
17
30
|
# @param header [String, nil] the raw +Retry-After+ header value
|
|
18
31
|
# @param fallback [Numeric] seconds to use when the header is missing or unparseable
|
|
19
32
|
# @param now [Time] reference time for the HTTP-date form (injectable for tests)
|
|
20
|
-
# @
|
|
21
|
-
|
|
33
|
+
# @param max [Numeric] ceiling on the returned wait. Pass +Float::INFINITY+
|
|
34
|
+
# to honour the header no matter how large.
|
|
35
|
+
# @return [Float] non-negative seconds to wait, never more than +max+
|
|
36
|
+
def seconds(header, fallback:, now: Time.now, max: MAX_SECONDS)
|
|
37
|
+
[raw_seconds(header, fallback: fallback, now: now), max.to_f].min
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The header's own value, uncapped.
|
|
41
|
+
def raw_seconds(header, fallback:, now: Time.now)
|
|
22
42
|
value = header.to_s.strip
|
|
23
43
|
return fallback.to_f if value.empty?
|
|
24
44
|
|
|
@@ -33,28 +33,28 @@ module Woods
|
|
|
33
33
|
def analyze(source:, file_path:)
|
|
34
34
|
root = @parser.parse(source)
|
|
35
35
|
units = []
|
|
36
|
-
extract_definitions(root, source, file_path, [], units)
|
|
36
|
+
extract_definitions(root, source, file_path, [], units, [])
|
|
37
37
|
units
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
|
-
def extract_definitions(node, source, file_path, namespace_stack, units)
|
|
42
|
+
def extract_definitions(node, source, file_path, namespace_stack, units, scopes)
|
|
43
43
|
return unless node.is_a?(Ast::Node)
|
|
44
44
|
|
|
45
45
|
case node.type
|
|
46
46
|
when :class
|
|
47
|
-
process_definition(node, :ruby_class, source, file_path, namespace_stack, units)
|
|
47
|
+
process_definition(node, :ruby_class, source, file_path, namespace_stack, units, scopes)
|
|
48
48
|
when :module
|
|
49
|
-
process_definition(node, :ruby_module, source, file_path, namespace_stack, units)
|
|
49
|
+
process_definition(node, :ruby_module, source, file_path, namespace_stack, units, scopes)
|
|
50
50
|
else
|
|
51
51
|
(node.children || []).each do |child|
|
|
52
|
-
extract_definitions(child, source, file_path, namespace_stack, units)
|
|
52
|
+
extract_definitions(child, source, file_path, namespace_stack, units, scopes)
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
def process_definition(node, type, source, file_path, namespace_stack, units)
|
|
57
|
+
def process_definition(node, type, source, file_path, namespace_stack, units, scopes)
|
|
58
58
|
name = node.method_name
|
|
59
59
|
fqn = build_fqn(name, namespace_stack)
|
|
60
60
|
namespace = build_namespace(name, namespace_stack)
|
|
@@ -70,6 +70,7 @@ module Woods
|
|
|
70
70
|
unit.namespace = namespace
|
|
71
71
|
unit.source_code = extract_source(node, source)
|
|
72
72
|
unit.metadata = {
|
|
73
|
+
lexical_scopes: [fqn] + scopes,
|
|
73
74
|
superclass: superclass,
|
|
74
75
|
includes: includes,
|
|
75
76
|
extends: extends,
|
|
@@ -80,24 +81,19 @@ module Woods
|
|
|
80
81
|
units << unit
|
|
81
82
|
|
|
82
83
|
# Recurse into body for nested definitions
|
|
83
|
-
inner_ns =
|
|
84
|
+
inner_ns = fqn.split('::')
|
|
84
85
|
children.each do |child|
|
|
85
|
-
extract_definitions(child, source, file_path, inner_ns, units)
|
|
86
|
+
extract_definitions(child, source, file_path, inner_ns, units, [fqn] + scopes)
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# Build namespace string (everything except the leaf name).
|
|
90
91
|
def build_namespace(name, namespace_stack)
|
|
91
|
-
parts =
|
|
92
|
+
parts = build_fqn(name, namespace_stack).split('::')
|
|
92
93
|
parts.pop # Remove leaf
|
|
93
94
|
parts.empty? ? nil : parts.join('::')
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
# Split a name that may contain :: into parts.
|
|
97
|
-
def fqn_parts(name)
|
|
98
|
-
name.to_s.split('::')
|
|
99
|
-
end
|
|
100
|
-
|
|
101
97
|
# Extract superclass name from a class node.
|
|
102
98
|
# Children[0] is name, children[1] is superclass (or nil).
|
|
103
99
|
def extract_superclass(class_node)
|