okf 1.7.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.
@@ -8,10 +8,14 @@ module OKF
8
8
  #
9
9
  # It parses eagerly: each concept file becomes an OKF::Concept, each
10
10
  # index.md/log.md is kept as raw text (its structure is validated as text), and
11
- # a concept file whose frontmatter does not parse is retained as an unparseable
12
- # entry (carrying the ParseError message, so §9.1 can report it) rather than
13
- # dropped or raised. Every read goes through Path.join_under! so a
14
- # symlinked or crafted path cannot escape the bundle root.
11
+ # a file the reader cannot use — frontmatter that does not parse, or a file it
12
+ # cannot open at all is retained as an unparseable entry (carrying the
13
+ # ParseError message or the errno, so §9.1 can report it) rather than dropped
14
+ # or raised. That tolerance is the whole §9 best-effort promise: one bad file
15
+ # never breaks the rest, and this is the read every verb shares. Every read
16
+ # goes through Path.join_under! so a symlinked or crafted path cannot escape
17
+ # the bundle root — that guard still raises, because a path leaving the root
18
+ # is not a bad file, it is a bundle lying about its shape.
15
19
  class Reader
16
20
  def self.read(dir)
17
21
  new(dir).read
@@ -39,6 +43,19 @@ module OKF
39
43
  end
40
44
  rescue Markdown::Frontmatter::ParseError => e
41
45
  unparseable << Entry.new(path: path, content: content, error: e.message)
46
+ rescue SystemCallError => e
47
+ # A file that cannot be opened is one unusable file, not a broken
48
+ # bundle. Letting the errno out of here breaks "one bad file never
49
+ # breaks the rest" for every verb at once — the read is the one path
50
+ # they all share — and it breaks it in the worst way: a backtrace,
51
+ # under an exit code that claims the bundle is non-conformant. So it
52
+ # joins the same bucket a bad frontmatter block does, and §9.1 reports
53
+ # it naming the file and the errno.
54
+ #
55
+ # Its content is "" rather than nil: unknown, but every analyzer reads
56
+ # it as text, and empty is the honest shape of a file we never saw —
57
+ # no links to resolve, no encoding to be invalid, nothing claimed.
58
+ unparseable << Entry.new(path: path, content: "", error: e.message)
42
59
  end
43
60
  end
