ruby-spacy 0.4.1 → 0.6.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 +74 -0
- data/CHANGELOG.md +70 -0
- data/Gemfile +0 -8
- data/README.md +112 -9
- data/examples/llm/anthropic_chat.rb +21 -0
- data/examples/llm/local_llm_ollama.rb +27 -0
- data/examples/llm/structured_ner_comparison.rb +49 -0
- data/examples/rule_based_matching/creating_spans_from_matches.rb +1 -1
- data/lib/ruby-spacy/anthropic_client.rb +66 -0
- data/lib/ruby-spacy/anthropic_helper.rb +118 -0
- data/lib/ruby-spacy/llm_client_base.rb +120 -0
- data/lib/ruby-spacy/openai_client.rb +37 -107
- data/lib/ruby-spacy/openai_helper.rb +60 -16
- data/lib/ruby-spacy/version.rb +1 -1
- data/lib/ruby-spacy.rb +197 -41
- data/ruby-spacy.gemspec +6 -6
- metadata +40 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 055dc3da59c059df185ce0d868ba8da07453f0c5c1501d2a61fc5e27e5abee28
|
|
4
|
+
data.tar.gz: adc27ca2bfcc46ea0ad90bcd2469c2fc5abfdad47982339a7acb085ddeb20de0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ef618c78e55a8607b2975bd91946ab0d337a7fa73982f05fda1bb6db4f8e773b5535b90833fe79de63f8def8a3fb08ce363592a54f941de85b57618f7b7945e
|
|
7
|
+
data.tar.gz: 0bd57b4cfe238a630c85c93c4b84b0d95b1e07dc74583cf4916fc2b16ba51222c4a35b2a5d5e18e470050ba8680ef642a1d0ba50c6d830b506bf4c4f7b6523f3
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
# Every branch, so that work in progress is tested before a pull request exists
|
|
5
|
+
push:
|
|
6
|
+
branches: ["**"]
|
|
7
|
+
pull_request:
|
|
8
|
+
# Weekly run to detect breakage from new spaCy / PyCall / Ruby releases
|
|
9
|
+
# even when the repository itself is untouched
|
|
10
|
+
schedule:
|
|
11
|
+
- cron: "23 3 * * 2"
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: ruby ${{ matrix.ruby }} / python ${{ matrix.python }}
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
# Pull requests from this repository are already covered by the push event;
|
|
18
|
+
# only run the pull_request event for forks, to avoid duplicate matrices
|
|
19
|
+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
|
|
20
|
+
# This project has known hang paths (pipeline calls from non-main threads,
|
|
21
|
+
# spacy.load hangs that Ruby's Timeout cannot interrupt); never let a job
|
|
22
|
+
# run until GitHub's 6-hour limit
|
|
23
|
+
timeout-minutes: 30
|
|
24
|
+
continue-on-error: ${{ matrix.experimental == true }}
|
|
25
|
+
strategy:
|
|
26
|
+
fail-fast: false
|
|
27
|
+
matrix:
|
|
28
|
+
ruby: ["3.2", "3.3", "3.4", "4.0"]
|
|
29
|
+
python: ["3.11", "3.12", "3.13", "3.14"]
|
|
30
|
+
include:
|
|
31
|
+
# Ruby 4.1 is not released yet; track the development branch instead
|
|
32
|
+
- { ruby: head, python: "3.13", experimental: true }
|
|
33
|
+
env:
|
|
34
|
+
# PyCall locates the Python interpreter via this variable
|
|
35
|
+
PYTHON: python
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v7
|
|
38
|
+
|
|
39
|
+
# Must precede ruby/setup-ruby: installing the pycall gem compiles its
|
|
40
|
+
# native extension against this Python
|
|
41
|
+
- uses: actions/setup-python@v7
|
|
42
|
+
with:
|
|
43
|
+
python-version: ${{ matrix.python }}
|
|
44
|
+
|
|
45
|
+
- uses: ruby/setup-ruby@v1
|
|
46
|
+
with:
|
|
47
|
+
ruby-version: ${{ matrix.ruby }}
|
|
48
|
+
bundler-cache: true
|
|
49
|
+
|
|
50
|
+
- id: site-packages
|
|
51
|
+
run: echo "path=$(python -c 'import site; print(site.getsitepackages()[0])')" >> "$GITHUB_OUTPUT"
|
|
52
|
+
|
|
53
|
+
# Cache the language models only (en_core_web_lg alone is several hundred
|
|
54
|
+
# MB). spaCy itself is intentionally installed fresh on every run so that
|
|
55
|
+
# new releases are exercised immediately. Bump the key suffix when
|
|
56
|
+
# upgrading the models.
|
|
57
|
+
- uses: actions/cache@v6
|
|
58
|
+
with:
|
|
59
|
+
path: |
|
|
60
|
+
${{ steps.site-packages.outputs.path }}/en_core_web_sm*
|
|
61
|
+
${{ steps.site-packages.outputs.path }}/en_core_web_lg*
|
|
62
|
+
key: spacy-models-${{ matrix.python }}-v1
|
|
63
|
+
|
|
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.
|
|
66
|
+
- name: Install spaCy and language models
|
|
67
|
+
run: |
|
|
68
|
+
python -m pip install --upgrade pip
|
|
69
|
+
python -m pip install --upgrade spacy
|
|
70
|
+
python -m spacy download en_core_web_sm
|
|
71
|
+
if [ "${{ matrix.python }}" = "3.13" ]; then python -m spacy download en_core_web_lg; fi
|
|
72
|
+
|
|
73
|
+
- name: Run tests
|
|
74
|
+
run: bundle exec rake test
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-08-31
|
|
4
|
+
### Added
|
|
5
|
+
- GitHub Actions CI — Ruby 3.2 to 4.0 (plus ruby-head) and Python 3.11 to 3.14,
|
|
6
|
+
with a weekly run to catch breakage from new spaCy or PyCall releases
|
|
7
|
+
- `:label` in `Matcher#match` results — the label string of the matched pattern
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- PyCall 1.5.3 or later is now required; earlier versions freeze the whole
|
|
11
|
+
process when Ruby's GC releases a Python object on a non-main thread, which
|
|
12
|
+
affects any threaded application (Rails, Puma, Sidekiq)
|
|
13
|
+
- Minimum Ruby version raised to 3.2
|
|
14
|
+
- Removed the `numpy` gem dependency; NumPy is used directly through PyCall
|
|
15
|
+
- Relaxed the `terminal-table` requirement to `>= 3.0, < 5`
|
|
16
|
+
- Language models are no longer stored in Python's `__main__`, so creating a
|
|
17
|
+
`Language` no longer keeps a pipeline alive until the process exits
|
|
18
|
+
- `Language.new(timeout:)` now actually fires. The wait happens on a Python
|
|
19
|
+
thread rather than through Ruby's `Timeout`, whose watcher thread cannot run
|
|
20
|
+
while PyCall holds the GVL. `timeout: nil` waits indefinitely, and a timeout
|
|
21
|
+
is never retried
|
|
22
|
+
- README documents the thread-safety constraint: all spaCy calls must be made
|
|
23
|
+
from the thread that initialized PyCall
|
|
24
|
+
|
|
25
|
+
### Deprecated
|
|
26
|
+
- `Language#spacy_nlp_id` — use `#py_nlp` instead; referencing it still works
|
|
27
|
+
but creates a Python global variable that is never released
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- `Matcher#match` returned a corrupted `:match_id` (often `0`) for labels whose
|
|
31
|
+
hash was `2**62` or larger, since unsigned 64-bit values do not survive the
|
|
32
|
+
PyCall boundary. Ids now round-trip through `Language#vocab_string_lookup`,
|
|
33
|
+
which accepts large ids again
|
|
34
|
+
- Integer attributes reached through `method_missing`, such as `Token#orth`,
|
|
35
|
+
were silently corrupted for the same reason: 7 of 12 common words returned a
|
|
36
|
+
negative `orth`. They are now fetched safely, which also restores
|
|
37
|
+
`Token#rank` for out-of-vocabulary tokens
|
|
38
|
+
|
|
39
|
+
## 0.5.0 - 2026-07-21
|
|
40
|
+
### Added
|
|
41
|
+
- `Language#with_llm(provider:)` — provider-neutral block-based LLM API
|
|
42
|
+
supporting `:openai`, `:anthropic` (Claude), and `:ollama` (local models)
|
|
43
|
+
- `AnthropicHelper` / `AnthropicClient` — Anthropic Messages API support
|
|
44
|
+
using only net/http (default model: `claude-sonnet-5`; requires
|
|
45
|
+
`ANTHROPIC_API_KEY`)
|
|
46
|
+
- `base_url:` option for OpenAI client/helper — works with any
|
|
47
|
+
OpenAI-compatible server (Ollama, LM Studio, llama.cpp server, vLLM,
|
|
48
|
+
OpenRouter, etc.)
|
|
49
|
+
- `schema:` option for `chat` — Structured Outputs on both providers;
|
|
50
|
+
returns a validated, parsed Ruby Hash
|
|
51
|
+
- New examples under `examples/llm/` (Anthropic, local Ollama, and a
|
|
52
|
+
spaCy-vs-LLM NER comparison with structured outputs)
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- `temperature` is now sent only when explicitly specified; if a model
|
|
56
|
+
rejects it, the request is retried once without it. The model-name
|
|
57
|
+
heuristic (`temperature_unsupported?`) has been removed, so new models
|
|
58
|
+
work without gem updates. Note: previously a default of 0.7 was sent to
|
|
59
|
+
models that supported it (also from `Doc#openai_query` /
|
|
60
|
+
`Doc#openai_completion`); now the API default applies unless you pass
|
|
61
|
+
`temperature:` yourself
|
|
62
|
+
- Shared HTTP layer (`LLMClientBase`) extracted from `OpenAIClient`;
|
|
63
|
+
no behavior change for existing OpenAI usage
|
|
64
|
+
- Truncated responses (`finish_reason: length` / `stop_reason: max_tokens`)
|
|
65
|
+
now emit a warning; with `schema:`, unparseable (e.g. truncated) JSON
|
|
66
|
+
returns nil with a warning instead of raising `JSON::ParserError`
|
|
67
|
+
- LLM error messages are printed to stderr (`Kernel#warn`) instead of
|
|
68
|
+
stdout, keeping stdout clean for program output
|
|
69
|
+
- Default model names are now public constants:
|
|
70
|
+
`OpenAIClient::DEFAULT_MODEL`, `OpenAIClient::DEFAULT_EMBEDDINGS_MODEL`,
|
|
71
|
+
`AnthropicClient::DEFAULT_MODEL`
|
|
72
|
+
|
|
3
73
|
## 0.4.1 - 2026-07-19
|
|
4
74
|
### Fixed
|
|
5
75
|
- Gem packaging: normalize file permissions at build time so packaged files
|
data/Gemfile
CHANGED
|
@@ -5,15 +5,7 @@ source "https://rubygems.org"
|
|
|
5
5
|
# Specify your gem's dependencies in ruby-spacy.gemspec
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
|
-
gem "fiddle" # Required for Ruby 4.0+ (moved from default to bundled gem)
|
|
9
|
-
gem "numpy"
|
|
10
|
-
gem "pycall", "~> 1.5.1"
|
|
11
|
-
gem "terminal-table"
|
|
12
|
-
|
|
13
8
|
group :development do
|
|
14
9
|
gem "github-markup"
|
|
15
|
-
gem "minitest", "~> 5.0"
|
|
16
|
-
gem "rake", "~> 13.0"
|
|
17
10
|
gem "redcarpet"
|
|
18
|
-
gem "yard"
|
|
19
11
|
end
|
data/README.md
CHANGED
|
@@ -11,27 +11,28 @@
|
|
|
11
11
|
| ✅ | Named entity recognition |
|
|
12
12
|
| ✅ | Syntactic dependency visualization |
|
|
13
13
|
| ✅ | Access to pre-trained word vectors |
|
|
14
|
-
| ✅ | OpenAI
|
|
14
|
+
| ✅ | LLM integration: OpenAI, Anthropic (Claude), and local models |
|
|
15
15
|
|
|
16
|
-
Current Version: `0.
|
|
16
|
+
Current Version: `0.6.0`
|
|
17
17
|
|
|
18
|
-
- Ruby 4.0 supported
|
|
18
|
+
- Ruby 3.2 to 4.0 supported (PyCall 1.5.3 or later required)
|
|
19
19
|
- spaCy 3.8 supported
|
|
20
|
-
- OpenAI
|
|
21
|
-
-
|
|
20
|
+
- Multi-provider LLM API: OpenAI, Anthropic (Claude), and local models via Ollama or any OpenAI-compatible server
|
|
21
|
+
- Structured outputs (JSON Schema) support
|
|
22
|
+
- Block-based LLM API with linguistic analysis
|
|
22
23
|
|
|
23
24
|
## Installation of Prerequisites
|
|
24
25
|
|
|
25
|
-
**IMPORTANT**: Make sure that the `enable-shared` option is enabled in your Python installation. You can use [pyenv](https://github.com/pyenv/pyenv) to install any version of Python you like.
|
|
26
|
+
**IMPORTANT**: Make sure that the `enable-shared` option is enabled in your Python installation. You can use [pyenv](https://github.com/pyenv/pyenv) to install any version of Python you like. spaCy 3.8 supports Python 3.10 and later (wheels are provided for Python 3.10–3.14), so we recommend using one of those versions. Install Python 3.13, for instance, using pyenv with `enable-shared` as follows:
|
|
26
27
|
|
|
27
28
|
```shell
|
|
28
|
-
$ env CONFIGURE_OPTS="--enable-shared" pyenv install 3.
|
|
29
|
+
$ env CONFIGURE_OPTS="--enable-shared" pyenv install 3.13
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
Remember to make it accessible from your working directory. It is recommended that you set `global` to the version of python you just installed.
|
|
32
33
|
|
|
33
34
|
```shell
|
|
34
|
-
$ pyenv global 3.
|
|
35
|
+
$ pyenv global 3.13
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
Then, install [spaCy](https://spacy.io/). If you use `pip`, the following command will do:
|
|
@@ -524,6 +525,30 @@ Output:
|
|
|
524
525
|
| 9 | アルザス | 0.5644999742507935 |
|
|
525
526
|
| 10 | 南仏 | 0.5547999739646912 |
|
|
526
527
|
|
|
528
|
+
### Matcher
|
|
529
|
+
|
|
530
|
+
`Matcher` finds token sequences with rule-based patterns.
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
require "ruby-spacy"
|
|
534
|
+
|
|
535
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
536
|
+
|
|
537
|
+
matcher = nlp.matcher
|
|
538
|
+
matcher.add("GREETING", [[{ LOWER: "hello" }, { IS_PUNCT: true }, { LOWER: "world" }]])
|
|
539
|
+
|
|
540
|
+
doc = nlp.read("Hello, world!")
|
|
541
|
+
matcher.match(doc).each do |match|
|
|
542
|
+
span = doc.span(match[:start_index]..match[:end_index])
|
|
543
|
+
puts "#{match[:label]}: #{span.text}"
|
|
544
|
+
end
|
|
545
|
+
# => GREETING: Hello, world
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`Matcher#match` returns an array of hashes with `:match_id` (the label's numeric id), `:start_index`, `:end_index`, and `:label` (the label string).
|
|
549
|
+
|
|
550
|
+
See `examples/rule_based_matching/` for more examples.
|
|
551
|
+
|
|
527
552
|
### PhraseMatcher
|
|
528
553
|
|
|
529
554
|
`PhraseMatcher` is more efficient than `Matcher` for matching large terminology lists. It's ideal for extracting known entities like product names, company names, or domain-specific terms.
|
|
@@ -588,7 +613,9 @@ end
|
|
|
588
613
|
|
|
589
614
|
## OpenAI API Integration
|
|
590
615
|
|
|
591
|
-
> ⚠️ This feature requires GPT-5 series models. Please refer to OpenAI's [API reference](https://platform.openai.com/docs/api-reference) for details.
|
|
616
|
+
> ⚠️ This feature requires GPT-5 series models. Please refer to OpenAI's [API reference](https://platform.openai.com/docs/api-reference) for details.
|
|
617
|
+
|
|
618
|
+
> ℹ️ The `temperature` parameter is sent to the API only when you specify it explicitly. If the model does not support it (e.g., GPT-5 series and o-series models), the request is automatically retried once without it — no per-model configuration is needed.
|
|
592
619
|
|
|
593
620
|
Easily leverage GPT models within ruby-spacy by using an OpenAI API key. When constructing prompts for the `Doc::openai_query` method, you can incorporate the following token properties of the document. These properties are retrieved through tool calls (made internally by GPT when necessary) and seamlessly integrated into your prompt. The available properties include:
|
|
594
621
|
|
|
@@ -903,8 +930,82 @@ nlp.with_openai do |ai|
|
|
|
903
930
|
end
|
|
904
931
|
```
|
|
905
932
|
|
|
933
|
+
### Multi-provider LLM API
|
|
934
|
+
|
|
935
|
+
The `Language#with_llm` block API generalizes `with_openai` to multiple providers. The helper yielded to the block has the same `chat` interface for every provider.
|
|
936
|
+
|
|
937
|
+
**Anthropic (Claude):**
|
|
938
|
+
|
|
939
|
+
```ruby
|
|
940
|
+
# Requires the ANTHROPIC_API_KEY environment variable
|
|
941
|
+
# Default model: claude-sonnet-5 (override with model: "...")
|
|
942
|
+
nlp.with_llm(provider: :anthropic) do |ai|
|
|
943
|
+
result = ai.chat(
|
|
944
|
+
system: "You are a linguistic analyst.",
|
|
945
|
+
user: doc.linguistic_summary
|
|
946
|
+
)
|
|
947
|
+
puts result
|
|
948
|
+
end
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
**Local models via Ollama (no API key needed):**
|
|
952
|
+
|
|
953
|
+
```ruby
|
|
954
|
+
# Requires a running Ollama server: https://ollama.com
|
|
955
|
+
nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
956
|
+
puts ai.chat(user: doc.linguistic_summary)
|
|
957
|
+
end
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
Any other OpenAI-compatible server (LM Studio, llama.cpp server, vLLM, OpenRouter, etc.) works via `base_url:`:
|
|
961
|
+
|
|
962
|
+
```ruby
|
|
963
|
+
nlp.with_llm(provider: :openai, base_url: "http://localhost:1234/v1",
|
|
964
|
+
access_token: "not-needed", model: "your-model") do |ai|
|
|
965
|
+
puts ai.chat(user: "Hello!")
|
|
966
|
+
end
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
**Structured outputs (`schema:`):** pass a JSON Schema and receive a validated, parsed Ruby Hash — useful for comparing LLM output with spaCy's analysis programmatically. Works with both `:openai` and `:anthropic`. Objects in the schema must set `additionalProperties: false`.
|
|
970
|
+
|
|
971
|
+
```ruby
|
|
972
|
+
schema = {
|
|
973
|
+
type: "object",
|
|
974
|
+
properties: {
|
|
975
|
+
entities: {
|
|
976
|
+
type: "array",
|
|
977
|
+
items: {
|
|
978
|
+
type: "object",
|
|
979
|
+
properties: { text: { type: "string" }, label: { type: "string" } },
|
|
980
|
+
required: %w[text label],
|
|
981
|
+
additionalProperties: false
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
required: ["entities"],
|
|
986
|
+
additionalProperties: false
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
result = nlp.with_llm(provider: :openai) do |ai|
|
|
990
|
+
ai.chat(system: "Extract named entities.", user: doc.text, schema: schema)
|
|
991
|
+
end
|
|
992
|
+
result["entities"].each { |ent| puts "#{ent["text"]} (#{ent["label"]})" }
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
**Note on `temperature`:** for all providers, `temperature` is omitted from requests unless you pass it explicitly (`ai.chat(user: "...", temperature: 0.3)`). Models that reject the parameter (e.g., GPT-5 series, o-series, and current Claude models) are automatically retried once without it, so any model works without per-model configuration.
|
|
996
|
+
|
|
997
|
+
See `examples/llm/` for complete scripts, including a spaCy-vs-LLM NER comparison.
|
|
998
|
+
|
|
906
999
|
## Advanced Usage
|
|
907
1000
|
|
|
1001
|
+
### Thread Safety
|
|
1002
|
+
|
|
1003
|
+
All spaCy calls must be made from the same thread that initialized PyCall (normally the main thread). Calling the spaCy pipeline from another thread — e.g. `nlp.read(text)` inside a `Thread.new` block, a Rails multi-threaded server, or a Sidekiq worker — will hang the entire process.
|
|
1004
|
+
|
|
1005
|
+
Note that attribute access (such as `nlp.pipe_names`) works from other threads, so the failure mode is not obvious: the process only freezes when the pipeline actually runs. The root cause is currently unknown (it is specific to spaCy pipeline execution; other GIL-releasing Python calls work fine from other threads).
|
|
1006
|
+
|
|
1007
|
+
If you need concurrent processing, serialize all Python calls onto a single dedicated thread, for example with a worker thread and a queue.
|
|
1008
|
+
|
|
908
1009
|
### Setting a Timeout
|
|
909
1010
|
|
|
910
1011
|
You can set a timeout for the `Spacy::Language.new` method:
|
|
@@ -913,6 +1014,8 @@ You can set a timeout for the `Spacy::Language.new` method:
|
|
|
913
1014
|
nlp = Spacy::Language.new("en_core_web_sm", timeout: 120) # Set timeout to 120 seconds
|
|
914
1015
|
```
|
|
915
1016
|
|
|
1017
|
+
If the model does not finish loading within the given seconds, a `RuntimeError` is raised. Pass `timeout: nil` to wait indefinitely.
|
|
1018
|
+
|
|
916
1019
|
### Document Serialization
|
|
917
1020
|
|
|
918
1021
|
You can serialize processed documents to binary format for caching or storage. This is useful when you want to avoid re-processing the same text multiple times.
|
|
@@ -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
|
+
|
|
8
|
+
# Requires the ANTHROPIC_API_KEY environment variable
|
|
9
|
+
# Default model: claude-sonnet-5 (override with model: "...")
|
|
10
|
+
|
|
11
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
12
|
+
doc = nlp.read("The cat sat on the mat while the dog slept under the table.")
|
|
13
|
+
|
|
14
|
+
result = nlp.with_llm(provider: :anthropic) do |ai|
|
|
15
|
+
ai.chat(
|
|
16
|
+
system: "You are a linguistics tutor. Explain the syntactic structure briefly.",
|
|
17
|
+
user: doc.linguistic_summary
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
puts result
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
|
|
8
|
+
# Requires a running Ollama server (https://ollama.com):
|
|
9
|
+
# ollama pull llama3.2
|
|
10
|
+
# ollama serve
|
|
11
|
+
# No API key is needed.
|
|
12
|
+
|
|
13
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
14
|
+
doc = nlp.read("I sat on the bank of the river.")
|
|
15
|
+
|
|
16
|
+
result = nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
17
|
+
ai.chat(
|
|
18
|
+
system: "Identify the meaning of the word 'bank' in one short sentence.",
|
|
19
|
+
user: doc.linguistic_summary
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
puts result
|
|
24
|
+
|
|
25
|
+
# Any OpenAI-compatible server works the same way via base_url, e.g.:
|
|
26
|
+
# nlp.with_llm(provider: :openai, base_url: "http://localhost:1234/v1",
|
|
27
|
+
# access_token: "not-needed", model: "your-model") { |ai| ... }
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
|
|
8
|
+
# Compares spaCy's named entity recognition with an LLM's, using structured
|
|
9
|
+
# outputs so the LLM's answer comes back as a validated Ruby Hash.
|
|
10
|
+
# Requires the OPENAI_API_KEY environment variable
|
|
11
|
+
# (or swap in provider: :anthropic with ANTHROPIC_API_KEY).
|
|
12
|
+
|
|
13
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
14
|
+
text = "Mr. Best flew to New York on Saturday morning to meet Tim Cook of Apple."
|
|
15
|
+
doc = nlp.read(text)
|
|
16
|
+
|
|
17
|
+
schema = {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
entities: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
text: { type: "string" },
|
|
26
|
+
label: { type: "string", enum: %w[PERSON ORG GPE DATE TIME OTHER] }
|
|
27
|
+
},
|
|
28
|
+
required: %w[text label],
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ["entities"],
|
|
34
|
+
additionalProperties: false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
llm_result = nlp.with_llm(provider: :openai) do |ai|
|
|
38
|
+
ai.chat(
|
|
39
|
+
system: "Extract all named entities from the user's text.",
|
|
40
|
+
user: text,
|
|
41
|
+
schema: schema
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts "spaCy entities:"
|
|
46
|
+
doc.ents.each { |ent| puts " #{ent.text} (#{ent.label})" }
|
|
47
|
+
|
|
48
|
+
puts "\nLLM entities:"
|
|
49
|
+
llm_result["entities"].each { |ent| puts " #{ent["text"]} (#{ent["label"]})" }
|
|
@@ -14,7 +14,7 @@ doc = nlp.read("Barack Obama was the 44th president of the United States")
|
|
|
14
14
|
matches = matcher.match(doc)
|
|
15
15
|
|
|
16
16
|
matches.each do |match|
|
|
17
|
-
span = Spacy::Span.new(doc, start_index: match[:start_index], end_index: match[:end_index], options: { label: match[:
|
|
17
|
+
span = Spacy::Span.new(doc, start_index: match[:start_index], end_index: match[:end_index], options: { label: match[:label] })
|
|
18
18
|
puts "#{span.text} / #{span.label}"
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_client_base"
|
|
4
|
+
|
|
5
|
+
module Spacy
|
|
6
|
+
# A lightweight Anthropic Messages API client without external dependencies.
|
|
7
|
+
class AnthropicClient < LLMClientBase
|
|
8
|
+
# Default Anthropic API endpoint
|
|
9
|
+
API_ENDPOINT = "https://api.anthropic.com/v1"
|
|
10
|
+
# Messages API version header value
|
|
11
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
12
|
+
# Default model for chat requests
|
|
13
|
+
DEFAULT_MODEL = "claude-sonnet-5"
|
|
14
|
+
# (see LLMClientBase::APIError)
|
|
15
|
+
APIError = LLMClientBase::APIError
|
|
16
|
+
|
|
17
|
+
# @param access_token [String] Anthropic API key
|
|
18
|
+
# @param timeout [Integer] request timeout in seconds
|
|
19
|
+
# @param base_url [String] API endpoint override
|
|
20
|
+
def initialize(access_token:, timeout: DEFAULT_TIMEOUT, base_url: API_ENDPOINT)
|
|
21
|
+
super(base_url: base_url, timeout: timeout)
|
|
22
|
+
@access_token = access_token
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Sends a Messages API request.
|
|
26
|
+
#
|
|
27
|
+
# The +temperature+ parameter is sent only when explicitly given. Current
|
|
28
|
+
# Claude models (Opus 4.7+) reject it; the request is then retried once
|
|
29
|
+
# without it.
|
|
30
|
+
#
|
|
31
|
+
# @param model [String] The model to use (e.g., "claude-sonnet-5")
|
|
32
|
+
# @param messages [Array<Hash>] The conversation messages
|
|
33
|
+
# @param system [String, nil] System prompt (top-level parameter)
|
|
34
|
+
# @param max_tokens [Integer] Maximum tokens in the response (required by the API)
|
|
35
|
+
# @param temperature [Float, nil] Sampling temperature (omitted when nil)
|
|
36
|
+
# @param output_config [Hash, nil] Output configuration, e.g.
|
|
37
|
+
# { format: { type: "json_schema", schema: {...} } } for structured outputs
|
|
38
|
+
# @return [Hash] The API response
|
|
39
|
+
def messages(model:, messages:, system: nil, max_tokens: 1000, temperature: nil, output_config: nil)
|
|
40
|
+
body = {
|
|
41
|
+
model: model,
|
|
42
|
+
max_tokens: max_tokens,
|
|
43
|
+
messages: messages
|
|
44
|
+
}
|
|
45
|
+
body[:system] = system if system
|
|
46
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
47
|
+
body[:output_config] = output_config if output_config
|
|
48
|
+
|
|
49
|
+
post("/messages", body)
|
|
50
|
+
rescue APIError => e
|
|
51
|
+
raise unless body.key?(:temperature) && e.status_code == 400 && e.message.match?(/temperature/i)
|
|
52
|
+
|
|
53
|
+
warn "Warning: model does not support the temperature parameter; retrying without it"
|
|
54
|
+
post("/messages", body.reject { |key, _| key == :temperature })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def request_headers
|
|
60
|
+
{
|
|
61
|
+
"x-api-key" => @access_token,
|
|
62
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spacy
|
|
4
|
+
# A helper class for Anthropic (Claude) API interactions, designed to work
|
|
5
|
+
# with spaCy's linguistic analysis via the block-based {Language#with_llm} API.
|
|
6
|
+
#
|
|
7
|
+
# Provides the same chat interface as {OpenAIHelper}, so code written against
|
|
8
|
+
# one provider works with the other. Anthropic does not offer an embeddings
|
|
9
|
+
# API; use the :openai or :ollama provider for embeddings.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage with linguistic_summary
|
|
12
|
+
# nlp = Spacy::Language.new("en_core_web_sm")
|
|
13
|
+
# nlp.with_llm(provider: :anthropic) do |ai|
|
|
14
|
+
# doc = nlp.read("Apple Inc. was founded by Steve Jobs.")
|
|
15
|
+
# ai.chat(system: "Analyze the linguistic data.", user: doc.linguistic_summary)
|
|
16
|
+
# end
|
|
17
|
+
class AnthropicHelper
|
|
18
|
+
# @return [String] the default model for chat requests
|
|
19
|
+
attr_reader :model
|
|
20
|
+
|
|
21
|
+
# Creates a new AnthropicHelper instance.
|
|
22
|
+
# @param access_token [String, nil] Anthropic API key (defaults to ANTHROPIC_API_KEY env var)
|
|
23
|
+
# @param model [String] the default model for chat requests
|
|
24
|
+
# @param max_tokens [Integer] default maximum tokens in responses
|
|
25
|
+
# @param temperature [Float, nil] default sampling temperature (omitted from
|
|
26
|
+
# requests when nil; models that reject it are retried without it)
|
|
27
|
+
# @param base_url [String, nil] API endpoint override
|
|
28
|
+
def initialize(access_token: nil, model: AnthropicClient::DEFAULT_MODEL,
|
|
29
|
+
max_tokens: 1000, temperature: nil, base_url: nil)
|
|
30
|
+
@access_token = access_token || ENV["ANTHROPIC_API_KEY"]
|
|
31
|
+
raise "Error: ANTHROPIC_API_KEY is not set" unless @access_token
|
|
32
|
+
|
|
33
|
+
@model = model
|
|
34
|
+
@default_max_tokens = max_tokens
|
|
35
|
+
@default_temperature = temperature
|
|
36
|
+
@client = AnthropicClient.new(access_token: @access_token,
|
|
37
|
+
base_url: base_url || AnthropicClient::API_ENDPOINT)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sends a Messages API request.
|
|
41
|
+
#
|
|
42
|
+
# Provides convenient `system:` and `user:` keyword arguments as shortcuts.
|
|
43
|
+
# For multi-turn conversations, pass a full `messages:` array directly
|
|
44
|
+
# (the system prompt stays a separate `system:` argument — Anthropic takes
|
|
45
|
+
# it as a top-level parameter, not a message role).
|
|
46
|
+
#
|
|
47
|
+
# @param system [String, nil] system prompt
|
|
48
|
+
# @param user [String, nil] user message content (shortcut)
|
|
49
|
+
# @param messages [Array<Hash>, nil] full message array (overrides user:)
|
|
50
|
+
# @param model [String, nil] model override (defaults to instance model)
|
|
51
|
+
# @param max_tokens [Integer, nil] token limit override
|
|
52
|
+
# @param temperature [Float, nil] temperature override
|
|
53
|
+
# @param schema [Hash, nil] JSON Schema for structured outputs; when given,
|
|
54
|
+
# the model output is constrained to the schema and the parsed Hash is
|
|
55
|
+
# returned. Objects in the schema must set `additionalProperties: false`.
|
|
56
|
+
# @param raw [Boolean] if true, returns the full API response Hash instead of text
|
|
57
|
+
# @return [String, Hash, nil] the response text, parsed Hash (if schema:),
|
|
58
|
+
# full response Hash (if raw:), or nil on API error, refusal, or when
|
|
59
|
+
# the schema output cannot be parsed as JSON (e.g., truncated by the
|
|
60
|
+
# token limit)
|
|
61
|
+
def chat(system: nil, user: nil, messages: nil,
|
|
62
|
+
model: nil, max_tokens: nil, temperature: nil,
|
|
63
|
+
schema: nil, raw: false)
|
|
64
|
+
msgs = messages || (user ? [{ role: "user", content: user }] : [])
|
|
65
|
+
raise ArgumentError, "No messages provided. Use user:/messages:" if msgs.empty?
|
|
66
|
+
|
|
67
|
+
output_config = schema ? { format: { type: "json_schema", schema: schema } } : nil
|
|
68
|
+
|
|
69
|
+
response = @client.messages(
|
|
70
|
+
model: model || @model,
|
|
71
|
+
messages: msgs,
|
|
72
|
+
system: system,
|
|
73
|
+
max_tokens: max_tokens || @default_max_tokens,
|
|
74
|
+
temperature: temperature || @default_temperature,
|
|
75
|
+
output_config: output_config
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return response if raw
|
|
79
|
+
|
|
80
|
+
# Safety classifiers can decline a request with HTTP 200 and an empty
|
|
81
|
+
# or partial content array.
|
|
82
|
+
if response["stop_reason"] == "refusal"
|
|
83
|
+
warn "Error: Anthropic API declined the request (stop_reason: refusal)"
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if response["stop_reason"] == "max_tokens"
|
|
88
|
+
warn "Warning: response was truncated (stop_reason: max_tokens); consider increasing max_tokens"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
text = Array(response["content"])
|
|
92
|
+
.select { |block| block["type"] == "text" }
|
|
93
|
+
.map { |block| block["text"] }
|
|
94
|
+
.join
|
|
95
|
+
schema ? parse_json_content(text) : text
|
|
96
|
+
rescue AnthropicClient::APIError => e
|
|
97
|
+
warn "Error: Anthropic API call failed - #{e.message}"
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Anthropic does not provide an embeddings API.
|
|
102
|
+
# @raise [NotImplementedError] always
|
|
103
|
+
def embeddings(*)
|
|
104
|
+
raise NotImplementedError,
|
|
105
|
+
"Anthropic does not provide an embeddings API. " \
|
|
106
|
+
"Use with_llm(provider: :openai) or a local OpenAI-compatible server for embeddings."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse_json_content(text)
|
|
112
|
+
JSON.parse(text)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
warn "Error: response is not valid JSON (possibly truncated output); returning nil"
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|