ruby-spacy 0.3.0 → 0.4.1

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +6 -0
  3. data/CHANGELOG.md +24 -0
  4. data/README.md +61 -1
  5. data/Rakefile +13 -0
  6. data/examples/get_started/lexeme.rb +0 -0
  7. data/examples/get_started/linguistic_annotations.rb +0 -0
  8. data/examples/get_started/morphology.rb +0 -0
  9. data/examples/get_started/most_similar.rb +0 -0
  10. data/examples/get_started/named_entities.rb +0 -0
  11. data/examples/get_started/outputs/test_dep.svg +0 -0
  12. data/examples/get_started/outputs/test_dep_compact.svg +0 -0
  13. data/examples/get_started/outputs/test_ent.html +0 -0
  14. data/examples/get_started/pos_tags_and_dependencies.rb +0 -0
  15. data/examples/get_started/similarity.rb +0 -0
  16. data/examples/get_started/tokenization.rb +0 -0
  17. data/examples/get_started/visualizing_dependencies.rb +0 -0
  18. data/examples/get_started/visualizing_dependencies_compact.rb +0 -0
  19. data/examples/get_started/visualizing_named_entities.rb +0 -0
  20. data/examples/get_started/vocab.rb +0 -0
  21. data/examples/get_started/word_vectors.rb +0 -0
  22. data/examples/japanese/ancestors.rb +0 -0
  23. data/examples/japanese/entity_annotations_and_labels.rb +0 -0
  24. data/examples/japanese/information_extraction.rb +0 -0
  25. data/examples/japanese/lemmatization.rb +0 -0
  26. data/examples/japanese/most_similar.rb +0 -0
  27. data/examples/japanese/named_entity_recognition.rb +0 -0
  28. data/examples/japanese/navigating_parse_tree.rb +0 -0
  29. data/examples/japanese/noun_chunks.rb +0 -0
  30. data/examples/japanese/outputs/test_dep.svg +0 -0
  31. data/examples/japanese/outputs/test_ent.html +0 -0
  32. data/examples/japanese/pos_tagging.rb +0 -0
  33. data/examples/japanese/sentence_segmentation.rb +0 -0
  34. data/examples/japanese/similarity.rb +0 -0
  35. data/examples/japanese/tokenization.rb +0 -0
  36. data/examples/japanese/visualizing_dependencies.rb +0 -0
  37. data/examples/japanese/visualizing_named_entities.rb +0 -0
  38. data/examples/linguistic_features/ancestors.rb +0 -0
  39. data/examples/linguistic_features/entity_annotations_and_labels.rb +0 -0
  40. data/examples/linguistic_features/finding_a_verb_with_a_subject.rb +0 -0
  41. data/examples/linguistic_features/information_extraction.rb +0 -0
  42. data/examples/linguistic_features/iterating_children.rb +0 -0
  43. data/examples/linguistic_features/iterating_lefts_and_rights.rb +0 -0
  44. data/examples/linguistic_features/lemmatization.rb +0 -0
  45. data/examples/linguistic_features/named_entity_recognition.rb +0 -0
  46. data/examples/linguistic_features/navigating_parse_tree.rb +0 -0
  47. data/examples/linguistic_features/noun_chunks.rb +0 -0
  48. data/examples/linguistic_features/outputs/test_ent.html +0 -0
  49. data/examples/linguistic_features/pos_tagging.rb +0 -0
  50. data/examples/linguistic_features/retokenize_1.rb +0 -0
  51. data/examples/linguistic_features/retokenize_2.rb +0 -0
  52. data/examples/linguistic_features/rule_based_morphology.rb +0 -0
  53. data/examples/linguistic_features/sentence_segmentation.rb +0 -0
  54. data/examples/linguistic_features/similarity.rb +0 -0
  55. data/examples/linguistic_features/similarity_between_lexemes.rb +0 -0
  56. data/examples/linguistic_features/similarity_between_spans.rb +0 -0
  57. data/examples/linguistic_features/tokenization.rb +0 -0
  58. data/examples/openai_integration/openai_embeddings.rb +0 -0
  59. data/examples/rule_based_matching/creating_spans_from_matches.rb +0 -0
  60. data/examples/rule_based_matching/matcher.rb +0 -0
  61. data/lib/ruby-spacy/openai_client.rb +57 -40
  62. data/lib/ruby-spacy/openai_helper.rb +91 -0
  63. data/lib/ruby-spacy/version.rb +1 -1
  64. data/lib/ruby-spacy.rb +282 -189
  65. data/ruby-spacy.gemspec +1 -0
  66. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6005c638c2b268fe162b288e124439be6a525952557a48b0b50685bbd2a6ea1
