ruby-spacy 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 055dc3da59c059df185ce0d868ba8da07453f0c5c1501d2a61fc5e27e5abee28
4
- data.tar.gz: adc27ca2bfcc46ea0ad90bcd2469c2fc5abfdad47982339a7acb085ddeb20de0
3
+ metadata.gz: eff16e19adc4863d93ac22803e63e5ac2ab8bba077eca7cbc8c2403174704aac
4
+ data.tar.gz: 1766dc8fbb6c2c4b10314c925ddae484907f6677f9fb314ede756c9b3752923e
5
5
  SHA512:
6
- metadata.gz: 0ef618c78e55a8607b2975bd91946ab0d337a7fa73982f05fda1bb6db4f8e773b5535b90833fe79de63f8def8a3fb08ce363592a54f941de85b57618f7b7945e
7
- data.tar.gz: 0bd57b4cfe238a630c85c93c4b84b0d95b1e07dc74583cf4916fc2b16ba51222c4a35b2a5d5e18e470050ba8680ef642a1d0ba50c6d830b506bf4c4f7b6523f3
6
+ metadata.gz: b1ef879141c89307e0fa05d5130fa997c0e1746ab0fc4a466dca032a6c4f1007341e8e6a5d1369d1f5a260070e161df8df60f87042343870c862d931faaa7789
7
+ data.tar.gz: 54baa96c6f475f147b374d8b27a80c6aa792937d759793178fd4f78592588f93d121f4e1edbfb89fdb7e8da5cd6b74abf8b1ccc404ef8bd6f836cc88b55fa407
@@ -33,6 +33,10 @@ jobs:
33
33
  env:
34
34
  # PyCall locates the Python interpreter via this variable
35
35
  PYTHON: python
36
+ # rsyntaxtree (used by the syntax_tree tests) is in the :rendering
37
+ # bundler group and needs pango/rsvg system libraries; install it only
38
+ # on representative jobs
39
+ BUNDLE_WITHOUT: ${{ matrix.python != '3.13' && 'rendering' || '' }}
36
40
  steps:
37
41
  - uses: actions/checkout@v7
38
42
 
@@ -42,6 +46,12 @@ jobs:
42
46
  with:
43
47
  python-version: ${{ matrix.python }}
44
48
 
49
+ # Must precede ruby/setup-ruby: bundler-cache installs the pango/rsvg2
50
+ # gems, which compile against these system libraries
51
+ - name: Install rsyntaxtree system dependencies (representative jobs)
52
+ if: matrix.python == '3.13'
53
+ run: sudo apt-get update && sudo apt-get install -y libpango1.0-dev librsvg2-dev libgirepository1.0-dev
54
+
45
55
  - uses: ruby/setup-ruby@v1
46
56
  with:
47
57
  ruby-version: ${{ matrix.ruby }}
@@ -53,22 +63,35 @@ jobs:
53
63
  # Cache the language models only (en_core_web_lg alone is several hundred
54
64
  # MB). spaCy itself is intentionally installed fresh on every run so that
55
65
  # new releases are exercised immediately. Bump the key suffix when
56
- # upgrading the models.
66
+ # upgrading the models. The ja/ru/de/zh models are installed only on the
67
+ # python 3.13 jobs (where the syntax_tree tests run); listing them here
68
+ # for other jobs is harmless
57
69
  - uses: actions/cache@v6
58
70
  with:
59
71
  path: |
60
72
  ${{ steps.site-packages.outputs.path }}/en_core_web_sm*
61
73
  ${{ steps.site-packages.outputs.path }}/en_core_web_lg*
62
- key: spacy-models-${{ matrix.python }}-v1
74
+ ${{ steps.site-packages.outputs.path }}/ja_core_news_sm*
75
+ ${{ steps.site-packages.outputs.path }}/ru_core_news_sm*
76
+ ${{ steps.site-packages.outputs.path }}/de_core_news_sm*
77
+ ${{ steps.site-packages.outputs.path }}/zh_core_web_sm*
78
+ key: spacy-models-${{ matrix.python }}-v2
63
79
 
64
- # en_core_web_lg is large; install it only on representative jobs.
65
- # Tests that need it are skipped automatically where it is absent.
80
+ # en_core_web_lg and the ja/ru/de/zh models are large; install them only
81
+ # on representative jobs (where the syntax_tree tests run). Tests that
82
+ # need them are skipped automatically where they are absent.
66
83
  - name: Install spaCy and language models
