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.
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Spacy
4
4
  # The version number of the module
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
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
- require "numpy"
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 = PyCall.eval("__import__('base64').b64decode('#{b64}')")
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.send(name, *args)
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
- def initialize(model = "en_core_web_sm", max_retrial: MAX_RETRIAL, timeout: 60)
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
- Timeout.timeout(timeout) do
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 a vocabulary item of the given id.
563
- # @param id [Integer] a vocabulary id
564
- # @return [Object] a Python `Lexeme` object (https://spacy.io/api/lexeme)
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
- PyCall.eval("#{@spacy_nlp_id}.vocab.strings[#{Integer(id)}]")
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 = Numpy.asarray([vector])
766
+ vec_array = PyNp.asarray([vector])
594
767
  py_result = @py_nlp.vocab.vectors.most_similar(vec_array, n: num)
595
- key_texts = PyCall.eval("[[str(num), #{@spacy_nlp_id}.vocab[num].text] for num in #{py_result[0][0].tolist}]")
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.send(name, *args)
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 end position
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.call(doc.py_doc)).map do |py_match|
746
- { match_id: py_match[0].to_i, start_index: py_match[1].to_i, end_index: py_match[2].to_i - 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.send(name, *args)
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.send(name, *args)
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.send(name, *args)
1353
+ Spacy.safe_py_send(@py_lexeme, name, args)
1172
1354
  end
1173
1355
 
1174
1356
  def respond_to_missing?(sym, include_private = false)