4
- data.tar.gz: 41dbc057c9ec51ffa8d6f1149fb8acde3fb52a251299d0209b4e2d351942eac0
3
+ metadata.gz: 9bf4e97474302a2fffe019227e5afbb26c815036d13ec75bae6349f7186dff31
4
+ data.tar.gz: 2cd4012d9545d0a5ad9d2ebb03d07e40ae54acdfb18f0c9132c8848cce5cfd8e
5
5
  SHA512:
6
- metadata.gz: 5be0efa9e649b3d46da859472ce403adaa3cdaa34d4158e7a531680eb2830ae64779ec6ada8f0f6e324cc9cb314fb1fcbc617daa26e37e91a7d14f703caeec2d
7
- data.tar.gz: b8f56b4842fea3bec1b35366624c7ab9297c3a3b25c9a8502dc32c623593e511d9da538bf3e5cac272baf854cf4c2c97d4129790b492329183d88873467f8dbb
6
+ metadata.gz: 208f43e0efa0334ec159fb56e836f315bd5469a3768e77e0f6b7fa3ec936e722546e0323cc7bf5790393cc2fc52318e172355b821b6f3c83268de3cbf35ba139
7
+ data.tar.gz: 753515dcb7fb3e44ea2c4419e83baf9e0e8f9c6810a4f65cbfc3a631414b33019b5a0b4915c9a87edd8ac34fb8aac48673954c092c50e2e1cc152713bde48466
@@ -0,0 +1,6 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [yohasebe]
4
+ ko_fi: yohasebe
5
+ buy_me_a_coffee: yohasebe
6
+ # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Change Log
2
2
 
3
+ ## 0.4.1 - 2026-07-19
4
+ ### Fixed
5
+ - Gem packaging: normalize file permissions at build time so packaged files
6
+ are world-readable (0644 / 0755). Version 0.4.0 shipped 11 owner-only
7
+ (0600) files, which made the gem unusable after `sudo gem install`
8
+
9
+ ## 0.4.0 - 2026-02-23
10
+ ### Added
11
+ - `Language#with_openai` block API with `OpenAIHelper` for streamlined OpenAI integration
12
+ - `Doc#linguistic_summary` for JSON-formatted spaCy analysis output
13
+ - `Token#idx`, `Span#text`/`Span#to_s`, `Language#memory_zone` (spaCy 3.8+)
14
+
15
+ ### Fixed
16
+ - `Doc#ents` returns proper `Span` objects instead of raw Python objects
17
+
18
+ ### Changed
19
+ - Model name validation in `Language#initialize` for security
20
+ - OpenAI client: temperature support for o-series models, 429 retry with
21
+ exponential backoff, client reuse, `dimensions`/`response_format` parameters,
22
+ tool call depth limit
23
+ - Improved `respond_to_missing?` across all wrapper classes
24
+ - Added `instance_variables_to_inspect` for Ruby 4.0+ compatibility
25
+ - Added `base64` gem dependency (required for Ruby 3.4+)
26
+
3
27
  ## 0.3.0 - 2025-01-06
4
28
  ### Added
5
29
  - Ruby 4.0 support
data/README.md CHANGED
@@ -13,11 +13,12 @@
13
13
  | ✅ | Access to pre-trained word vectors |
14
14
  | ✅ | OpenAI Chat/Completion/Embeddings API integration |
15
15
 
16
- Current Version: `0.3.0`
16
+ Current Version: `0.4.0`
17
17
 
18
18
  - Ruby 4.0 supported
19
19
  - spaCy 3.8 supported
20
20
  - OpenAI GPT-5 API integration
21
+ - Block-based OpenAI API with linguistic analysis
21
22
 
22
23
  ## Installation of Prerequisites
23
24
 
@@ -843,6 +844,65 @@ Output:
843
844
  ...