67
84
  run: |
68
85
  python -m pip install --upgrade pip
69
86
  python -m pip install --upgrade spacy
70
87
  python -m spacy download en_core_web_sm
71
- if [ "${{ matrix.python }}" = "3.13" ]; then python -m spacy download en_core_web_lg; fi
88
+ if [ "${{ matrix.python }}" = "3.13" ]; then
89
+ python -m spacy download en_core_web_lg
90
+ python -m spacy download ja_core_news_sm
91
+ python -m spacy download ru_core_news_sm
92
+ python -m spacy download de_core_news_sm
93
+ python -m spacy download zh_core_web_sm
94
+ fi
72
95
 
73
96
  - name: Run tests
74
97
  run: bundle exec rake test
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Change Log
2
2
 
3
+ ## 0.7.0 - 2026-09-03
4
+ ### Added
5
+ - `Doc#syntax_tree` and `Span#syntax_tree` — turn a parse into rsyntaxtree
6
+ bracket notation, or draw it as SVG, PNG, PDF, TikZ, or JSON. Two styles:
7
+ `:projection` builds a phrase-structure-like tree from the head words,
8
+ `:chunks` lays the noun chunks out flat. Noun chunks are shaded, named
9
+ entities that coincide with one are labeled, and `morphology: true` attaches
10
+ a table of features to every leaf. rsyntaxtree (>= 2.4.0) is an optional
11
+ dependency, required on first use rather than at load time
12
+ - `Language.new(py_nlp:)` — wrap a Python pipeline built elsewhere, for the
13
+ languages spaCy ships no trained model for. Every right-to-left language is
14
+ one of them, and their trees are drawn mirrored so the leaves run in reading
15
+ order. A package such as spacy-stanza builds the pipeline; ruby-spacy gains
16
+ no dependency
17
+ - `docs/syntax_trees.md` — a gallery of trees in six languages, generated by
18
+ the scripts in `examples/rsyntaxtree/`
19
+
20
+ ### Changed
21
+ - CI runs the syntax tree tests against English, Japanese, Russian, German, and
22
+ Chinese models, which between them cover the paths that differ: with and
23
+ without noun chunks, two-line entity labels, and a mirrored right-to-left tree
24
+
3
25
  ## 0.6.0 - 2026-08-31
4
26
  ### Added
5
27
  - GitHub Actions CI — Ruby 3.2 to 4.0 (plus ruby-head) and Python 3.11 to 3.14,
data/Gemfile CHANGED
@@ -9,3 +9,10 @@ group :development do
9
9
  gem "github-markup"
10
10
  gem "redcarpet"
11
11
  end
12
+
13
+ # Used only for rendering in Doc#syntax_tree / Span#syntax_tree tests.
14
+ # Kept in its own group so CI jobs without the pango/rsvg system libraries
15
+ # can exclude it with BUNDLE_WITHOUT=rendering.
16
+ group :rendering do
17
+ gem "rsyntaxtree", ">= 2.4.0", require: false
18
+ end
data/README.md CHANGED
@@ -10,13 +10,15 @@
10
10
  | ✅ | Part-of-speech tagging and dependency parsing |
11
11
  | ✅ | Named entity recognition |
12
12
  | ✅ | Syntactic dependency visualization |
13
+ | ✅ | Syntax tree visualization (via rsyntaxtree) |
13
14
  | ✅ | Access to pre-trained word vectors |
14
15
  | ✅ | LLM integration: OpenAI, Anthropic (Claude), and local models |
15
16
 
16
- Current Version: `0.6.0`
17
+ Current Version: `0.7.0`
17
18
 
18
19
  - Ruby 3.2 to 4.0 supported (PyCall 1.5.3 or later required)
19
20
  - spaCy 3.8 supported
21
+ - Syntax trees drawn with rsyntaxtree, including right-to-left languages
20
22
  - Multi-provider LLM API: OpenAI, Anthropic (Claude), and local models via Ollama or any OpenAI-compatible server
21
23
  - Structured outputs (JSON Schema) support
22
24
  - Block-based LLM API with linguistic analysis
@@ -77,6 +79,18 @@ Or install it yourself as:
77
79
 
