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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bf4e97474302a2fffe019227e5afbb26c815036d13ec75bae6349f7186dff31
4
- data.tar.gz: 2cd4012d9545d0a5ad9d2ebb03d07e40ae54acdfb18f0c9132c8848cce5cfd8e
3
+ metadata.gz: 055dc3da59c059df185ce0d868ba8da07453f0c5c1501d2a61fc5e27e5abee28
4
+ data.tar.gz: adc27ca2bfcc46ea0ad90bcd2469c2fc5abfdad47982339a7acb085ddeb20de0
5
5
  SHA512:
6
- metadata.gz: 208f43e0efa0334ec159fb56e836f315bd5469a3768e77e0f6b7fa3ec936e722546e0323cc7bf5790393cc2fc52318e172355b821b6f3c83268de3cbf35ba139
7
- data.tar.gz: 753515dcb7fb3e44ea2c4419e83baf9e0e8f9c6810a4f65cbfc3a631414b33019b5a0b4915c9a87edd8ac34fb8aac48673954c092c50e2e1cc152713bde48466
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 Chat/Completion/Embeddings API integration |
14
+ | ✅ | LLM integration: OpenAI, Anthropic (Claude), and local models |
15
15
 
16
- Current Version: `0.4.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 GPT-5 API integration
21
- - Block-based OpenAI API with linguistic analysis
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. Install Python 3.10.6, for instance, using pyenv with `enable-shared` as follows:
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.10.6
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.10.6
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. Note: GPT-5 models do not support the `temperature` parameter.
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[:match_id] })
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