844
845
  ```
845
846
 
847
+ ### Block-based OpenAI API
848
+
849
+ The `Language#with_openai` block API provides a streamlined way to combine spaCy's linguistic analysis with OpenAI. The `Doc#linguistic_summary` method generates a JSON summary of spaCy's analysis (tokens, entities, noun chunks, etc.) that can be passed directly to the LLM as context.
850
+
851
+ **Basic usage:**
852
+
853
+ ```ruby
854
+ require "ruby-spacy"
855
+
856
+ nlp = Spacy::Language.new("en_core_web_sm")
857
+ doc = nlp.read("Apple Inc. was founded by Steve Jobs in California.")
858
+
859
+ nlp.with_openai(model: "gpt-5-mini") do |ai|
860
+ result = ai.chat(
861
+ system: "You are a linguistic analyst. Analyze the given linguistic data.",
862
+ user: doc.linguistic_summary
863
+ )
864
+ puts result
865
+ end
866
+ ```
867
+
868
+ **Batch processing with `pipe`:**
869
+
870
+ ```ruby
871
+ require "ruby-spacy"
872
+
873
+ nlp = Spacy::Language.new("en_core_web_sm")
874
+ texts = ["The bank approved the loan.", "I sat on the river bank."]
875
+
876
+ nlp.with_openai(model: "gpt-5-mini") do |ai|
877
+ nlp.pipe(texts).each do |doc|
878
+ result = ai.chat(
879
+ system: "Identify the meaning of 'bank' in one word based on the linguistic context.",
880
+ user: doc.linguistic_summary
881
+ )
882
+ puts "#{doc.text} => #{result}"
883
+ end
884
+ end
885
+ ```
886
+
887
+ **Customizing linguistic summary:**
888
+
889
+ ```ruby
890
+ # Include sentences and morphology, exclude noun chunks
891
+ summary = doc.linguistic_summary(
892
+ sections: [:text, :tokens, :entities, :sentences],
893
+ token_attributes: [:text, :lemma, :pos, :dep, :head, :morphology]
894
+ )
895
+ ```
896
+
897
+ **Embeddings:**
898
+
899
+ ```ruby
900
+ nlp.with_openai do |ai|
901
+ vector = ai.embeddings("Hello world")
902
+ puts vector.length # => 1536
903
+ end
904
+ ```
905
+
846
906
  ## Advanced Usage
847
907
 
848
908
  ### Setting a Timeout
data/Rakefile CHANGED
@@ -10,3 +10,16 @@ Rake::TestTask.new(:test) do |t|
10
10
  end
11
11
 
12
12
  task default: :test
13
+
14
+ # Gem packaging preserves on-disk file modes; owner-only permissions here
15
+ # produce gems whose files are unreadable after a sudo install. Executable
16
+ # status is determined by path alone because local (Dropbox-synced) modes
17
+ # are unreliable.
18
+ task :normalize_permissions do
19
+ `git ls-files -z`.split("\x0").each do |f|
20
+ executable = f.start_with?("bin/", "exe/")
21
+ File.chmod(executable ? 0o755 : 0o644, f)
22
+ end
23
+ end
24
+
25
+ Rake::Task["build"].enhance([:normalize_permissions])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -13,7 +13,7 @@ module Spacy
13
13
  API_ENDPOINT = "https://api.openai.com/v1"
14
14
  DEFAULT_TIMEOUT = 120
15
15
  MAX_RETRIES = 3
16
- RETRY_DELAY = 1
16
+ BASE_RETRY_DELAY = 1
17
17
 
18
18
  class APIError < StandardError
19
19
  attr_reader :status_code, :response_body
@@ -31,24 +31,25 @@ module Spacy
31
31
  end
32
32
 
33
33
  # Sends a chat completion request with optional tools support.
34
- # Note: GPT-5 series models do not support the temperature parameter.
34
+ # Note: GPT-5 series and o-series models do not support the temperature parameter.
35
35
  #
36
36
  # @param model [String] The model to use (e.g., "gpt-5-mini")
37
37
  # @param messages [Array<Hash>] The conversation messages
38
38
  # @param max_completion_tokens [Integer] Maximum tokens in the response
39
- # @param temperature [Float, nil] Sampling temperature (ignored for GPT-5 models)
39
+ # @param temperature [Float, nil] Sampling temperature (ignored for models that don't support it)
40
40
  # @param tools [Array<Hash>, nil] Tool definitions for function calling
41
41
  # @param tool_choice [String, Hash, nil] Tool selection strategy
42
+ # @param response_format [Hash, nil] Response format specification (e.g., { type: "json_object" })
42
43
  # @return [Hash] The API response
43
- def chat(model:, messages:, max_completion_tokens: 1000, temperature: nil, tools: nil, tool_choice: nil)
44
+ def chat(model:, messages:, max_completion_tokens: 1000, temperature: nil, tools: nil, tool_choice: nil, response_format: nil)
44
45
  body = {
45
46
  model: model,
46
47
  messages: messages,
47
48
  max_completion_tokens: max_completion_tokens
48
49
  }
