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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +368 -0
- data/README.md +78 -24
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/graph.rb +5 -1
- data/lib/okf/bundle/linter.rb +6 -1
- data/lib/okf/bundle/reader.rb +21 -4
- 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 +917 -131
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/{server → render}/graph/template.html.erb +1081 -130
- data/lib/okf/{server → render}/graph.rb +67 -9
- data/lib/okf/server/app.rb +23 -45
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +28 -17
- 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 +50 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +200 -23
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +21 -3
data/lib/okf/bundle/reader.rb
CHANGED
|
@@ -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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
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)
|