okf 1.8.0 → 1.9.0
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 +218 -1
- data/README.md +10 -7
- data/lib/okf/bundle/folder.rb +16 -0
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +2 -2
- data/lib/okf/cli.rb +95 -25
- data/lib/okf/{server → render}/graph/template.html.erb +535 -116
- data/lib/okf/{server → render}/graph.rb +39 -7
- data/lib/okf/server/app.rb +7 -44
- data/lib/okf/skill/SKILL.md +27 -16
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/maintain.md +1 -1
- data/lib/okf/skill/playbooks/search.md +47 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +76 -17
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +19 -3
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -2,24 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class Bundle
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# with `regexp: true`. Matches rank by where they hit (a title hit outranks a
|
|
10
|
-
# body hit) and carry one bounded context snippet, so answering "which concept
|
|
11
|
-
# covers X?" costs a row, not a body read.
|
|
5
|
+
# Ranked text retrieval over one or more in-memory bundles. Terms are ANDed:
|
|
6
|
+
# every term must hit at least one searched field, though not necessarily the
|
|
7
|
+
# same one. Rows carry the fields each term hit, so a result stays explainable
|
|
8
|
+
# rather than being a bare relevance number.
|
|
12
9
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# This class is a *facade*. It owns everything that defines what a result is —
|
|
11
|
+
# the documents, the row and its key order, the snippet window, the final sort
|
|
12
|
+
# — and delegates only "which documents match, how well, and where" to an
|
|
13
|
+
# engine (Search::Index by default, Search::Scan for regexp). An engine that
|
|
14
|
+
# built its own rows could disagree about what a match is; this split makes
|
|
15
|
+
# that unrepresentable.
|
|
15
16
|
#
|
|
16
|
-
# Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
|
|
17
|
-
# it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
|
|
17
|
+
# Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
|
|
18
|
+
# share it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
|
|
18
19
|
class Search
|
|
20
|
+
# Raised when the query needs something the engine cannot do — either the
|
|
21
|
+
# one that was named, or any that is available. Carries structured data
|
|
22
|
+
# rather than a finished sentence, because the shell says "--regexp" where
|
|
23
|
+
# the core says ":regexp"; the CLI formats it and exits 2.
|
|
24
|
+
class UnsupportedQuery < OKF::Error
|
|
25
|
+
attr_reader :missing, :engine
|
|
26
|
+
|
|
27
|
+
def initialize(missing, engine: nil)
|
|
28
|
+
@missing = missing
|
|
29
|
+
@engine = engine
|
|
30
|
+
super(build_message(missing, engine))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_message(missing, engine)
|
|
36
|
+
return "no search engine is available" if missing.empty?
|
|
37
|
+
|
|
38
|
+
offered = missing.map { |name| ":#{name}" }.join(", ")
|
|
39
|
+
engine.nil? ? "no available search engine offers #{offered}" : "engine #{engine} does not offer #{offered}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when `--engine` names something that is not on offer. An engine
|
|
44
|
+
# registered but reporting `available? == false` is absent from the list for
|
|
45
|
+
# the same reason it is absent from routing: it cannot answer. A future
|
|
46
|
+
# addon whose native build failed will want a kinder message than this one.
|
|
47
|
+
class UnknownEngine < OKF::Error
|
|
48
|
+
attr_reader :name, :available
|
|
49
|
+
|
|
50
|
+
def initialize(name, available)
|
|
51
|
+
@name = name
|
|
52
|
+
@available = available
|
|
53
|
+
super("unknown search engine: #{name} (available: #{available.join(", ")})")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The **declarable** vocabulary: what an engine may claim about itself.
|
|
58
|
+
# Frozen so an engine declaring `:regex` is refused at registration rather
|
|
59
|
+
# than silently never selected — a typo in an addon would otherwise present
|
|
60
|
+
# as "my engine is ignored".
|
|
61
|
+
#
|
|
62
|
+
# `:prefix` lives here and *not* in ROUTABLE on purpose. Nothing asks for
|
|
63
|
+
# its absence, so it selects nothing; what it does is document that this
|
|
64
|
+
# engine grows a term to the tokens it prefixes, which an FTS5 engine may
|
|
65
|
+
# not do by default. Declarative, and honest about being declarative.
|
|
66
|
+
CAPABILITIES = %i[regexp fuzzy prefix].freeze
|
|
67
|
+
|
|
68
|
+
# The **routable** subset: the capabilities a query can actually require,
|
|
69
|
+
# and therefore the only ones that pick an engine. Kept distinct from
|
|
70
|
+
# CAPABILITIES because a capability nothing selects on, filed among the ones
|
|
71
|
+
# that do, is documentation posing as code.
|
|
72
|
+
#
|
|
73
|
+
# Each entry is also the option name the facade hands an engine that
|
|
74
|
+
# declares it — see #engine_options, which is what keeps a meaningful
|
|
75
|
+
# option from reaching an engine that would quietly drop it.
|
|
76
|
+
ROUTABLE = %i[regexp fuzzy].freeze
|
|
77
|
+
|
|
78
|
+
# Chosen when the query requires nothing in particular, which is the
|
|
79
|
+
# overwhelming majority of searches.
|
|
80
|
+
#
|
|
81
|
+
# The scan, not the index, because a one-shot CLI builds an index, asks one
|
|
82
|
+
# question and exits — a build with a single query to amortize it over.
|
|
83
|
+
# Measured end to end: 3.00s vs 0.24s at 1,000 concepts, 0.83s vs 0.18s at
|
|
84
|
+
# 250, and the gap widens with the bundle. Raw-text matching also carries no
|
|
85
|
+
# tokenizer, so the terms that are glued to symbols and therefore
|
|
86
|
+
# unreachable by token (`minifts`, $OKF_HOME) stay findable by default.
|
|
87
|
+
#
|
|
88
|
+
# What it gives up is BM25+ ranking, reachable with `--engine index` — and
|
|
89
|
+
# that is also the engine the browser page runs, so the two rank alike only
|
|
90
|
+
# when the index is named. See .okf/design/search-engines.md.
|
|
91
|
+
DEFAULT_ENGINE = :scan
|
|
92
|
+
|
|
19
93
|
# The searchable fields with their rank weight, strongest signal first.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# mirroring the server page's haystack.
|
|
94
|
+
# In the index engine these ride as MiniFTS per-field `boost`; the scan
|
|
95
|
+
# sums the weights of the fields that matched instead.
|
|
23
96
|
WEIGHTS = {
|
|
24
97
|
"title" => 5,
|
|
25
98
|
"id" => 4,
|
|
@@ -38,53 +111,165 @@ module OKF
|
|
|
38
111
|
# Characters of context kept on each side of the first matched term.
|
|
39
112
|
SNIPPET_RADIUS = 44
|
|
40
113
|
|
|
41
|
-
|
|
42
|
-
|
|
114
|
+
# Edit distance as a fraction of term length, under `fuzzy: true` — the
|
|
115
|
+
# same 0.2 the browser page passes, so both forgive the same typos.
|
|
116
|
+
FUZZY_DISTANCE = 0.2
|
|
117
|
+
|
|
118
|
+
# The unique document key is "<slug>\0<id>": ids are only unique *within* a
|
|
119
|
+
# bundle, and a merge that collided two bundles' same-named concepts would
|
|
120
|
+
# silently drop one.
|
|
121
|
+
KEY_SEPARATOR = "\0"
|
|
122
|
+
|
|
123
|
+
# Append-only and idempotent by id: a second registration of an id already
|
|
124
|
+
# present is a no-op, so a double `require` cannot double the registry and
|
|
125
|
+
# an addon cannot quietly displace a built-in. Deliberately the same shape
|
|
126
|
+
# as the Linter's planned register hook — two extension points, one idiom.
|
|
127
|
+
def self.register(engine)
|
|
128
|
+
rogue = engine.capabilities - CAPABILITIES
|
|
129
|
+
raise ArgumentError, "unknown search capability: #{rogue.join(", ")}" unless rogue.empty?
|
|
130
|
+
|
|
131
|
+
@engines ||= []
|
|
132
|
+
@engines << engine unless @engines.any? { |registered| registered.id == engine.id }
|
|
133
|
+
engine
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A frozen snapshot in registration order. Frozen because the registry is
|
|
137
|
+
# only meant to grow through .register, where the vocabulary is checked.
|
|
138
|
+
def self.engines
|
|
139
|
+
(@engines ||= []).dup.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The router. Naming an engine is an override, not a hint: it is how a
|
|
143
|
+
# caller reaches semantics no capability flag asks for — `--engine scan`
|
|
144
|
+
# means "match raw text", which the flags cannot express because there is
|
|
145
|
+
# nothing to *require*. A named engine that cannot do what was also asked
|
|
146
|
+
# is an error rather than a silent fallback, since falling back would answer
|
|
147
|
+
# a different question than the one that was posed.
|
|
148
|
+
#
|
|
149
|
+
# Unnamed, the default engine leads, then registration order; the first
|
|
150
|
+
# available engine offering *every* required capability answers. Partition
|
|
151
|
+
# rather than sort_by, because sort_by is not stable and registration order
|
|
152
|
+
# is the tie-break.
|
|
153
|
+
def self.engine_for(required, engines: self.engines, name: nil)
|
|
154
|
+
available = engines.select(&:available?)
|
|
155
|
+
return named_engine(name, required, available) unless OKF.blank?(name)
|
|
156
|
+
|
|
157
|
+
default, rest = available.partition { |engine| engine.id == DEFAULT_ENGINE }
|
|
158
|
+
found = (default + rest).find { |engine| (required - engine.capabilities).empty? }
|
|
159
|
+
return found if found
|
|
160
|
+
|
|
161
|
+
raise UnsupportedQuery, required
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.named_engine(name, required, available)
|
|
165
|
+
wanted = name.to_s.downcase
|
|
166
|
+
found = available.find { |engine| engine.id.to_s == wanted }
|
|
167
|
+
raise UnknownEngine.new(name, available.map(&:id)) if found.nil?
|
|
168
|
+
|
|
169
|
+
missing = required - found.capabilities
|
|
170
|
+
raise UnsupportedQuery.new(missing, engine: found.id) unless missing.empty?
|
|
171
|
+
|
|
172
|
+
found
|
|
173
|
+
end
|
|
174
|
+
private_class_method :named_engine
|
|
175
|
+
|
|
176
|
+
def self.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
177
|
+
new([ [ nil, bundle ] ], terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Several bundles as [ slug, bundle ] pairs, ranked into one list with every
|
|
181
|
+
# row labeled by its slug. They share **one** index on purpose: BM25 weighs a
|
|
182
|
+
# term by how rare it is in the corpus, so per-bundle indexes would score the
|
|
183
|
+
# same match differently depending on which bundle it came from. One index
|
|
184
|
+
# makes one corpus, and the merged ranking is comparable by construction.
|
|
185
|
+
def self.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
186
|
+
new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
43
187
|
end
|
|
44
188
|
|
|
45
|
-
# Raises RegexpError on an invalid pattern with `regexp: true
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@
|
|
189
|
+
# Raises RegexpError on an invalid pattern with `regexp: true`, and
|
|
190
|
+
# UnsupportedQuery when no engine can answer — the caller owns turning
|
|
191
|
+
# either into a usage error. `engines:` overrides the registry, which is how
|
|
192
|
+
# the "nothing qualifies" path stays reachable without an addon installed.
|
|
193
|
+
def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
194
|
+
@bundles = bundles
|
|
195
|
+
@terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
|
|
51
196
|
@fields = fields.nil? || fields.empty? ? FIELDS : fields
|
|
197
|
+
@regexp = regexp
|
|
198
|
+
@fuzzy = fuzzy
|
|
199
|
+
@engine = engine
|
|
200
|
+
@engines = engines
|
|
201
|
+
@sources = {}
|
|
52
202
|
end
|
|
53
203
|
|
|
54
204
|
# Ranked match rows, catalog-style identity plus where the terms hit:
|
|
55
|
-
# [{ id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
56
|
-
# ordered by score descending, then id.
|
|
205
|
+
# [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
206
|
+
# ordered by score descending, then slug, then id. `slug` is present only
|
|
207
|
+
# when searching across bundles. No terms means no matches.
|
|
57
208
|
def results
|
|
58
|
-
return [] if @
|
|
209
|
+
return [] if @terms.empty?
|
|
59
210
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
211
|
+
chosen = engine
|
|
212
|
+
rows = chosen.call(documents, @terms, **engine_options(chosen)).map do |hit|
|
|
213
|
+
slug, concept = @sources[hit[:key]]
|
|
214
|
+
row(slug, concept, hit[:matched], hit[:score], hit[:terms])
|
|
215
|
+
end
|
|
216
|
+
rows.sort_by { |row| [ -row[:score], row[:slug].to_s, row[:id] ] }
|
|
64
217
|
end
|
|
65
218
|
|
|
66
219
|
private
|
|
67
220
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
221
|
+
# The engine is chosen by what the query needs, not by a flag naming one.
|
|
222
|
+
# `-e` unambiguously means "regexp semantics", so it routes on its own and
|
|
223
|
+
# says nothing about it — there is no --engine flag to reconcile with.
|
|
224
|
+
def engine
|
|
225
|
+
Search.engine_for(required_capabilities, engines: @engines || Search.engines, name: @engine)
|
|
226
|
+
end
|
|
72
227
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
228
|
+
# What the query requires, in the routable vocabulary. `:prefix` never
|
|
229
|
+
# appears: it is declarable, not routable — nothing asks for its absence.
|
|
230
|
+
def required_capabilities
|
|
231
|
+
ROUTABLE.select { |capability| requested[capability] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `fields:` always, plus exactly the routable options the chosen engine
|
|
235
|
+
# declared it understands.
|
|
236
|
+
#
|
|
237
|
+
# The facade used to hand every engine every option and trust it to ignore
|
|
238
|
+
# what it could not use. Routing makes that harmless in practice — a fuzzy
|
|
239
|
+
# query only ever reaches a :fuzzy engine — but "harmless because something
|
|
240
|
+
# else prevents it" is precisely how an option comes to be dropped in
|
|
241
|
+
# silence the day that something else changes. An engine now receives only
|
|
242
|
+
# what it can act on, so there is nothing left for it to ignore.
|
|
243
|
+
def engine_options(chosen)
|
|
244
|
+
options = { fields: @fields }
|
|
245
|
+
ROUTABLE.each do |capability|
|
|
246
|
+
options[capability] = requested[capability] if chosen.capabilities.include?(capability)
|
|
247
|
+
end
|
|
248
|
+
options
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def requested
|
|
252
|
+
@requested ||= { regexp: @regexp, fuzzy: @fuzzy }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Every concept as an indexable document, keyed uniquely across bundles.
|
|
256
|
+
# @sources keeps the way back, so the index stores no fields of its own.
|
|
257
|
+
def documents
|
|
258
|
+
docs = []
|
|
259
|
+
@bundles.each do |slug, bundle|
|
|
260
|
+
bundle.concepts.each do |concept|
|
|
261
|
+
key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
|
|
262
|
+
@sources[key] = [ slug, concept ]
|
|
263
|
+
docs << field_texts(concept).merge("key" => key)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
docs
|
|
83
267
|
end
|
|
84
268
|
|
|
85
|
-
# { field => original-case text } for
|
|
86
|
-
|
|
87
|
-
|
|
269
|
+
# { field => original-case text } for every searchable field. The index reads
|
|
270
|
+
# all of them; `fields:` narrows the search, not the document.
|
|
271
|
+
def field_texts(concept)
|
|
272
|
+
{
|
|
88
273
|
"id" => concept.id,
|
|
89
274
|
"title" => concept.title.to_s,
|
|
90
275
|
"type" => concept.type.to_s,
|
|
@@ -92,35 +277,46 @@ module OKF
|
|
|
92
277
|
"tags" => Array(concept.tags).join(" "),
|
|
93
278
|
"body" => concept.body
|
|
94
279
|
}
|
|
95
|
-
texts.each_with_object({}) do |(field, text), acc|
|
|
96
|
-
acc[field] = text if @fields.include?(field)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# The union of fields any term hit, in WEIGHTS order — or nil when some term
|
|
101
|
-
# hit nothing (terms are ANDed).
|
|
102
|
-
def matched_fields(texts)
|
|
103
|
-
hits = @matchers.map do |matcher|
|
|
104
|
-
fields = texts.keys.select { |field| hit?(matcher, texts[field]) }
|
|
105
|
-
return nil if fields.empty?
|
|
106
|
-
|
|
107
|
-
fields
|
|
108
|
-
end
|
|
109
|
-
FIELDS.select { |field| hits.flatten.include?(field) }
|
|
110
280
|
end
|
|
111
281
|
|
|
112
|
-
|
|
113
|
-
|
|
282
|
+
# `slug` leads the row so a merged result reads bundle-first, and drops
|
|
283
|
+
# entirely for a single bundle, which has no slug to carry.
|
|
284
|
+
def row(slug, concept, matched, score, terms)
|
|
285
|
+
texts = field_texts(concept)
|
|
286
|
+
built = {
|
|
287
|
+
slug: slug,
|
|
288
|
+
id: concept.id,
|
|
289
|
+
title: (concept.title || concept.id).to_s,
|
|
290
|
+
type: concept.type.to_s,
|
|
291
|
+
area: area_of(concept.id),
|
|
292
|
+
tags: Array(concept.tags).map(&:to_s),
|
|
293
|
+
matched: matched,
|
|
294
|
+
score: score.round(4),
|
|
295
|
+
snippet: snippet(texts, matched, terms)
|
|
296
|
+
}
|
|
297
|
+
built.delete(:slug) if slug.nil?
|
|
298
|
+
built
|
|
114
299
|
end
|
|
115
300
|
|
|
116
301
|
# One bounded context window around the first term that hit the strongest
|
|
117
302
|
# snippet-worthy field; "" when the match needs no context (id/title/type/tags).
|
|
118
|
-
def snippet(texts, matched)
|
|
303
|
+
def snippet(texts, matched, terms)
|
|
119
304
|
field = SNIPPET_FIELDS.find { |candidate| matched.include?(candidate) }
|
|
120
305
|
return "" if field.nil?
|
|
121
306
|
|
|
122
|
-
matcher =
|
|
123
|
-
context(texts[field], matcher)
|
|
307
|
+
matcher = snippet_matcher(texts[field], terms)
|
|
308
|
+
matcher.nil? ? "" : context(texts[field], matcher)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# What to point the window at: the first of the engine's reported matchers
|
|
312
|
+
# that this text actually contains. Engine-agnostic on purpose — the scan
|
|
313
|
+
# reports compiled patterns, the index reports lowercased document terms,
|
|
314
|
+
# and both are things `locate` can find again in the flattened text.
|
|
315
|
+
def snippet_matcher(text, terms)
|
|
316
|
+
down = text.downcase
|
|
317
|
+
Array(terms).find do |term|
|
|
318
|
+
term.is_a?(Regexp) ? term.match?(text) : down.include?(term)
|
|
319
|
+
end
|
|
124
320
|
end
|
|
125
321
|
|
|
126
322
|
def context(text, matcher)
|
data/lib/okf/bundle.rb
CHANGED
|
@@ -111,7 +111,7 @@ module OKF
|
|
|
111
111
|
id = concept.id
|
|
112
112
|
{
|
|
113
113
|
id: id,
|
|
114
|
-
title: (concept.title
|
|
114
|
+
title: OKF.blank?(concept.title) ? File.basename(id) : concept.title.to_s,
|
|
115
115
|
type: concept.type.to_s,
|
|
116
116
|
description: concept.description.to_s,
|
|
117
117
|
tags: Array(concept.tags).map(&:to_s),
|
|
@@ -159,7 +159,7 @@ module OKF
|
|
|
159
159
|
listing: here.map do |concept|
|
|
160
160
|
{
|
|
161
161
|
id: concept.id,
|
|
162
|
-
title: (concept.title
|
|
162
|
+
title: OKF.blank?(concept.title) ? File.basename(concept.id) : concept.title.to_s,
|
|
163
163
|
description: concept.description.to_s,
|
|
164
164
|
type: concept.type.to_s,
|
|
165
165
|
tags: Array(concept.tags).map(&:to_s)
|
data/lib/okf/cli.rb
CHANGED
|
@@ -92,6 +92,12 @@ module OKF
|
|
|
92
92
|
# together.
|
|
93
93
|
ALL_REF = "@all"
|
|
94
94
|
|
|
95
|
+
# The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
|
|
96
|
+
# the flag vocabulary in the shell, where it belongs, and lets the message end
|
|
97
|
+
# with the fix rather than only the complaint: an engine that *can* do what was
|
|
98
|
+
# asked is named, so the next command is obvious.
|
|
99
|
+
CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
|
|
100
|
+
|
|
95
101
|
# The row shape each list view emits, so `--fields`/`--except` can be checked
|
|
96
102
|
# against a name even when the result is empty. Without it the typo guard
|
|
97
103
|
# keyed off the data: `--fields bogus` was a usage error against a bundle
|
|
@@ -183,20 +189,27 @@ module OKF
|
|
|
183
189
|
0
|
|
184
190
|
end
|
|
185
191
|
|
|
186
|
-
#
|
|
187
|
-
# and extended to bodies. Terms after the
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
192
|
+
# Ranked text retrieval — the browser page's search brought to the CLI on the
|
|
193
|
+
# same engine (a MiniFTS index) and extended to bodies. Terms after the
|
|
194
|
+
# directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
|
|
195
|
+
# --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
|
|
196
|
+
# where they hit (title > id > tags > type/description > body) and carry one
|
|
197
|
+
# bounded context snippet, so "which concept covers X?" costs a few rows, not
|
|
198
|
+
# a body read. Advisory read: exit 0 even with no matches. Exact by default —
|
|
199
|
+
# the consuming agent is the fuzzy layer, until it asks not to be.
|
|
193
200
|
def search(argv)
|
|
194
|
-
options = { json: false, regexp: false }
|
|
201
|
+
options = { json: false, regexp: false, fuzzy: false, engine: nil }
|
|
195
202
|
parser = OptionParser.new do |o|
|
|
196
|
-
o.banner = "Usage: okf search <dir|@slug…|@all> <term
|
|
203
|
+
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
|
|
204
|
+
search_engine_note(o)
|
|
197
205
|
json_flags(o, options, "emit the matches as JSON")
|
|
198
206
|
projection_flags(o, options)
|
|
199
|
-
o.on("-e", "--regexp", "
|
|
207
|
+
o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
|
|
208
|
+
"than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
|
|
209
|
+
o.on("--fuzzy",
|
|
210
|
+
"tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
|
|
211
|
+
o.on("--engine NAME", "match with this engine instead of the default",
|
|
212
|
+
"(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
|
|
200
213
|
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
201
214
|
filter_flags(o, options, :type, :area, :tag)
|
|
202
215
|
help_flag(o)
|
|
@@ -237,11 +250,19 @@ module OKF
|
|
|
237
250
|
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
238
251
|
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
239
252
|
|
|
253
|
+
# Two query languages, not two dials on one: a regexp is matched against raw
|
|
254
|
+
# text, --fuzzy is an edit distance over indexed tokens. Silently honouring
|
|
255
|
+
# one and dropping the other would answer a question nobody asked.
|
|
256
|
+
if options[:regexp] && options[:fuzzy]
|
|
257
|
+
return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
|
|
258
|
+
end
|
|
259
|
+
|
|
240
260
|
return multi_search(pairs, terms, options) if pairs
|
|
241
261
|
|
|
242
262
|
folder = OKF::Bundle::Folder.load(dir)
|
|
243
263
|
report_skipped(folder)
|
|
244
|
-
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp]
|
|
264
|
+
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
|
|
265
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
245
266
|
keep = filter_ids(folder, options)
|
|
246
267
|
rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
247
268
|
return print_search_json(dir, terms, rows, options) if options[:json]
|
|
@@ -250,6 +271,10 @@ module OKF
|
|
|
250
271
|
0
|
|
251
272
|
rescue RegexpError => e
|
|
252
273
|
usage_error("invalid pattern: #{e.message}")
|
|
274
|
+
rescue OKF::Bundle::Search::UnknownEngine => e
|
|
275
|
+
usage_error(e.message)
|
|
276
|
+
rescue OKF::Bundle::Search::UnsupportedQuery => e
|
|
277
|
+
usage_error(unsupported_query_message(e))
|
|
253
278
|
end
|
|
254
279
|
|
|
255
280
|
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
@@ -324,22 +349,29 @@ module OKF
|
|
|
324
349
|
[ [ ref_slugs[path], path ] ]
|
|
325
350
|
end
|
|
326
351
|
|
|
327
|
-
# Search
|
|
328
|
-
#
|
|
329
|
-
#
|
|
352
|
+
# Search every bundle at once and merge the rankings, each row labeled with
|
|
353
|
+
# its bundle's slug. The bundles go in as *one* corpus rather than one search
|
|
354
|
+
# each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
|
|
355
|
+
# statistics and then interleaving the lists would let the same match score
|
|
356
|
+
# differently for no reason a reader could see. One index, one ranking.
|
|
357
|
+
#
|
|
358
|
+
# Filters stay per-bundle — they are per-folder questions — so they apply to
|
|
359
|
+
# the merged rows by (slug, id) afterwards.
|
|
330
360
|
def multi_search(pairs, terms, options)
|
|
331
|
-
|
|
361
|
+
bundles = []
|
|
362
|
+
keeps = {}
|
|
332
363
|
total = 0
|
|
333
364
|
pairs.each do |slug, dir|
|
|
334
365
|
folder = OKF::Bundle::Folder.load(dir)
|
|
335
366
|
report_skipped(folder)
|
|
336
367
|
total += folder.bundle.concepts.size
|
|
337
|
-
|
|
368
|
+
bundles << [ slug, folder.bundle ]
|
|
338
369
|
keep = filter_ids(folder, options)
|
|
339
|
-
|
|
340
|
-
found.each { |row| rows << { slug: slug }.merge(row) }
|
|
370
|
+
keeps[slug] = keep unless keep.nil?
|
|
341
371
|
end
|
|
342
|
-
rows
|
|
372
|
+
rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
|
|
373
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
374
|
+
rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
|
|
343
375
|
return print_multi_search_json(pairs, terms, rows, options) if options[:json]
|
|
344
376
|
|
|
345
377
|
print_multi_search(pairs, terms, rows, total)
|
|
@@ -400,7 +432,7 @@ module OKF
|
|
|
400
432
|
o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
|
|
401
433
|
o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
402
434
|
o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
|
|
403
|
-
o.on("--layout NAME", OKF::
|
|
435
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
404
436
|
help_flag(o)
|
|
405
437
|
end
|
|
406
438
|
dirs = positional_dirs(parser, argv) or return 2
|
|
@@ -531,7 +563,7 @@ module OKF
|
|
|
531
563
|
# self-contained HTML file (bodies, catalog, index, logs baked in, no server
|
|
532
564
|
# needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
|
|
533
565
|
def render(argv)
|
|
534
|
-
require "okf/
|
|
566
|
+
require "okf/render/graph"
|
|
535
567
|
|
|
536
568
|
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
537
569
|
parser = OptionParser.new do |o|
|
|
@@ -539,14 +571,14 @@ module OKF
|
|
|
539
571
|
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
540
572
|
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
541
573
|
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
542
|
-
o.on("--layout NAME", OKF::
|
|
574
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
543
575
|
help_flag(o)
|
|
544
576
|
end
|
|
545
577
|
dir = positional_dir(parser, argv) or return 2
|
|
546
578
|
|
|
547
579
|
folder = OKF::Bundle::Folder.load(dir)
|
|
548
580
|
report_skipped(folder)
|
|
549
|
-
html = OKF::
|
|
581
|
+
html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
|
|
550
582
|
if options[:output]
|
|
551
583
|
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
552
584
|
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
@@ -1117,6 +1149,43 @@ module OKF
|
|
|
1117
1149
|
end
|
|
1118
1150
|
end
|
|
1119
1151
|
|
|
1152
|
+
# The registered engines, read at parse time so an addon that registers one
|
|
1153
|
+
# shows up in `--help` without the CLI knowing it exists.
|
|
1154
|
+
def engine_names
|
|
1155
|
+
OKF::Bundle::Search.engines.map(&:id).join(" | ")
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def unsupported_query_message(error)
|
|
1159
|
+
wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
|
|
1160
|
+
return "no available search engine offers #{wanted}" if error.engine.nil?
|
|
1161
|
+
|
|
1162
|
+
able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
|
|
1163
|
+
message = "--engine #{error.engine} does not support #{wanted}"
|
|
1164
|
+
message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
|
|
1165
|
+
message
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# The engine story, told once, in the only place there is to tell it. `search`
|
|
1169
|
+
# routes on what the query needs — a pattern needs the scan, --fuzzy needs the
|
|
1170
|
+
# index — and says nothing about it at runtime: no note on stderr, nothing in
|
|
1171
|
+
# the header, and deliberately no --engine flag. So this is where a user learns
|
|
1172
|
+
# that the exactness a token index gives up is still reachable, and that -e is
|
|
1173
|
+
# how. Without it that capability is present but undiscoverable.
|
|
1174
|
+
#
|
|
1175
|
+
# It leads rather than trails because #help_flag registers -h with `on_tail`,
|
|
1176
|
+
# which OptionParser renders after every separator: a closing paragraph would
|
|
1177
|
+
# print *above* the -h line and split the option list in half. Stating the
|
|
1178
|
+
# matching model before the flags reads better anyway.
|
|
1179
|
+
def search_engine_note(parser)
|
|
1180
|
+
parser.separator ""
|
|
1181
|
+
parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
|
|
1182
|
+
parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
|
|
1183
|
+
parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
|
|
1184
|
+
parser.separator "better ranking and the engine the browser page runs, at the cost of that"
|
|
1185
|
+
parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
|
|
1186
|
+
parser.separator ""
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1120
1189
|
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
1121
1190
|
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
1122
1191
|
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
@@ -1281,7 +1350,8 @@ module OKF
|
|
|
1281
1350
|
return resolve_registered(arg) if arg.start_with?("@")
|
|
1282
1351
|
|
|
1283
1352
|
unless File.directory?(arg)
|
|
1284
|
-
@err.puts "error: #{arg} is not a directory"
|
|
1353
|
+
@err.puts "error: #{arg} is not a directory or a registry ref " \
|
|
1354
|
+
"(@slug names a registered bundle, @ the default; okf registry list)"
|
|
1285
1355
|
return nil
|
|
1286
1356
|
end
|
|
1287
1357
|
arg
|
|
@@ -1692,7 +1762,7 @@ module OKF
|
|
|
1692
1762
|
loose <dir|@slug> [--json] list files with no graph links, by folder
|
|
1693
1763
|
validate <dir|@slug> [--json] check OKF v0.1 conformance
|
|
1694
1764
|
|
|
1695
|
-
search <dir|@slug…|@all> <term…> [-e] [...]
|
|
1765
|
+
search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] find concepts by text or regexp, ranked (@all: every bundle)
|
|
1696
1766
|
index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
1697
1767
|
stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
1698
1768
|
types <dir|@slug> [--json] [filters] list types with their concepts, by count
|