49
50
 
50
- # GPT-5 series models do not support temperature parameter
51
- unless gpt5_model?(model)
51
+ # GPT-5 series and o-series models do not support temperature parameter
52
+ unless temperature_unsupported?(model)
52
53
  body[:temperature] = temperature || 0.7
53
54
  end
54
55
 
@@ -57,25 +58,32 @@ module Spacy
57
58
  body[:tool_choice] = tool_choice || "auto"
58
59
  end
59
60
 
61
+ body[:response_format] = response_format if response_format
62
+
60
63
  post("/chat/completions", body)
61
64
  end
62
65
 
63
- # Checks if the model is a GPT-5 series model.
64
- # GPT-5 models have different parameter requirements (no temperature support).
65
- def gpt5_model?(model)
66
- model.to_s.start_with?("gpt-5")
66
+ # Checks if the model does not support the temperature parameter.
67
+ # This includes GPT-5 series and o-series (o1, o3, o4-mini, etc.) models.
68
+ # @param model [String] The model name
69
+ # @return [Boolean]
70
+ def temperature_unsupported?(model)
71
+ name = model.to_s
72
+ name.start_with?("gpt-5") || name.match?(/\Ao\d/)
67
73
  end
68
74
 
69
75
  # Sends an embeddings request.
70
76
  #
71
77
  # @param model [String] The embeddings model (e.g., "text-embedding-3-small")
72
78
  # @param input [String] The text to embed
79
+ # @param dimensions [Integer, nil] The number of dimensions for the output embeddings
73
80
  # @return [Hash] The API response
74
- def embeddings(model:, input:)
81
+ def embeddings(model:, input:, dimensions: nil)
75
82
  body = {
76
83
  model: model,
77
84
  input: input
78
85
  }
86
+ body[:dimensions] = dimensions if dimensions
79
87
 
80
88
  post("/embeddings", body)
81
89
  end
@@ -94,36 +102,45 @@ module Spacy
94
102
  uri = URI.parse("#{API_ENDPOINT}#{path}")
95
103
  retries = 0
96
104
 
97
- begin
98
- http = Net::HTTP.new(uri.host, uri.port)
99
- http.use_ssl = true
100
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
101
- http.cert_store = default_cert_store
102
- http.open_timeout = @timeout
103
- http.read_timeout = @timeout
104
-
105
- request = Net::HTTP::Post.new(uri.path)
106
- request["Content-Type"] = "application/json"
107
- request["Authorization"] = "Bearer #{@access_token}"
108
- request.body = body.to_json
109
-
110
- response = http.request(request)
111
-
112
- handle_response(response)
113
- rescue Net::OpenTimeout, Net::ReadTimeout => e
114
- retries += 1
115
- if retries <= MAX_RETRIES
116
- sleep RETRY_DELAY
117
- retry
118
- end
119
- raise APIError.new("Request timed out after #{MAX_RETRIES} retries: #{e.message}")
120
- rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
121
- retries += 1
122
- if retries <= MAX_RETRIES
123
- sleep RETRY_DELAY
124
- retry
105
+ loop do
106
+ begin
107
+ http = Net::HTTP.new(uri.host, uri.port)
108
+ http.use_ssl = true
109
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
110
+ http.cert_store = default_cert_store
111
+ http.open_timeout = @timeout
112
+ http.read_timeout = @timeout
113
+
114
+ request = Net::HTTP::Post.new(uri.path)
115
+ request["Content-Type"] = "application/json"
116
+ request["Authorization"] = "Bearer #{@access_token}"
117
+ request.body = body.to_json
118
+
119
+ response = http.request(request)
120
+
121
+ # Handle 429 rate limiting before general response handling
122
+ if response.code.to_i == 429
123
+ retries += 1
124
+ if retries <= MAX_RETRIES
125
+ retry_after = response["Retry-After"]&.to_f
126
+ delay = retry_after || (BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5)
127
+ sleep delay
128
+ next
129
+ end
130
+ raise APIError.new("Rate limited after #{MAX_RETRIES} retries",
131
+ status_code: 429, response_body: response.body)
132
+ end
133
+
134
+ return handle_response(response)
135
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
136
+ retries += 1
137
+ if retries <= MAX_RETRIES
138
+ delay = BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5
139
+ sleep delay
140
+ next
141
+ end
142
+ raise APIError.new("Network error after #{MAX_RETRIES} retries: #{e.message}")
125
143
  end