78
80
  See [Examples](#examples) below.
79
81
 
82
+ ### Using an External Pipeline
83
+
84
+ `Spacy::Language.new` normally loads an installed model by name. To use a language spaCy has no model for, or a pipeline you built yourself, pass an existing Python `Language` object instead:
85
+
86
+ ```ruby
87
+ py_nlp = PyCall.import_module("spacy_stanza").load_pipeline("ar")
88
+ nlp = Spacy::Language.new(py_nlp: py_nlp)
89
+ nlp.read("...").tokens # works like any other nlp
90
+ ```
91
+
92
+ Third-party packages such as [spacy-stanza](https://github.com/explosion/spacy-stanza) or [spacy-udpipe](https://github.com/TakeLab/spacy-udpipe) are not ruby-spacy dependencies; install them yourself. `model` and `py_nlp:` are mutually exclusive.
93
+
80
94
  ## Examples
81
95
 
82
96
  Many of the following examples are Python-to-Ruby translations of code snippets in [spaCy 101](https://spacy.io/usage/spacy-101). For more examples, look inside the `examples` directory.
@@ -286,6 +300,42 @@ Output:
286
300
 
287
301
  ![](https://github.com/yohasebe/ruby-spacy/blob/main/examples/get_started/outputs/test_dep_compact.svg)
288
302
 
303
+ ### Syntax Trees
304
+
305
+ → [rsyntaxtree](https://github.com/yohasebe/rsyntaxtree)
306
+
307
+ `Doc#syntax_tree` (and `Span#syntax_tree`) converts the parse into rsyntaxtree bracket notation and can render it as an image. rsyntaxtree (>= 2.4.0) is an optional dependency: `gem install rsyntaxtree`.
308
+
309
+ ```ruby
310
+ require "ruby-spacy"
311
+
312
+ nlp = Spacy::Language.new("en_core_web_sm")
313
+ doc = nlp.read("The quick brown fox jumped over the lazy dog near the river.")
314
+
315
+ doc.syntax_tree # => "[S [%NP [DET The] [ADJ quick] ...] ...]"
316
+ File.binwrite("tree.png", doc.syntax_tree(format: :png))
317
+ doc.syntax_tree(style: :chunks) # shallow tree with noun chunks
318
+ doc.syntax_tree(morphology: true) # attach morphology tables to the leaves
319
+ ```
320
+
321
+ <img src="https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_en_projection.png" alt="English projection tree" width="560">
322
+
323
+ <img src="https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_en_morphology.png" alt="English projection tree with morphology tables" width="760">
324
+
325
+ <img src="https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ja_projection.png" alt="Japanese projection tree with named entities" width="500">
326
+
327
+ Two styles are available: `:projection` (default; a phrase-structure-like tree projected from the head words) and `:chunks` (a shallow tree with noun chunks). In both, noun chunks are shaded grey, and a chunk that is also a named entity is shaded orange and labeled with the entity type (`entities: false` to disable). The blue and green are rsyntaxtree's default palette, not marking of any kind; only the shading carries meaning. Punctuation is omitted (`punctuation: true` to keep it). The `format:` option accepts `:bracket` (default), `:svg`, `:png`, `:pdf`, `:tikz`, and `:json`; any other keywords are passed through to rsyntaxtree (e.g. `fontsize: 12`).
328
+
329
+ The notation is rsyntaxtree-flavored: it can contain AVMs (`#(...#)`), region backgrounds (`%`), and in-word space joins (`<>`). A doc must hold a single sentence; for a multi-sentence doc, use `doc.sents.map { |s| s.syntax_tree }`.
330
+
331
+ **Annotation schemes differ between models.** spaCy's English models make the preposition the head of its phrase, so projection trees contain `PP` nodes. UD-style models (Japanese, Russian, Chinese, and most others) attach prepositions and particles to the noun instead, so no `PP` appears. Phrase labels come from the head's POS tag and follow whatever scheme the model uses.
332
+
333
+ **Entity highlighting requires noun chunks.** An entity gets its colored background only where it coincides with a noun chunk. In languages whose models have no noun chunk iterator (Russian, Chinese, Korean, Polish, ...) `entities: true` highlights nothing and `style: :chunks` raises `ArgumentError`.
334
+
335
+ **Right-to-left languages.** spaCy ships no pipelines for Arabic, Hebrew, or other RTL languages, so one has to come from outside (see [Using an External Pipeline](#using-an-external-pipeline)). Their trees are drawn mirrored automatically; pass `mirror: "off"` to disable.
336
+
337
+ See the [syntax tree gallery](docs/syntax_trees.md) for rendered trees in six languages, and `examples/rsyntaxtree/` for the scripts that generate them.
338
+
289
339
  ### Named Entity Recognition
290
340
 
291
341
  → [spaCy: Named entities](https://spacy.io/usage/spacy-101#annotations-ner)
@@ -0,0 +1,55 @@
1
+ # Syntax Tree Gallery
2
+
3
+ Trees drawn by `Doc#syntax_tree` with the [rsyntaxtree](https://github.com/yohasebe/rsyntaxtree) gem. The scripts in `examples/rsyntaxtree/` generate every image here.
4
+
5
+ Noun chunks are shaded grey; a chunk that is also a named entity is shaded orange and labeled with the entity type. The blue and green are rsyntaxtree's default palette, not marking of any kind. Phrase labels come from the head's POS tag, so they follow whatever annotation scheme the model uses. Both are explained in the [README](../README.md#syntax-trees).
6
+
7
+ ## English (`en_core_web_sm`)
8
+
9
+ Projection, the default style:
10
+
11
+ ![English projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_en_projection.png)
12
+
13
+ Chunks style:
14
+
15
+ ![English chunks](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_en_chunks.png)
16
+
17
+ With morphology (a shorter sentence — the tables are tall):
18
+
19
+ ![English morphology](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_en_morphology.png)
20
+
21
+ ## Japanese (`ja_core_news_sm`)
22
+
23
+ Projection, with PERSON and GPE highlighted:
24
+
25
+ ![Japanese projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ja_projection.png)
26
+
27
+ Chunks style:
28
+
29
+ ![Japanese chunks](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ja_chunks.png)
30
+
31
+ ## Russian (`ru_core_news_sm`)
32
+
33
+ No noun chunk iterator, so `style: :chunks` is unavailable.
34
+
35
+ ![Russian projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ru_projection.png)
36
+
37
+ With morphology — Russian marks case, gender, animacy, aspect, and voice:
38
+
39
+ ![Russian morphology](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ru_morphology.png)
40
+
41
+ ## German (`de_core_news_sm`)
42
+
43
+ ![German projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_de_projection.png)
44
+
45
+ ## Chinese (`zh_core_web_sm`)
46
+
47
+ No noun chunk iterator, so `style: :chunks` is unavailable.
48
+
49
+ ![Chinese projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_zh_projection.png)
50
+
51
+ ## Arabic (right-to-left)
52
+
53
+ spaCy ships no Arabic pipeline, so this tree was parsed with [Stanza](https://stanfordnlp.github.io/stanza/) via [spacy-stanza](https://github.com/explosion/spacy-stanza) and wrapped with `Spacy::Language.new(py_nlp:)`. Right-to-left languages are drawn mirrored automatically. This pipeline has no noun chunks, so nothing is shaded. See `examples/rsyntaxtree/syntax_tree_ar.rb`.
54
+
55
+ ![Arabic projection](https://github.com/yohasebe/ruby-spacy/blob/main/examples/rsyntaxtree/outputs/tree_ar_projection.png)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arabic via an external pipeline. spaCy ships no trained pipeline for
4
+ # Arabic (or any other right-to-left language), so this example builds one
5
+ # with Stanza through spacy-stanza. These are NOT ruby-spacy dependencies;
6
+ # install them yourself first:
7
+ #
8
+ # pip install spacy-stanza
9
+ # pip install --upgrade "stanza>=1.10" # spacy-stanza's stanza pin is old and conflicts with current torch
10
+ # python -c "import stanza; stanza.download('ar')"
11
+ #
12
+ # Note: at exit, spacy-stanza under PyCall prints a harmless
13
+ # multiprocessing.resource_tracker LoadError to stderr (sys.executable
14
+ # points to ruby, not python); the results are unaffected.
15
+
16
+ # add path to ruby-spacy lib to load path
17
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
18
+
19
+ require "ruby-spacy"
20
+ require "fileutils"
21
+
22
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
23
+
24
+ py_nlp = PyCall.import_module("spacy_stanza")
25
+ .load_pipeline("ar", processors: "tokenize,pos,lemma,depparse", verbose: false)
26
+ nlp = Spacy::Language.new(py_nlp: py_nlp)
27
+ doc = nlp.read("قرأ الطالب الجديد كتابا مثيرا في المكتبة أمس.")
28
+
29
+ puts doc.syntax_tree
30
+
31
+ # Right-to-left languages are drawn mirrored automatically (pass
32
+ # mirror: "off" to disable)
33
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
34
+ FileUtils.mkdir_p(output_dir)
35
+ File.binwrite(File.join(output_dir, "tree_ar_projection.png"),
36
+ doc.syntax_tree(format: :png))
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # add path to ruby-spacy lib to load path
4
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
5
+
6
+ require "ruby-spacy"
7
+ require "fileutils"
8
+
9
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
10
+
11
+ nlp = Spacy::Language.new("de_core_news_sm")
12
+ doc = nlp.read("Der alte Professor las gestern ein interessantes Buch.")
13
+
14
+ puts doc.syntax_tree
15
+
16
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
17
+ FileUtils.mkdir_p(output_dir)
18
+ File.binwrite(File.join(output_dir, "tree_de_projection.png"),
19
+ doc.syntax_tree(format: :png))
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # add path to ruby-spacy lib to load path
4
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
5
+
6
+ require "ruby-spacy"
7
+ require "fileutils"
8
+
9
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
10
+
11
+ nlp = Spacy::Language.new("en_core_web_sm")
12
+ doc = nlp.read("The quick brown fox jumped over the lazy dog near the river.")
13
+
14
+ puts doc.syntax_tree
15
+
16
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
17
+ FileUtils.mkdir_p(output_dir)
18
+ File.binwrite(File.join(output_dir, "tree_en_projection.png"),
19
+ doc.syntax_tree(format: :png))
20
+ File.binwrite(File.join(output_dir, "tree_en_chunks.png"),
21
+ doc.syntax_tree(style: :chunks, format: :png))
22
+ # A shorter sentence for the morphology tables: every leaf becomes a box, so a
23
+ # long sentence draws a figure too wide to read
24
+ compact = nlp.read("He was sitting under the old tree.")
25
+ File.binwrite(File.join(output_dir, "tree_en_morphology.png"),
26
+ compact.syntax_tree(format: :png, morphology: true))
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # add path to ruby-spacy lib to load path
4
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
5
+
6
+ require "ruby-spacy"
7
+ require "fileutils"
8
+
9
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
10
+
11
+ nlp = Spacy::Language.new("ja_core_news_sm")
12
+ doc = nlp.read("太郎は昨日、東京で花子に古い本を渡した。")
13
+
14
+ puts doc.syntax_tree
15
+
16
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
17
+ FileUtils.mkdir_p(output_dir)
18
+ File.binwrite(File.join(output_dir, "tree_ja_projection.png"),
19
+ doc.syntax_tree(format: :png))
20
+ File.binwrite(File.join(output_dir, "tree_ja_chunks.png"),
21
+ doc.syntax_tree(style: :chunks, format: :png))
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # add path to ruby-spacy lib to load path
4
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
5
+
6
+ require "ruby-spacy"
7
+ require "fileutils"
8
+
9
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
10
+
11
+ nlp = Spacy::Language.new("ru_core_news_sm")
12
+ doc = nlp.read("Старый профессор читал интересную книгу в библиотеке.")
13
+
14
+ puts doc.syntax_tree
15
+
16
+ # ru_core_news_sm has no noun chunk iterator, so style: :chunks raises
17
+ # ArgumentError for this language; Russian morphology is rich, though
18
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
19
+ FileUtils.mkdir_p(output_dir)
20
+ File.binwrite(File.join(output_dir, "tree_ru_projection.png"),
21
+ doc.syntax_tree(format: :png))
22
+ File.binwrite(File.join(output_dir, "tree_ru_morphology.png"),
23
+ doc.syntax_tree(format: :png, morphology: true))
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # add path to ruby-spacy lib to load path
4
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
5
+
6
+ require "ruby-spacy"
7
+ require "fileutils"
8
+
9
+ # Requires the rsyntaxtree gem (>= 2.4.0): gem install rsyntaxtree
10
+
11
+ nlp = Spacy::Language.new("zh_core_web_sm")
12
+ doc = nlp.read("老教授昨天在图书馆读了一本有趣的书。")
13
+
14
+ puts doc.syntax_tree
15
+
16
+ # zh_core_web_sm has no noun chunk iterator, so style: :chunks raises
17
+ # ArgumentError for this language
18
+ output_dir = File.join(File.dirname(__FILE__), "outputs")
19
+ FileUtils.mkdir_p(output_dir)
20
+ File.binwrite(File.join(output_dir, "tree_zh_projection.png"),
21
+ doc.syntax_tree(format: :png))
@@ -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.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/lib/ruby-spacy.rb CHANGED
@@ -6,6 +6,7 @@ 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_relative "ruby-spacy/syntax_tree"
9
10
  require "pycall"
10
11
  require "json"
11
12
  require "base64"
@@ -344,6 +345,40 @@ module Spacy
344
345
  PyDisplacy.render(py_doc, style: style, options: { compact: compact }, jupyter: false)
345
346
  end
346
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
+
347
382
  # Generates a JSON string summarizing the linguistic analysis of the document.
348
383
  # Designed to be passed as context to an LLM (e.g., via {OpenAIHelper#chat}).
349
384
  #
@@ -614,6 +649,12 @@ module Spacy
614
649
  end
615
650
  end
616
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
+
617
658
  # Creates a language model instance, which is conventionally referred to by a variable named `nlp`.
618
659
  # @param model [String] A language model installed in the system
619
660
  # @param timeout [Numeric, nil] Seconds to wait for the model to load before
@@ -622,7 +663,31 @@ module Spacy
622
663
  # because Ruby's `Timeout` cannot fire while PyCall holds the GVL. When it
623
664
  # fires, the loading thread is left running as a daemon until the process
624
665
  # exits (accepted: timeouts are an abnormal path).
625
- def initialize(model = "en_core_web_sm", max_retrial: MAX_RETRIAL, timeout: 60)
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)
626
691
  unless model.to_s.match?(/\A[a-zA-Z0-9_\-\.\/]+\z/)
627
692
  raise ArgumentError, "Invalid model name: #{model.inspect}"
628
693
  end
@@ -1003,6 +1068,15 @@ module Spacy
1003
1068
  Doc.new(@doc.py_nlp, text: text)
1004
1069
  end
1005
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
+
1006
1080
  # Returns tokens conjugated to the root of the span.
1007
1081
  # @return [Array<Token>] an array of tokens
1008
1082
  def conjuncts
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-spacy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -169,6 +169,7 @@ files:
169
169
  - Rakefile
170
170
  - bin/console
171
171
  - bin/setup
172
+ - docs/syntax_trees.md
172
173
  - examples/get_started/lexeme.rb
173
174
  - examples/get_started/linguistic_annotations.rb
174
175
  - examples/get_started/morphology.rb
@@ -230,6 +231,22 @@ files:
230
231
  - examples/openai_integration/openai_query_2.rb
231
232
  - examples/openai_integration/openai_query_3.rb
232
233
  - examples/openai_integration/openai_query_4.rb
234
+ - examples/rsyntaxtree/outputs/tree_ar_projection.png
235
+ - examples/rsyntaxtree/outputs/tree_de_projection.png
236
+ - examples/rsyntaxtree/outputs/tree_en_chunks.png
237
+ - examples/rsyntaxtree/outputs/tree_en_morphology.png
238
+ - examples/rsyntaxtree/outputs/tree_en_projection.png
239
+ - examples/rsyntaxtree/outputs/tree_ja_chunks.png
240
+ - examples/rsyntaxtree/outputs/tree_ja_projection.png
241
+ - examples/rsyntaxtree/outputs/tree_ru_morphology.png
242
+ - examples/rsyntaxtree/outputs/tree_ru_projection.png
243
+ - examples/rsyntaxtree/outputs/tree_zh_projection.png
244
+ - examples/rsyntaxtree/syntax_tree_ar.rb
245
+ - examples/rsyntaxtree/syntax_tree_de.rb
246
+ - examples/rsyntaxtree/syntax_tree_en.rb
247
+ - examples/rsyntaxtree/syntax_tree_ja.rb
248
+ - examples/rsyntaxtree/syntax_tree_ru.rb
249
+ - examples/rsyntaxtree/syntax_tree_zh.rb
233
250
  - examples/rule_based_matching/creating_spans_from_matches.rb
234
251
  - examples/rule_based_matching/matcher.rb
235
252
  - lib/ruby-spacy.rb
@@ -238,6 +255,7 @@ files:
238
255
  - lib/ruby-spacy/llm_client_base.rb
239
256
  - lib/ruby-spacy/openai_client.rb
240
257
  - lib/ruby-spacy/openai_helper.rb
258
+ - lib/ruby-spacy/syntax_tree.rb
241
259
  - lib/ruby-spacy/version.rb
242
260
  - ruby-spacy.gemspec
243
261
  homepage: https://github.com/yohasebe/ruby-spacy