ruby-spacy 0.5.0 → 0.7.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/.github/workflows/ci.yml +97 -0
- data/CHANGELOG.md +58 -0
- data/Gemfile +7 -8
- data/README.md +89 -5
- data/docs/syntax_trees.md +55 -0
- data/examples/rsyntaxtree/outputs/tree_ar_projection.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_de_projection.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_en_chunks.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_en_morphology.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_en_projection.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_ja_chunks.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_ja_projection.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_ru_morphology.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_ru_projection.png +0 -0
- data/examples/rsyntaxtree/outputs/tree_zh_projection.png +0 -0
- data/examples/rsyntaxtree/syntax_tree_ar.rb +36 -0
- data/examples/rsyntaxtree/syntax_tree_de.rb +19 -0
- data/examples/rsyntaxtree/syntax_tree_en.rb +26 -0
- data/examples/rsyntaxtree/syntax_tree_ja.rb +21 -0
- data/examples/rsyntaxtree/syntax_tree_ru.rb +23 -0
- data/examples/rsyntaxtree/syntax_tree_zh.rb +21 -0
- data/examples/rule_based_matching/creating_spans_from_matches.rb +1 -1
- data/lib/ruby-spacy/syntax_tree.rb +304 -0
- data/lib/ruby-spacy/version.rb +1 -1
- data/lib/ruby-spacy.rb +210 -28
- data/ruby-spacy.gemspec +6 -6
- metadata +52 -21
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spacy
|
|
4
|
+
# Converts spaCy parse results into rsyntaxtree bracket notation and renders
|
|
5
|
+
# them with the rsyntaxtree gem. This is an internal implementation module;
|
|
6
|
+
# the public API is {Doc#syntax_tree} and {Span#syntax_tree}.
|
|
7
|
+
#
|
|
8
|
+
# rsyntaxtree is a soft dependency: it is required (>= 2.4.0, for
|
|
9
|
+
# `RSyntaxTree.escape`) on the first call, not at load time.
|
|
10
|
+
module SyntaxTree
|
|
11
|
+
FORMATS = %i[bracket svg png pdf tikz json].freeze
|
|
12
|
+
STYLES = %i[projection chunks].freeze
|
|
13
|
+
|
|
14
|
+
# rsyntaxtree drawing defaults chosen for the wide, shallow trees produced
|
|
15
|
+
# here. User-supplied options take precedence (except hyphen, which the
|
|
16
|
+
# escaping depends on).
|
|
17
|
+
RENDER_DEFAULTS = {
|
|
18
|
+
hyphen: "literal",
|
|
19
|
+
polyline: "on",
|
|
20
|
+
tidy: "medium",
|
|
21
|
+
leafstyle: "nothing",
|
|
22
|
+
color: "modern"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Phrase label mapping from the head's POS tag
|
|
26
|
+
PHRASE_LABEL = {
|
|
27
|
+
"NOUN" => "NP", "PROPN" => "NP", "PRON" => "NP", "NUM" => "NP",
|
|
28
|
+
"VERB" => "VP", "AUX" => "VP", "ADP" => "PP", "ADJ" => "AdjP",
|
|
29
|
+
"ADV" => "AdvP", "DET" => "DP", "SCONJ" => "CP", "CCONJ" => "ConjP"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
ENT_BACKGROUND = "orange"
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @param source [Doc, Span] the document or span to convert
|
|
36
|
+
# @return [String] the bracket notation (format: :bracket), the rendered
|
|
37
|
+
# output (SVG/TikZ/JSON text, or PNG/PDF binary), depending on format
|
|
38
|
+
def generate(source, format: :bracket, style: :projection, morphology: false,
|
|
39
|
+
entities: true, punctuation: false, **render_opts)
|
|
40
|
+
ensure_rsyntaxtree!
|
|
41
|
+
format = format.to_sym
|
|
42
|
+
style = style.to_sym
|
|
43
|
+
validate_options!(format, style, render_opts)
|
|
44
|
+
|
|
45
|
+
bracket = bracket_for(source, style: style, morphology: morphology,
|
|
46
|
+
entities: entities, punctuation: punctuation)
|
|
47
|
+
return bracket if format == :bracket
|
|
48
|
+
|
|
49
|
+
# Right-to-left scripts are drawn mirrored (leaves run right to left)
|
|
50
|
+
# unless the caller says otherwise. The notation itself is unaffected
|
|
51
|
+
render_opts = { mirror: "on" }.merge(render_opts) if rtl?(source)
|
|
52
|
+
render(bracket, format, render_opts)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# True when the source's language writes right-to-left. Asked from the
|
|
58
|
+
# pipeline itself (`Defaults.writing_system`) rather than a hardcoded
|
|
59
|
+
# language list, so external pipelines (spacy-stanza, spacy-udpipe) work
|
|
60
|
+
# too. Any failure (e.g. no `Defaults`) falls back to left-to-right;
|
|
61
|
+
# note this also swallows a genuine detection failure, so if an RTL
|
|
62
|
+
# tree ever renders unmirrored, this fallback is the first place to check
|
|
63
|
+
def rtl?(source)
|
|
64
|
+
py_nlp = source.is_a?(Spacy::Span) ? source.doc.py_nlp : source.py_nlp
|
|
65
|
+
direction = Spacy::Builtins.getattr(py_nlp.Defaults, "writing_system")["direction"]
|
|
66
|
+
direction.to_s == "rtl"
|
|
67
|
+
rescue StandardError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_rsyntaxtree!
|
|
72
|
+
return if @loaded
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
require "rsyntaxtree"
|
|
76
|
+
rescue LoadError
|
|
77
|
+
raise LoadError, "syntax_tree requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree"
|
|
78
|
+
end
|
|
79
|
+
unless RSyntaxTree.respond_to?(:escape)
|
|
80
|
+
raise LoadError, "syntax_tree requires rsyntaxtree >= 2.4.0 (found #{RSyntaxTree::VERSION})"
|
|
81
|
+
end
|
|
82
|
+
@loaded = true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_options!(format, style, render_opts)
|
|
86
|
+
unless FORMATS.include?(format)
|
|
87
|
+
raise ArgumentError, "unknown format: #{format.inspect} (expected one of: #{FORMATS.join(', ')})"
|
|
88
|
+
end
|
|
89
|
+
unless STYLES.include?(style)
|
|
90
|
+
raise ArgumentError, "unknown style: #{style.inspect} (expected one of: #{STYLES.join(', ')})"
|
|
91
|
+
end
|
|
92
|
+
if render_opts.key?(:hyphen)
|
|
93
|
+
raise ArgumentError, "hyphen: cannot be overridden (the notation is escaped for hyphen: :literal)"
|
|
94
|
+
end
|
|
95
|
+
unknown = render_opts.keys.map(&:to_sym) - ::DEFAULT_OPTS.keys
|
|
96
|
+
unless unknown.empty?
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"unknown rsyntaxtree option(s): #{unknown.join(', ')} (valid: #{::DEFAULT_OPTS.keys.join(', ')})"
|
|
99
|
+
end
|
|
100
|
+
if format == :bracket && !render_opts.empty?
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"drawing options (#{render_opts.keys.join(', ')}) have no effect with format: :bracket"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render(bracket, format, render_opts)
|
|
107
|
+
params = RENDER_DEFAULTS.merge(render_opts).merge(data: bracket)
|
|
108
|
+
gen = RSyntaxTree::RSGenerator.new(params)
|
|
109
|
+
case format
|
|
110
|
+
when :svg then gen.draw_svg
|
|
111
|
+
when :json then gen.draw_json
|
|
112
|
+
when :tikz then gen.draw_tikz
|
|
113
|
+
when :png then gen.draw_png.force_encoding(Encoding::BINARY)
|
|
114
|
+
when :pdf then gen.draw_pdf.force_encoding(Encoding::BINARY)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Escapes a string for the bracket notation. `as:` follows
|
|
119
|
+
# `RSyntaxTree.escape` (:word for leaf words, :label for entity labels,
|
|
120
|
+
# :cell for AVM cells).
|
|
121
|
+
def escape(text, as:, context: nil)
|
|
122
|
+
RSyntaxTree.escape(text, as: as, hyphen: :literal, apostrophe: :keep)
|
|
123
|
+
rescue ArgumentError => e
|
|
124
|
+
where = context ? " (#{context})" : ""
|
|
125
|
+
raise ArgumentError, "syntax_tree: cannot escape#{where}: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def bracket_for(source, style:, morphology:, entities:, punctuation:)
|
|
129
|
+
tokens, root, root_label = tree_scope(source)
|
|
130
|
+
chunks = chunk_spans(source)
|
|
131
|
+
if chunks.nil? && style == :chunks
|
|
132
|
+
raise ArgumentError,
|
|
133
|
+
"noun chunks are not available for this language/model, so style: :chunks cannot be used"
|
|
134
|
+
end
|
|
135
|
+
chunks ||= []
|
|
136
|
+
ents = entities ? ent_map(source) : {}
|
|
137
|
+
|
|
138
|
+
case style
|
|
139
|
+
when :chunks
|
|
140
|
+
chunks_bracket(tokens, chunks, ents, morphology: morphology, punctuation: punctuation)
|
|
141
|
+
else
|
|
142
|
+
projection_bracket(root, chunks, ents, root_label: root_label,
|
|
143
|
+
morphology: morphology, punctuation: punctuation)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns [tokens, root_token, root_label]. All positions are doc-based
|
|
148
|
+
# (Span#tokens / Token#i are doc-based, which matches chunk and entity
|
|
149
|
+
# offsets).
|
|
150
|
+
def tree_scope(source)
|
|
151
|
+
case source
|
|
152
|
+
when Spacy::Doc
|
|
153
|
+
py_doc = source.py_doc
|
|
154
|
+
unless py_doc.has_annotation("DEP")
|
|
155
|
+
raise ArgumentError, "syntax_tree requires a dependency parse (the pipeline has no parser)"
|
|
156
|
+
end
|
|
157
|
+
if py_doc.has_annotation("SENT_START") && source.sents.size > 1
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"syntax_tree requires a single sentence; " \
|
|
160
|
+
"use doc.sents.map { |s| s.syntax_tree } for a multi-sentence doc"
|
|
161
|
+
end
|
|
162
|
+
tokens = source.tokens
|
|
163
|
+
# The root is the token that is its own head. Comparing dep strings
|
|
164
|
+
# would tie this to an annotation scheme (spaCy's trained pipelines
|
|
165
|
+
# use "ROOT" while UD-style pipelines such as Stanza use "root")
|
|
166
|
+
root = tokens.find { |t| t.head.i == t.i }
|
|
167
|
+
raise ArgumentError, "syntax_tree: no root token found (no dependency parse)" unless root
|
|
168
|
+
|
|
169
|
+
[tokens, root, "S"]
|
|
170
|
+
when Spacy::Span
|
|
171
|
+
unless source.py_span.doc.has_annotation("DEP")
|
|
172
|
+
raise ArgumentError, "syntax_tree requires a dependency parse (the pipeline has no parser)"
|
|
173
|
+
end
|
|
174
|
+
tokens = source.tokens
|
|
175
|
+
raise ArgumentError, "syntax_tree: empty span" if tokens.empty?
|
|
176
|
+
|
|
177
|
+
first_i = tokens.first.i
|
|
178
|
+
last_i = tokens.last.i
|
|
179
|
+
roots = tokens.select { |t| t.head.i == t.i || t.head.i < first_i || t.head.i > last_i }
|
|
180
|
+
unless roots.size == 1
|
|
181
|
+
raise ArgumentError,
|
|
182
|
+
"syntax_tree requires a span with a single root (e.g. a sentence from doc.sents)"
|
|
183
|
+
end
|
|
184
|
+
root = roots.first
|
|
185
|
+
unless root.left_edge.i == first_i && root.right_edge.i == last_i
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"syntax_tree requires a span that is a complete subtree " \
|
|
188
|
+
"(e.g. a sentence from doc.sents or a noun chunk)"
|
|
189
|
+
end
|
|
190
|
+
is_sentence = source.py_span.sent.start == source.py_span.start &&
|
|
191
|
+
source.py_span.sent.end == source.py_span.end
|
|
192
|
+
[tokens, root, is_sentence ? "S" : PHRASE_LABEL.fetch(root.pos, "XP")]
|
|
193
|
+
else
|
|
194
|
+
raise ArgumentError, "syntax_tree expects a Spacy::Doc or Spacy::Span"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Noun chunk spans as doc-based [start, end) pairs, or nil when the
|
|
199
|
+
# language/model has no noun chunk iterator (spaCy error E894).
|
|
200
|
+
def chunk_spans(source)
|
|
201
|
+
source.noun_chunks.map { |c| [c.py_span.start, c.py_span.end] }
|
|
202
|
+
rescue PyCall::PyError => e
|
|
203
|
+
raise unless e.message.include?("E894")
|
|
204
|
+
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Entity spans as a doc-based [start, end) => label map
|
|
209
|
+
def ent_map(source)
|
|
210
|
+
source.ents.to_h { |e| [[e.py_span.start, e.py_span.end], e.label] }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def leaf(token, morphology:)
|
|
214
|
+
word = escape(token.text, as: :word, context: "token #{token.text.inspect}")
|
|
215
|
+
return "[#{token.pos} #{word}]" unless morphology
|
|
216
|
+
|
|
217
|
+
rows = ["pos\\t#{escape(token.pos, as: :cell)}"]
|
|
218
|
+
morph = token.morphology(hash: false)
|
|
219
|
+
unless morph.empty?
|
|
220
|
+
morph.split("|").each do |kv|
|
|
221
|
+
k, v = kv.split("=", 2)
|
|
222
|
+
rows << "#{escape(k, as: :cell)}\\t#{escape(v, as: :cell)}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
"[#(#{rows.join('\n')}#) #{word}]"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# "%NP" for a bare chunk; "%@orange:NP\nLABEL" for one matching an entity
|
|
229
|
+
def chunk_label(base, ent_label)
|
|
230
|
+
return "%#{base}" unless ent_label
|
|
231
|
+
|
|
232
|
+
"%@#{ENT_BACKGROUND}:#{base}\\n#{escape(ent_label, as: :label, context: 'entity label')}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A shallow tree: [S ...] with chunks as [%NP [POS w] ...] and other
|
|
236
|
+
# tokens as plain leaves. Chunks matching an entity get a colored
|
|
237
|
+
# background and a second label line.
|
|
238
|
+
def chunks_bracket(tokens, chunks, ents, morphology:, punctuation:)
|
|
239
|
+
i = 0
|
|
240
|
+
parts = []
|
|
241
|
+
while i < tokens.size
|
|
242
|
+
token = tokens[i]
|
|
243
|
+
chunk = chunks.find { |s, _e| s == token.i }
|
|
244
|
+
if chunk
|
|
245
|
+
s, e = chunk
|
|
246
|
+
chunk_tokens = tokens.select { |t| t.i >= s && t.i < e }
|
|
247
|
+
label = chunk_label("NP", ents[[s, e]])
|
|
248
|
+
parts << "[#{label} #{chunk_tokens.map { |t| leaf(t, morphology: morphology) }.join(' ')}]"
|
|
249
|
+
i += chunk_tokens.size
|
|
250
|
+
else
|
|
251
|
+
parts << leaf(token, morphology: morphology) unless token.pos == "PUNCT" && !punctuation
|
|
252
|
+
i += 1
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
"[S #{parts.join(' ')}]"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Head projection: each head projects a phrase node over its dependents
|
|
259
|
+
# and itself in word order. A phrase whose span coincides with a noun
|
|
260
|
+
# chunk gets a background; when the chunk containing the head is
|
|
261
|
+
# narrower than the projection (NP -> NP PP), the chunk's tokens are
|
|
262
|
+
# wrapped in an inner [%NP ...]. A single-token chunk (a frequent case
|
|
263
|
+
# for named entities) is wrapped as [%NP leaf] so that the background
|
|
264
|
+
# and entity label are not lost.
|
|
265
|
+
def projection_bracket(token, chunks, ents, root_label:, morphology:, punctuation:)
|
|
266
|
+
kids = token.children.to_a
|
|
267
|
+
kids = kids.reject { |k| k.pos == "PUNCT" } unless punctuation
|
|
268
|
+
if kids.empty?
|
|
269
|
+
node = leaf(token, morphology: morphology)
|
|
270
|
+
single = [token.i, token.i + 1]
|
|
271
|
+
return node unless chunks.include?(single)
|
|
272
|
+
|
|
273
|
+
return "[#{chunk_label('NP', ents[single])} #{node}]"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
label = root_label || PHRASE_LABEL.fetch(token.pos, "XP")
|
|
277
|
+
span = [token.left_edge.i, token.right_edge.i + 1]
|
|
278
|
+
label = chunk_label(label, ents[span]) if chunks.include?(span)
|
|
279
|
+
|
|
280
|
+
ordered = (kids + [token]).sort_by(&:i)
|
|
281
|
+
render_node = lambda do |t|
|
|
282
|
+
if t.i == token.i
|
|
283
|
+
leaf(t, morphology: morphology)
|
|
284
|
+
else
|
|
285
|
+
projection_bracket(t, chunks, ents, root_label: nil,
|
|
286
|
+
morphology: morphology, punctuation: punctuation)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
inner = chunks.find { |s, e| (s...e).cover?(token.i) && [s, e] != span }
|
|
291
|
+
if inner
|
|
292
|
+
s, e = inner
|
|
293
|
+
inside, = ordered.partition { |t| (s...e).cover?(t.i) }
|
|
294
|
+
inner_node = "[#{chunk_label('NP', ents[inner])} #{inside.map(&render_node).join(' ')}]"
|
|
295
|
+
parts = ordered.map { |t| inside.include?(t) ? (t.equal?(inside.first) ? inner_node : nil) : render_node.call(t) }
|
|
296
|
+
parts = parts.compact
|
|
297
|
+
else
|
|
298
|
+
parts = ordered.map(&render_node)
|
|
299
|
+
end
|
|
300
|
+
"[#{label} #{parts.join(' ')}]"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/ruby-spacy/version.rb
CHANGED
data/lib/ruby-spacy.rb
CHANGED
|
@@ -6,9 +6,8 @@ require_relative "ruby-spacy/openai_client"
|
|
|
6
6
|
require_relative "ruby-spacy/anthropic_client"
|
|
7
7
|
require_relative "ruby-spacy/openai_helper"
|
|
8
8
|
require_relative "ruby-spacy/anthropic_helper"
|
|
9
|
-
|
|
9
|
+
require_relative "ruby-spacy/syntax_tree"
|
|
10
10
|
require "pycall"
|
|
11
|
-
require "timeout"
|
|
12
11
|
require "json"
|
|
13
12
|
require "base64"
|
|
14
13
|
|
|
@@ -27,6 +26,16 @@ module Spacy
|
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
Builtins = PyCall.import_module("builtins")
|
|
29
|
+
|
|
30
|
+
# Python `spacy` module
|
|
31
|
+
PySpacy = spacy
|
|
32
|
+
|
|
33
|
+
# Python `__main__` module (used for the deprecated `Language#spacy_nlp_id`)
|
|
34
|
+
PyMain = PyCall.import_module("__main__")
|
|
35
|
+
|
|
36
|
+
# Python `base64` module (used for `Doc.from_bytes`)
|
|
37
|
+
PyBase64 = PyCall.import_module("base64")
|
|
38
|
+
|
|
30
39
|
SpacyVersion = spacy.__version__
|
|
31
40
|
|
|
32
41
|
# Python `Language` class
|
|
@@ -50,6 +59,92 @@ module Spacy
|
|
|
50
59
|
# Python `displacy` object
|
|
51
60
|
PyDisplacy = PyCall.import_module('spacy.displacy')
|
|
52
61
|
|
|
62
|
+
# Python-side helpers, kept in a dedicated module so that nothing is added to
|
|
63
|
+
# the user's `__main__` namespace. They exist because spaCy's vocabulary keys,
|
|
64
|
+
# matcher ids, and integer attributes (e.g. `Token#orth`) are unsigned 64-bit
|
|
65
|
+
# integers that do not survive the PyCall boundary in either direction: on the
|
|
66
|
+
# Python -> Ruby side, values from 2**62 up to 2**63-1 are silently corrupted
|
|
67
|
+
# into negative numbers and values from 2**63 up come back as nil; on the
|
|
68
|
+
# Ruby -> Python side, passing a large Ruby Integer raises TypeError
|
|
69
|
+
# ("an integer is required"). Moving ids as decimal strings and resolving them
|
|
70
|
+
# in Python sidesteps both directions.
|
|
71
|
+
#
|
|
72
|
+
# - key_texts(nlp, keys): used by `Language#most_similar`;
|
|
73
|
+
# returns [[key_string, text], ...]
|
|
74
|
+
# - string_lookup(nlp, key_str): used by `Language#vocab_string_lookup`;
|
|
75
|
+
# returns the string for the given id
|
|
76
|
+
# - matcher_matches(matcher, doc): used by `Matcher#match`;
|
|
77
|
+
# returns [(match_id_string, start, end, label_string), ...]
|
|
78
|
+
# - attr_or_call(obj, name, args): used by `Spacy.safe_py_send`; returns the
|
|
79
|
+
# attribute (called with args if given), with Python ints stringified with
|
|
80
|
+
# INT_PREFIX so they survive the PyCall boundary
|
|
81
|
+
# - load_with_timeout(model, seconds): used by `Language#initialize`; loads a
|
|
82
|
+
# model on a Python thread and returns nil if it does not finish in time
|
|
83
|
+
# (Ruby's Timeout cannot fire while PyCall holds the GVL)
|
|
84
|
+
PyHelpers = PyCall.import_module("types").ModuleType.new("ruby_spacy_helpers")
|
|
85
|
+
PyCall.exec(<<~PYTHON, globals: PyHelpers.__dict__)
|
|
86
|
+
import threading
|
|
87
|
+
|
|
88
|
+
_PREFIX = "__ruby_spacy_int__:"
|
|
89
|
+
|
|
90
|
+
def key_texts(nlp, keys):
|
|
91
|
+
return [[str(key), nlp.vocab[key].text] for key in keys]
|
|
92
|
+
|
|
93
|
+
def string_lookup(nlp, key_str):
|
|
94
|
+
return nlp.vocab.strings[int(key_str)]
|
|
95
|
+
|
|
96
|
+
def matcher_matches(matcher, doc):
|
|
97
|
+
return [(str(m[0]), m[1], m[2], matcher.vocab.strings[m[0]]) for m in matcher(doc)]
|
|
98
|
+
|
|
99
|
+
def attr_or_call(obj, name, args):
|
|
100
|
+
v = getattr(obj, name)
|
|
101
|
+
if args:
|
|
102
|
+
v = v(*args)
|
|
103
|
+
if type(v) is int:
|
|
104
|
+
return _PREFIX + str(v)
|
|
105
|
+
return v
|
|
106
|
+
|
|
107
|
+
def load_with_timeout(model, seconds):
|
|
108
|
+
import spacy
|
|
109
|
+
box = {}
|
|
110
|
+
def run():
|
|
111
|
+
try:
|
|
112
|
+
box["nlp"] = spacy.load(model)
|
|
113
|
+
except BaseException as e:
|
|
114
|
+
box["err"] = e
|
|
115
|
+
t = threading.Thread(target=run, daemon=True)
|
|
116
|
+
t.start()
|
|
117
|
+
t.join(seconds)
|
|
118
|
+
if t.is_alive():
|
|
119
|
+
return None
|
|
120
|
+
if "err" in box:
|
|
121
|
+
raise box["err"]
|
|
122
|
+
return box["nlp"]
|
|
123
|
+
PYTHON
|
|
124
|
+
|
|
125
|
+
# Marks integer values that `PyHelpers.attr_or_call` stringified so they can
|
|
126
|
+
# survive the PyCall boundary (see the comment above `PyHelpers`)
|
|
127
|
+
INT_PREFIX = "__ruby_spacy_int__:"
|
|
128
|
+
|
|
129
|
+
# Calls an attribute or method on a Python object. spaCy exposes several
|
|
130
|
+
# unsigned 64-bit integer attributes (e.g. `Token#orth`) that are silently
|
|
131
|
+
# corrupted when they cross the PyCall boundary (see the comment above
|
|
132
|
+
# `PyHelpers`), so when the result looks corrupted (nil, or a negative
|
|
133
|
+
# Integer, which cannot occur legitimately for these ids) the value is
|
|
134
|
+
# fetched again via `PyHelpers.attr_or_call`, which stringifies ints on the
|
|
135
|
+
# Python side. Ordinary values (strings, booleans, floats, Python objects,
|
|
136
|
+
# small non-negative integers) are returned as-is.
|
|
137
|
+
def self.safe_py_send(py_obj, name, args)
|
|
138
|
+
v = py_obj.send(name, *args)
|
|
139
|
+
return v unless v.nil? || (v.is_a?(Integer) && v.negative?)
|
|
140
|
+
|
|
141
|
+
r = PyHelpers.attr_or_call(py_obj, name.to_s, args)
|
|
142
|
+
r.is_a?(String) && r.start_with?(INT_PREFIX) ? r.delete_prefix(INT_PREFIX).to_i : r
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Python `numpy` module (always present as a spaCy dependency)
|
|
146
|
+
PyNp = PyCall.import_module("numpy")
|
|
147
|
+
|
|
53
148
|
# A utility module method to convert Python's generator object to a Ruby array,
|
|
54
149
|
# mainly used on the items inside the array returned from dependency-related methods
|
|
55
150
|
# such as {Span#rights}, {Span#lefts} and {Span#subtree}.
|
|
@@ -237,7 +332,7 @@ module Spacy
|
|
|
237
332
|
# doc = Spacy::Doc.from_bytes(nlp, bytes)
|
|
238
333
|
def self.from_bytes(nlp, byte_string)
|
|
239
334
|
b64 = Base64.strict_encode64(byte_string)
|
|
240
|
-
py_bytes =
|
|
335
|
+
py_bytes = PyBase64.b64decode(b64)
|
|
241
336
|
py_doc = nlp.py_nlp.call("").from_bytes(py_bytes)
|
|
242
337
|
new(nlp.py_nlp, py_doc: py_doc)
|
|
243
338
|
end
|
|
@@ -250,6 +345,40 @@ module Spacy
|
|
|
250
345
|
PyDisplacy.render(py_doc, style: style, options: { compact: compact }, jupyter: false)
|
|
251
346
|
end
|
|
252
347
|
|
|
348
|
+
# Generates a syntax tree in rsyntaxtree bracket notation, or renders it
|
|
349
|
+
# with the rsyntaxtree gem.
|
|
350
|
+
#
|
|
351
|
+
# Requires the rsyntaxtree gem (>= 2.4.0) at call time (it is a soft
|
|
352
|
+
# dependency; install it with `gem install rsyntaxtree`). The doc must
|
|
353
|
+
# contain a single sentence; for a multi-sentence doc, use
|
|
354
|
+
# `doc.sents.map { |s| s.syntax_tree }`.
|
|
355
|
+
#
|
|
356
|
+
# Note that the bracket notation is rsyntaxtree-flavored: it may contain
|
|
357
|
+
# rsyntaxtree-specific markup such as AVMs (`#(...#)`), region backgrounds
|
|
358
|
+
# (`%`), and in-word space joins (`<>`).
|
|
359
|
+
#
|
|
360
|
+
# @param format [Symbol] `:bracket` (default; the notation string),
|
|
361
|
+
# `:svg`, `:png`, `:pdf`, `:tikz`, or `:json`. `:png` and `:pdf` return
|
|
362
|
+
# a binary string
|
|
363
|
+
# @param style [Symbol] `:projection` (default; head-projection
|
|
364
|
+
# phrase-structure-like tree) or `:chunks` (shallow tree with noun chunks)
|
|
365
|
+
# @param morphology [Boolean] attach a morphology AVM to each leaf
|
|
366
|
+
# @param entities [Boolean] mark chunks that match a named entity with a
|
|
367
|
+
# colored background and the entity label
|
|
368
|
+
# @param punctuation [Boolean] keep punctuation tokens
|
|
369
|
+
# @param render_opts [Hash] rsyntaxtree drawing options (e.g. `fontsize:`).
|
|
370
|
+
# `hyphen:` cannot be overridden
|
|
371
|
+
# @return [String] the bracket notation or the rendered output
|
|
372
|
+
# @example
|
|
373
|
+
# doc.syntax_tree # => "[S [%NP [DET The] ...] ...]"
|
|
374
|
+
# doc.syntax_tree(format: :svg) # => "<svg ..."
|
|
375
|
+
# doc.syntax_tree(format: :png, fontsize: 12)
|
|
376
|
+
def syntax_tree(format: :bracket, style: :projection, morphology: false,
|
|
377
|
+
entities: true, punctuation: false, **render_opts)
|
|
378
|
+
SyntaxTree.generate(self, format: format, style: style, morphology: morphology,
|
|
379
|
+
entities: entities, punctuation: punctuation, **render_opts)
|
|
380
|
+
end
|
|
381
|
+
|
|
253
382
|
# Generates a JSON string summarizing the linguistic analysis of the document.
|
|
254
383
|
# Designed to be passed as context to an LLM (e.g., via {OpenAIHelper#chat}).
|
|
255
384
|
#
|
|
@@ -488,7 +617,7 @@ module Spacy
|
|
|
488
617
|
|
|
489
618
|
# Methods defined in Python but not wrapped in ruby-spacy can be called by this dynamic method handling mechanism.
|
|
490
619
|
def method_missing(name, *args)
|
|
491
|
-
@py_doc
|
|
620
|
+
Spacy.safe_py_send(@py_doc, name, args)
|
|
492
621
|
end
|
|
493
622
|
|
|
494
623
|
def respond_to_missing?(sym, include_private = false)
|
|
@@ -502,28 +631,70 @@ module Spacy
|
|
|
502
631
|
|
|
503
632
|
# See also spaCy Python API document for [`Language`](https://spacy.io/api/language).
|
|
504
633
|
class Language
|
|
505
|
-
# @return [String] an identifier string that can be used to refer to the Python `Language` object inside `PyCall::exec` or `PyCall::eval`
|
|
506
|
-
attr_reader :spacy_nlp_id
|
|
507
|
-
|
|
508
634
|
# @return [Object] a Python `Language` instance accessible via `PyCall`
|
|
509
635
|
attr_reader :py_nlp
|
|
510
636
|
|
|
637
|
+
# @return [String] an identifier string that can be used to refer to the Python `Language` object inside `PyCall::exec` or `PyCall::eval`
|
|
638
|
+
# @deprecated The Python object is no longer stored in a global variable at
|
|
639
|
+
# initialization time. Referencing this method creates a global variable in
|
|
640
|
+
# Python's `__main__` on demand (which then stays alive until the process
|
|
641
|
+
# exits). Use {#py_nlp} instead.
|
|
642
|
+
def spacy_nlp_id
|
|
643
|
+
@spacy_nlp_id ||= begin
|
|
644
|
+
warn "[DEPRECATION] `Spacy::Language#spacy_nlp_id` is deprecated. " \
|
|
645
|
+
"It creates a Python global variable that is never released; use `py_nlp` instead."
|
|
646
|
+
id = "nlp_#{@py_nlp.object_id}"
|
|
647
|
+
Builtins.setattr(PyMain, id, @py_nlp)
|
|
648
|
+
id
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Sentinel for "the caller gave no model argument". Distinct from nil so
|
|
653
|
+
# that an explicit nil (e.g. an unset ENV var passed straight through)
|
|
654
|
+
# still fails validation instead of silently loading the default model
|
|
655
|
+
NO_MODEL = Object.new.freeze
|
|
656
|
+
private_constant :NO_MODEL
|
|
657
|
+
|
|
511
658
|
# Creates a language model instance, which is conventionally referred to by a variable named `nlp`.
|
|
512
659
|
# @param model [String] A language model installed in the system
|
|
513
|
-
|
|
660
|
+
# @param timeout [Numeric, nil] Seconds to wait for the model to load before
|
|
661
|
+
# raising a `RuntimeError`. `nil` waits indefinitely. The timeout is
|
|
662
|
+
# enforced on the Python side (a loading thread with `join(timeout)`)
|
|
663
|
+
# because Ruby's `Timeout` cannot fire while PyCall holds the GVL. When it
|
|
664
|
+
# fires, the loading thread is left running as a daemon until the process
|
|
665
|
+
# exits (accepted: timeouts are an abnormal path).
|
|
666
|
+
# @param py_nlp [Object, nil] an existing Python `Language` pipeline to wrap
|
|
667
|
+
# instead of loading a model. For languages spaCy ships no trained
|
|
668
|
+
# pipeline for (e.g. Arabic and other right-to-left languages) or for
|
|
669
|
+
# self-built pipelines, create one with a third-party package such as
|
|
670
|
+
# spacy-stanza or spacy-udpipe and pass it here. Mutually exclusive with
|
|
671
|
+
# `model`; model name validation, timeout, and retries are skipped
|
|
672
|
+
# @example Load an installed spaCy model
|
|
673
|
+
# nlp = Spacy::Language.new("en_core_web_sm")
|
|
674
|
+
# @example Wrap an external pipeline (requires: pip install spacy-stanza)
|
|
675
|
+
# py_nlp = PyCall.import_module("spacy_stanza").load_pipeline("ar")
|
|
676
|
+
# nlp = Spacy::Language.new(py_nlp: py_nlp)
|
|
677
|
+
def initialize(model = NO_MODEL, max_retrial: MAX_RETRIAL, timeout: 60, py_nlp: nil)
|
|
678
|
+
if py_nlp
|
|
679
|
+
raise ArgumentError, "model and py_nlp: are mutually exclusive" unless model.equal?(NO_MODEL)
|
|
680
|
+
unless Builtins.isinstance(py_nlp, PyLanguage)
|
|
681
|
+
raise ArgumentError,
|
|
682
|
+
"py_nlp: must be a spaCy Language pipeline " \
|
|
683
|
+
"(e.g. from spacy.load or spacy_stanza.load_pipeline)"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
@py_nlp = py_nlp
|
|
687
|
+
return
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
model = "en_core_web_sm" if model.equal?(NO_MODEL)
|
|
514
691
|
unless model.to_s.match?(/\A[a-zA-Z0-9_\-\.\/]+\z/)
|
|
515
692
|
raise ArgumentError, "Invalid model name: #{model.inspect}"
|
|
516
693
|
end
|
|
517
694
|
|
|
518
|
-
@spacy_nlp_id = "nlp_#{model.object_id}"
|
|
519
695
|
retrial = 0
|
|
520
696
|
begin
|
|
521
|
-
|
|
522
|
-
PyCall.exec("import spacy; #{@spacy_nlp_id} = spacy.load('#{model}')")
|
|
523
|
-
end
|
|
524
|
-
@py_nlp = PyCall.eval(@spacy_nlp_id)
|
|
525
|
-
rescue Timeout::Error
|
|
526
|
-
raise "PyCall execution timed out after #{timeout} seconds"
|
|
697
|
+
@py_nlp = PyHelpers.load_with_timeout(model, timeout)
|
|
527
698
|
rescue StandardError => e
|
|
528
699
|
retrial += 1
|
|
529
700
|
if retrial <= max_retrial
|
|
@@ -533,6 +704,8 @@ module Spacy
|
|
|
533
704
|
raise "Failed to initialize Spacy after #{max_retrial} attempts: #{e.message}"
|
|
534
705
|
end
|
|
535
706
|
end
|
|
707
|
+
# A timeout is not retried; it almost certainly means a hung load
|
|
708
|
+
raise "PyCall execution timed out after #{timeout} seconds" if @py_nlp.nil?
|
|
536
709
|
end
|
|
537
710
|
|
|
538
711
|
# Reads and analyze the given text.
|
|
@@ -559,11 +732,11 @@ module Spacy
|
|
|
559
732
|
PhraseMatcher.new(self, attr: attr)
|
|
560
733
|
end
|
|
561
734
|
|
|
562
|
-
# A utility method to lookup
|
|
563
|
-
# @param id [Integer] a vocabulary id
|
|
564
|
-
# @return [
|
|
735
|
+
# A utility method to lookup the string of the given vocabulary id.
|
|
736
|
+
# @param id [Integer] a vocabulary id (unsigned 64-bit values are supported)
|
|
737
|
+
# @return [String] the string corresponding to the given vocabulary id
|
|
565
738
|
def vocab_string_lookup(id)
|
|
566
|
-
|
|
739
|
+
PyHelpers.string_lookup(@py_nlp, Integer(id).to_s)
|
|
567
740
|
end
|
|
568
741
|
|
|
569
742
|
# A utility method to list pipeline components.
|
|
@@ -590,9 +763,9 @@ module Spacy
|
|
|
590
763
|
# @param vector [Object] A vector representation of a word (whether existing or non-existing)
|
|
591
764
|
# @return [Array<Hash{:key => Integer, :text => String, :best_rows => Array<Float>, :score => Float}>] An array of hash objects each contains the `key`, `text`, `best_row` and similarity `score` of a lexeme
|
|
592
765
|
def most_similar(vector, num)
|
|
593
|
-
vec_array =
|
|
766
|
+
vec_array = PyNp.asarray([vector])
|
|
594
767
|
py_result = @py_nlp.vocab.vectors.most_similar(vec_array, n: num)
|
|
595
|
-
key_texts = PyCall.
|
|
768
|
+
key_texts = PyCall::List.call(PyHelpers.key_texts(@py_nlp, py_result[0][0].tolist))
|
|
596
769
|
keys = key_texts.map { |kt| kt[0] }
|
|
597
770
|
texts = key_texts.map { |kt| kt[1] }
|
|
598
771
|
best_rows = PyCall::List.call(py_result[1])[0]
|
|
@@ -708,7 +881,7 @@ module Spacy
|
|
|
708
881
|
|
|
709
882
|
# Methods defined in Python but not wrapped in ruby-spacy can be called by this dynamic method handling mechanism.
|
|
710
883
|
def method_missing(name, *args)
|
|
711
|
-
@py_nlp
|
|
884
|
+
Spacy.safe_py_send(@py_nlp, name, args)
|
|
712
885
|
end
|
|
713
886
|
|
|
714
887
|
def respond_to_missing?(sym, include_private = false)
|
|
@@ -740,10 +913,10 @@ module Spacy
|
|
|
740
913
|
|
|
741
914
|
# Execute the match.
|
|
742
915
|
# @param doc [Doc] an {Doc} instance
|
|
743
|
-
# @return [Array<Hash{:match_id => Integer, :start_index => Integer, :end_index => Integer}>] the id of the matched pattern, the starting position, and the
|
|
916
|
+
# @return [Array<Hash{:match_id => Integer, :start_index => Integer, :end_index => Integer, :label => String}>] the id of the matched pattern, the starting position, the end position, and the label string of the matched pattern
|
|
744
917
|
def match(doc)
|
|
745
|
-
PyCall::List.call(@py_matcher
|
|
746
|
-
{ match_id: py_match[0].to_i, start_index: py_match[1]
|
|
918
|
+
PyCall::List.call(PyHelpers.matcher_matches(@py_matcher, doc.py_doc)).map do |py_match|
|
|
919
|
+
{ match_id: py_match[0].to_i, start_index: py_match[1], end_index: py_match[2] - 1, label: py_match[3] }
|
|
747
920
|
end
|
|
748
921
|
end
|
|
749
922
|
end
|
|
@@ -895,6 +1068,15 @@ module Spacy
|
|
|
895
1068
|
Doc.new(@doc.py_nlp, text: text)
|
|
896
1069
|
end
|
|
897
1070
|
|
|
1071
|
+
# Generates a syntax tree for the span in rsyntaxtree bracket notation, or
|
|
1072
|
+
# renders it with the rsyntaxtree gem. The span must have a single root
|
|
1073
|
+
# (e.g. a sentence from {Doc#sents}). See {Doc#syntax_tree} for the
|
|
1074
|
+
# available options.
|
|
1075
|
+
# @return [String] the bracket notation or the rendered output
|
|
1076
|
+
def syntax_tree(**options)
|
|
1077
|
+
SyntaxTree.generate(self, **options)
|
|
1078
|
+
end
|
|
1079
|
+
|
|
898
1080
|
# Returns tokens conjugated to the root of the span.
|
|
899
1081
|
# @return [Array<Token>] an array of tokens
|
|
900
1082
|
def conjuncts
|
|
@@ -933,7 +1115,7 @@ module Spacy
|
|
|
933
1115
|
|
|
934
1116
|
# Methods defined in Python but not wrapped in ruby-spacy can be called by this dynamic method handling mechanism.
|
|
935
1117
|
def method_missing(name, *args)
|
|
936
|
-
@py_span
|
|
1118
|
+
Spacy.safe_py_send(@py_span, name, args)
|
|
937
1119
|
end
|
|
938
1120
|
|
|
939
1121
|
def respond_to_missing?(sym, include_private = false)
|
|
@@ -1089,7 +1271,7 @@ module Spacy
|
|
|
1089
1271
|
|
|
1090
1272
|
# Methods defined in Python but not wrapped in ruby-spacy can be called by this dynamic method handling mechanism.
|
|
1091
1273
|
def method_missing(name, *args)
|
|
1092
|
-
@py_token
|
|
1274
|
+
Spacy.safe_py_send(@py_token, name, args)
|
|
1093
1275
|
end
|
|
1094
1276
|
|
|
1095
1277
|
def respond_to_missing?(sym, include_private = false)
|
|
@@ -1168,7 +1350,7 @@ module Spacy
|
|
|
1168
1350
|
|
|
1169
1351
|
# Methods defined in Python but not wrapped in ruby-spacy can be called by this dynamic method handling mechanism.
|
|
1170
1352
|
def method_missing(name, *args)
|
|
1171
|
-
@py_lexeme
|
|
1353
|
+
Spacy.safe_py_send(@py_lexeme, name, args)
|
|
1172
1354
|
end
|
|
1173
1355
|
|
|
1174
1356
|
def respond_to_missing?(sym, include_private = false)
|