126
- raise APIError.new("Network error after #{MAX_RETRIES} retries: #{e.message}")
127
144
  end
128
145
  end
129
146
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spacy
4
+ # A helper class for OpenAI API interactions, designed to work with spaCy's
5
+ # linguistic analysis via the block-based {Language#with_openai} API.
6
+ #
7
+ # @example Basic usage with linguistic_summary
8
+ # nlp = Spacy::Language.new("en_core_web_sm")
9
+ # nlp.with_openai(model: "gpt-5-mini") do |ai|
10
+ # doc = nlp.read("Apple Inc. was founded by Steve Jobs.")
11
+ # ai.chat(system: "Analyze the linguistic data.", user: doc.linguistic_summary)
12
+ # end
13
+ class OpenAIHelper
14
+ # @return [String] the default model for chat requests
15
+ attr_reader :model
16
+
17
+ # Creates a new OpenAIHelper instance.
18
+ # @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
19
+ # @param model [String] the default model for chat requests
20
+ # @param max_completion_tokens [Integer] default maximum tokens in responses
21
+ # @param temperature [Float] default sampling temperature
22
+ def initialize(access_token: nil, model: "gpt-5-mini",
23
+ max_completion_tokens: 1000, temperature: 0.7)
24
+ @access_token = access_token || ENV["OPENAI_API_KEY"]
25
+ raise "Error: OPENAI_API_KEY is not set" unless @access_token
26
+
27
+ @model = model
28
+ @default_max_completion_tokens = max_completion_tokens
29
+ @default_temperature = temperature
30
+ @client = OpenAIClient.new(access_token: @access_token)
31
+ end
32
+
33
+ # Sends a chat completion request to OpenAI.
34
+ #
35
+ # Provides convenient `system:` and `user:` keyword arguments as shortcuts
36
+ # for building simple message arrays. For more complex conversations, pass
37
+ # a full `messages:` array directly.
38
+ #
39
+ # @param system [String, nil] system message content (shortcut)
40
+ # @param user [String, nil] user message content (shortcut)
41
+ # @param messages [Array<Hash>, nil] full message array (overrides system:/user:)
42
+ # @param model [String, nil] model override (defaults to instance model)
43
+ # @param max_completion_tokens [Integer, nil] token limit override
44
+ # @param temperature [Float, nil] temperature override
45
+ # @param response_format [Hash, nil] response format (e.g., { type: "json_object" })
46
+ # @param raw [Boolean] if true, returns the full API response Hash instead of text
47
+ # @return [String, Hash, nil] the response text, full response Hash (if raw:), or nil on error
48
+ def chat(system: nil, user: nil, messages: nil,
49
+ model: nil, max_completion_tokens: nil,
50
+ temperature: nil, response_format: nil, raw: false)
51
+ msgs = messages || build_messages(system: system, user: user)
52
+ raise ArgumentError, "No messages provided. Use system:/user: or messages:" if msgs.empty?
53
+
54
+ response = @client.chat(
55
+ model: model || @model,
56
+ messages: msgs,
57
+ max_completion_tokens: max_completion_tokens || @default_max_completion_tokens,
58
+ temperature: temperature || @default_temperature,
59
+ response_format: response_format
60
+ )
61
+
62
+ raw ? response : response.dig("choices", 0, "message", "content")
63
+ rescue OpenAIClient::APIError => e
64
+ puts "Error: OpenAI API call failed - #{e.message}"
65
+ nil
66
+ end
67
+
68
+ # Generates text embeddings using OpenAI's embeddings API.
69
+ #
70
+ # @param text [String] the text to embed
71
+ # @param model [String] the embeddings model
72
+ # @param dimensions [Integer, nil] number of dimensions (nil uses model default)
73
+ # @return [Array<Float>, nil] the embedding vector, or nil on error
74
+ def embeddings(text, model: "text-embedding-3-small", dimensions: nil)
75
+ response = @client.embeddings(model: model, input: text, dimensions: dimensions)
76
+ response.dig("data", 0, "embedding")
77
+ rescue OpenAIClient::APIError => e
78
+ puts "Error: OpenAI API call failed - #{e.message}"
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ def build_messages(system: nil, user: nil)
85
+ msgs = []
86
+ msgs << { role: "system", content: system } if system
87
+ msgs << { role: "user", content: user } if user
88
+ msgs
89
+ end
90
+ end
91
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Spacy
4
4
  # The version number of the module
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end