44
61
 
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minifts"
4
+ require "okf/bundle/search"
5
+
6
+ module OKF
7
+ class Bundle
8
+ class Search
9
+ # The default engine: a MiniFTS full-text index — the same engine, and the
10
+ # same BM25+ arithmetic, the browser page already runs as MiniSearch, so a
11
+ # Ruby-built index and the page's rank identically.
12
+ #
13
+ # Matching is by *token*: a term matches a whole word or a word it prefixes
14
+ # ("dedup" reaches "deduplication"), and `fuzzy:` opts into typo tolerance.
15
+ # The index is built per call — see .okf/capabilities/search.md for why that
16
+ # ceiling stands and what lifts it.
17
+ module Index
18
+ CAPABILITIES = %i[fuzzy prefix].freeze
19
+
20
+ class << self
21
+ def id
22
+ :index
23
+ end
24
+
25
+ def capabilities
26
+ CAPABILITIES
27
+ end
28
+
29
+ # minifts is a hard runtime dependency with no native extension, so it
30
+ # is here whenever the gem is. An addon backed by a native build is the
31
+ # case this predicate exists for.
32
+ def available?
33
+ true
34
+ end
35
+
36
+ # `fields:` narrows where a term may hit, so a field the caller excluded
37
+ # can neither match nor be credited. The hit's `terms` are MiniFTS's
38
+ # matched *document* terms — already lowercased, and present in the text
39
+ # verbatim even when the query only prefixed them.
40
+ def call(documents, terms, fields:, fuzzy: false, **_options)
41
+ index = MiniFTS.new(fields: FIELDS, id_field: "key")
42
+ index.add_all(documents)
43
+
44
+ options = { combine_with: "AND", prefix: true, boost: WEIGHTS, fields: fields }
45
+ options[:fuzzy] = FUZZY_DISTANCE if fuzzy
46
+
47
+ index.search(terms.join(" "), options).map do |hit|
48
+ { key: hit[:id], matched: matched_in(hit), score: hit[:score], terms: hit[:terms] }
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # The union of fields any term hit, in WEIGHTS order. MiniFTS reports it
55
+ # per query term as { term => [field, …] }.
56
+ def matched_in(hit)
57
+ FIELDS.select { |field| hit[:match].any? { |_term, found| found.include?(field) } }
58
+ end
59
+ end
60
+
61
+ Search.register(self)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "okf/bundle/search"
4
+
5
+ module OKF
6
+ class Bundle
7
+ class Search
8
+ # The linear engine: terms matched against raw field text, one document at a
9
+ # time. No index, so nothing is tokenized and nothing is normalized — a
10
+ # phrase stays a phrase, `7.2.0` stays one string, and an infix matches.
11
+ # That is the exactness a token index gives up, and the reason this engine
12
+ # survived the swap.
13
+ #
14
+ # Two readings of a term, and the engine is the *raw text* half of the split
15
+ # rather than the regexp half:
16
+ #
17
+ # `regexp: false` — literal substring, which is what this engine did
18
+ # before the index landed, and what `--engine scan`
19
+ # restores. Terms are escaped, so `7.2.0` does not
20
+ # match `7x2y0` and `[draft]` is not a character class.
21
+ # `regexp: true` — the term is a pattern, opted into with `-e`.
22
+ #
23
+ # Conflating the two would make choosing the engine silently change what the
24
+ # terms mean, and turn an ordinary term like `review (pending` into exit 2.
25
+ #
26
+ # Scoring is the summed weight of the fields that matched: absolute, and so
27
+ # comparable across bundles without a corpus to normalize against.
28
+ module Scan
29
+ CAPABILITIES = %i[regexp].freeze
30
+
31
+ class << self
32
+ def id
33
+ :scan
34
+ end
35
+
36
+ def capabilities
37
+ CAPABILITIES
38
+ end
39
+
40
+ # No backing store to fail: the engine is Regexp and Enumerable.
41
+ def available?
42
+ true
43
+ end
44
+
45
+ # Raises RegexpError on an invalid pattern under `regexp: true` — the
46
+ # caller owns turning that into a usage error. A literal term cannot
47
+ # raise, because it is escaped before it is compiled. The hit's `terms`
48
+ # are the compiled patterns, which is what the facade points its snippet
49
+ # window at.
50
+ def call(documents, terms, fields:, regexp: false, **_options)
51
+ patterns = terms.map do |term|
52
+ Regexp.new(regexp ? term : Regexp.escape(term), Regexp::IGNORECASE)
53
+ end
54
+
55
+ hits = []
56
+ documents.each do |document|
57
+ matched = matched_fields(document, patterns, fields)
58
+ next if matched.nil?
59
+
60
+ hits << {
61
+ key: document["key"],
62
+ matched: matched,
63
+ score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
64
+ terms: patterns
65
+ }
66
+ end
67
+ hits
68
+ end
69
+
70
+ private
71
+
72
+ # The union of fields any pattern hit, in WEIGHTS order — or nil when
73
+ # some pattern hit nothing (terms are ANDed).
74
+ def matched_fields(document, patterns, fields)
75
+ hits = patterns.map do |pattern|
76
+ found = fields.select { |field| pattern.match?(document[field]) }
77
+ return nil if found.empty?
78
+
79
+ found
80
+ end
81
+ FIELDS.select { |field| hits.any? { |found| found.include?(field) } }
82
+ end
83
+ end
84
+
85
+ Search.register(self)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -2,24 +2,97 @@
2
2
 
3
3
  module OKF
4
4
  class Bundle
5
- # Deterministic text retrieval over an in-memory bundle the browser page's
6
- # search brought server-side and extended to bodies. Terms are ANDed: every
7
- # term must hit at least one searched field, though not necessarily the same
8
- # one. A term is a case-insensitive substring, or a Ruby regular expression
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
- # Deliberately not fuzzy: the consuming agent is the fuzzy layer synonyms
14
- # and vocabulary drift are judgment over the index map, not string distance.
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 share
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
- # A concept's score sums the weights of the fields that matched; hitting a
21
- # field twice does not stack. Tags match against the space-joined list,
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
- def self.call(bundle, terms, fields: nil, regexp: false)
42
- new(bundle, terms, fields: fields, regexp: regexp).results
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` — the caller
46
- # owns turning that into a usage error.
47
- def initialize(bundle, terms, fields: nil, regexp: false)
48
- @bundle = bundle
49
- raw = Array(terms).reject { |term| OKF.blank?(term) }
50
- @matchers = raw.map { |term| regexp ? Regexp.new(term.to_s, Regexp::IGNORECASE) : term.to_s.downcase }
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. No terms means no matches.
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 @matchers.empty?
209
+ return [] if @terms.empty?
59
210
 
60
- @bundle.concepts
61
- .map { |concept| match(concept) }
62
- .compact
63
- .sort_by { |row| [ -row[:score], row[:id] ] }
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
- def match(concept)
69
- texts = searchable_texts(concept)
70
- matched = matched_fields(texts)
71
- return nil if matched.nil?
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
- id: concept.id,
75
- title: (concept.title || concept.id).to_s,
76
- type: concept.type.to_s,
77
- area: area_of(concept.id),
78
- tags: Array(concept.tags).map(&:to_s),
79
- matched: matched,
80
- score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
81
- snippet: snippet(texts, matched)
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 the fields this search reads.
86
- def searchable_texts(concept)
87
- texts = {
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
- def hit?(matcher, text)
113
- matcher.is_a?(Regexp) ? matcher.match?(text) : text.downcase.include?(matcher)
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 = @matchers.find { |candidate| hit?(candidate, texts[field]) }
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 || id).to_s,
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 || concept.id).to_s,
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)