ruby-spacy 0.4.0 → 0.5.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/CHANGELOG.md +58 -0
- data/README.md +133 -4
- data/Rakefile +13 -0
- data/examples/get_started/lexeme.rb +0 -0
- data/examples/get_started/linguistic_annotations.rb +0 -0
- data/examples/get_started/morphology.rb +0 -0
- data/examples/get_started/most_similar.rb +0 -0
- data/examples/get_started/named_entities.rb +0 -0
- data/examples/get_started/outputs/test_dep.svg +0 -0
- data/examples/get_started/outputs/test_dep_compact.svg +0 -0
- data/examples/get_started/outputs/test_ent.html +0 -0
- data/examples/get_started/pos_tags_and_dependencies.rb +0 -0
- data/examples/get_started/similarity.rb +0 -0
- data/examples/get_started/tokenization.rb +0 -0
- data/examples/get_started/visualizing_dependencies.rb +0 -0
- data/examples/get_started/visualizing_dependencies_compact.rb +0 -0
- data/examples/get_started/visualizing_named_entities.rb +0 -0
- data/examples/get_started/vocab.rb +0 -0
- data/examples/get_started/word_vectors.rb +0 -0
- data/examples/japanese/ancestors.rb +0 -0
- data/examples/japanese/entity_annotations_and_labels.rb +0 -0
- data/examples/japanese/information_extraction.rb +0 -0
- data/examples/japanese/lemmatization.rb +0 -0
- data/examples/japanese/most_similar.rb +0 -0
- data/examples/japanese/named_entity_recognition.rb +0 -0
- data/examples/japanese/navigating_parse_tree.rb +0 -0
- data/examples/japanese/noun_chunks.rb +0 -0
- data/examples/japanese/outputs/test_dep.svg +0 -0
- data/examples/japanese/outputs/test_ent.html +0 -0
- data/examples/japanese/pos_tagging.rb +0 -0
- data/examples/japanese/sentence_segmentation.rb +0 -0
- data/examples/japanese/similarity.rb +0 -0
- data/examples/japanese/tokenization.rb +0 -0
- data/examples/japanese/visualizing_dependencies.rb +0 -0
- data/examples/japanese/visualizing_named_entities.rb +0 -0
- data/examples/linguistic_features/ancestors.rb +0 -0
- data/examples/linguistic_features/entity_annotations_and_labels.rb +0 -0
- data/examples/linguistic_features/finding_a_verb_with_a_subject.rb +0 -0
- data/examples/linguistic_features/information_extraction.rb +0 -0
- data/examples/linguistic_features/iterating_children.rb +0 -0
- data/examples/linguistic_features/iterating_lefts_and_rights.rb +0 -0
- data/examples/linguistic_features/lemmatization.rb +0 -0
- data/examples/linguistic_features/named_entity_recognition.rb +0 -0
- data/examples/linguistic_features/navigating_parse_tree.rb +0 -0
- data/examples/linguistic_features/noun_chunks.rb +0 -0
- data/examples/linguistic_features/outputs/test_ent.html +0 -0
- data/examples/linguistic_features/pos_tagging.rb +0 -0
- data/examples/linguistic_features/retokenize_1.rb +0 -0
- data/examples/linguistic_features/retokenize_2.rb +0 -0
- data/examples/linguistic_features/rule_based_morphology.rb +0 -0
- data/examples/linguistic_features/sentence_segmentation.rb +0 -0
- data/examples/linguistic_features/similarity.rb +0 -0
- data/examples/linguistic_features/similarity_between_lexemes.rb +0 -0
- data/examples/linguistic_features/similarity_between_spans.rb +0 -0
- data/examples/linguistic_features/tokenization.rb +0 -0
- 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/openai_integration/openai_embeddings.rb +0 -0
- data/examples/rule_based_matching/creating_spans_from_matches.rb +0 -0
- data/examples/rule_based_matching/matcher.rb +0 -0
- 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 +62 -14
- metadata +7 -1
|
@@ -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
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Spacy
|
|
9
|
+
# Shared HTTP layer for LLM API clients ({OpenAIClient}, {AnthropicClient}).
|
|
10
|
+
# Implements JSON POST requests with retry on rate limits (429/529) and
|
|
11
|
+
# transient network errors, using only net/http (no external dependencies).
|
|
12
|
+
class LLMClientBase
|
|
13
|
+
# Default request timeout in seconds
|
|
14
|
+
DEFAULT_TIMEOUT = 120
|
|
15
|
+
# Maximum number of retries for rate-limited requests and network errors
|
|
16
|
+
MAX_RETRIES = 3
|
|
17
|
+
# Base delay in seconds for exponential backoff between retries
|
|
18
|
+
BASE_RETRY_DELAY = 1
|
|
19
|
+
|
|
20
|
+
# Raised when an LLM API request fails after retries; carries the HTTP
|
|
21
|
+
# status code and the parsed response body when available.
|
|
22
|
+
class APIError < StandardError
|
|
23
|
+
attr_reader :status_code, :response_body
|
|
24
|
+
|
|
25
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
26
|
+
@status_code = status_code
|
|
27
|
+
@response_body = response_body
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(base_url:, timeout: DEFAULT_TIMEOUT)
|
|
33
|
+
@base_url = base_url.sub(%r{/\z}, "")
|
|
34
|
+
@timeout = timeout
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Subclasses provide authentication and API-specific headers.
|
|
40
|
+
# @return [Hash{String => String}]
|
|
41
|
+
def request_headers
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Creates a certificate store with system CA certificates but without CRL checking.
|
|
46
|
+
# This avoids "unable to get certificate CRL" errors on some systems.
|
|
47
|
+
def default_cert_store
|
|
48
|
+
store = OpenSSL::X509::Store.new
|
|
49
|
+
store.set_default_paths
|
|
50
|
+
store
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post(path, body)
|
|
54
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
55
|
+
retries = 0
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
begin
|
|
59
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
60
|
+
if uri.scheme == "https"
|
|
61
|
+
http.use_ssl = true
|
|
62
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
63
|
+
http.cert_store = default_cert_store
|
|
64
|
+
end
|
|
65
|
+
http.open_timeout = @timeout
|
|
66
|
+
http.read_timeout = @timeout
|
|
67
|
+
|
|
68
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
69
|
+
request["Content-Type"] = "application/json"
|
|
70
|
+
request_headers.each { |key, value| request[key] = value }
|
|
71
|
+
request.body = body.to_json
|
|
72
|
+
|
|
73
|
+
response = http.request(request)
|
|
74
|
+
|
|
75
|
+
# Handle rate limiting (429) and overload (529) before general response handling
|
|
76
|
+
if [429, 529].include?(response.code.to_i)
|
|
77
|
+
retries += 1
|
|
78
|
+
if retries <= MAX_RETRIES
|
|
79
|
+
retry_after = response["Retry-After"]&.to_f
|
|
80
|
+
delay = retry_after || (BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5)
|
|
81
|
+
sleep delay
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
raise APIError.new("Rate limited after #{MAX_RETRIES} retries",
|
|
85
|
+
status_code: response.code.to_i, response_body: response.body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return handle_response(response)
|
|
89
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
|
|
90
|
+
retries += 1
|
|
91
|
+
if retries <= MAX_RETRIES
|
|
92
|
+
delay = BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5
|
|
93
|
+
sleep delay
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
raise APIError.new("Network error after #{MAX_RETRIES} retries: #{e.message}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_response(response)
|
|
102
|
+
body = JSON.parse(response.body)
|
|
103
|
+
|
|
104
|
+
case response.code.to_i
|
|
105
|
+
when 200
|
|
106
|
+
body
|
|
107
|
+
when 400..499
|
|
108
|
+
error_message = body.dig("error", "message") || "Client error"
|
|
109
|
+
raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
|
|
110
|
+
when 500..599
|
|
111
|
+
error_message = body.dig("error", "message") || "Server error"
|
|
112
|
+
raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
|
|
113
|
+
else
|
|
114
|
+
raise APIError.new("Unexpected response: #{response.code}", status_code: response.code.to_i, response_body: body)
|
|
115
|
+
end
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
raise APIError.new("Invalid JSON response", status_code: response.code.to_i, response_body: response.body)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -1,42 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "openssl"
|
|
5
|
-
require "uri"
|
|
6
|
-
require "json"
|
|
3
|
+
require_relative "llm_client_base"
|
|
7
4
|
|
|
8
5
|
module Spacy
|
|
9
|
-
# A lightweight OpenAI API client with tools support
|
|
6
|
+
# A lightweight OpenAI API client with tools support.
|
|
10
7
|
# This client implements the chat completions and embeddings endpoints
|
|
11
8
|
# without external dependencies.
|
|
12
|
-
|
|
9
|
+
#
|
|
10
|
+
# A custom +base_url+ makes it work with any OpenAI-compatible server
|
|
11
|
+
# (Ollama, LM Studio, llama.cpp server, vLLM, OpenRouter, etc.).
|
|
12
|
+
class OpenAIClient < LLMClientBase
|
|
13
|
+
# Default OpenAI API endpoint
|
|
13
14
|
API_ENDPOINT = "https://api.openai.com/v1"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def initialize(access_token:, timeout: DEFAULT_TIMEOUT)
|
|
15
|
+
# Default model for chat requests
|
|
16
|
+
DEFAULT_MODEL = "gpt-5-mini"
|
|
17
|
+
# Default model for embeddings requests
|
|
18
|
+
DEFAULT_EMBEDDINGS_MODEL = "text-embedding-3-small"
|
|
19
|
+
# (see LLMClientBase::APIError)
|
|
20
|
+
APIError = LLMClientBase::APIError
|
|
21
|
+
|
|
22
|
+
# @param access_token [String] API key ("ollama" or any placeholder for local servers)
|
|
23
|
+
# @param timeout [Integer] request timeout in seconds
|
|
24
|
+
# @param base_url [String] API endpoint; include the version path
|
|
25
|
+
# (e.g., "http://localhost:11434/v1" for Ollama)
|
|
26
|
+
def initialize(access_token:, timeout: DEFAULT_TIMEOUT, base_url: API_ENDPOINT)
|
|
27
|
+
super(base_url: base_url, timeout: timeout)
|
|
29
28
|
@access_token = access_token
|
|
30
|
-
@timeout = timeout
|
|
31
29
|
end
|
|
32
30
|
|
|
33
31
|
# Sends a chat completion request with optional tools support.
|
|
34
|
-
#
|
|
32
|
+
#
|
|
33
|
+
# The +temperature+ parameter is sent only when explicitly given. If the
|
|
34
|
+
# model rejects it (e.g., GPT-5 series and o-series models), the request
|
|
35
|
+
# is retried once without it.
|
|
35
36
|
#
|
|
36
37
|
# @param model [String] The model to use (e.g., "gpt-5-mini")
|
|
37
38
|
# @param messages [Array<Hash>] The conversation messages
|
|
38
39
|
# @param max_completion_tokens [Integer] Maximum tokens in the response
|
|
39
|
-
# @param temperature [Float, nil] Sampling temperature (
|
|
40
|
+
# @param temperature [Float, nil] Sampling temperature (omitted when nil)
|
|
40
41
|
# @param tools [Array<Hash>, nil] Tool definitions for function calling
|
|
41
42
|
# @param tool_choice [String, Hash, nil] Tool selection strategy
|
|
42
43
|
# @param response_format [Hash, nil] Response format specification (e.g., { type: "json_object" })
|
|
@@ -47,11 +48,7 @@ module Spacy
|
|
|
47
48
|
messages: messages,
|
|
48
49
|
max_completion_tokens: max_completion_tokens
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
# GPT-5 series and o-series models do not support temperature parameter
|
|
52
|
-
unless temperature_unsupported?(model)
|
|
53
|
-
body[:temperature] = temperature || 0.7
|
|
54
|
-
end
|
|
51
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
55
52
|
|
|
56
53
|
if tools && !tools.empty?
|
|
57
54
|
body[:tools] = tools
|
|
@@ -60,16 +57,7 @@ module Spacy
|
|
|
60
57
|
|
|
61
58
|
body[:response_format] = response_format if response_format
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
end
|
|
65
|
-
|
|
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/)
|
|
60
|
+
post_with_temperature_fallback("/chat/completions", body)
|
|
73
61
|
end
|
|
74
62
|
|
|
75
63
|
# Sends an embeddings request.
|
|
@@ -90,77 +78,19 @@ module Spacy
|
|
|
90
78
|
|
|
91
79
|
private
|
|
92
80
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def default_cert_store
|
|
96
|
-
store = OpenSSL::X509::Store.new
|
|
97
|
-
store.set_default_paths
|
|
98
|
-
store
|
|
81
|
+
def request_headers
|
|
82
|
+
{ "Authorization" => "Bearer #{@access_token}" }
|
|
99
83
|
end
|
|
100
84
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
85
|
+
# Models that do not accept the temperature parameter reject it with a 400;
|
|
86
|
+
# retry once without it so callers need no per-model knowledge.
|
|
87
|
+
def post_with_temperature_fallback(path, body)
|
|
88
|
+
post(path, body)
|
|
89
|
+
rescue APIError => e
|
|
90
|
+
raise unless body.key?(:temperature) && e.status_code == 400 && e.message.match?(/temperature/i)
|
|
104
91
|
|
|
105
|
-
|
|
106
|
-
|
|
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}")
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def handle_response(response)
|
|
148
|
-
body = JSON.parse(response.body)
|
|
149
|
-
|
|
150
|
-
case response.code.to_i
|
|
151
|
-
when 200
|
|
152
|
-
body
|
|
153
|
-
when 400..499
|
|
154
|
-
error_message = body.dig("error", "message") || "Client error"
|
|
155
|
-
raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
|
|
156
|
-
when 500..599
|
|
157
|
-
error_message = body.dig("error", "message") || "Server error"
|
|
158
|
-
raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
|
|
159
|
-
else
|
|
160
|
-
raise APIError.new("Unexpected response: #{response.code}", status_code: response.code.to_i, response_body: body)
|
|
161
|
-
end
|
|
162
|
-
rescue JSON::ParserError
|
|
163
|
-
raise APIError.new("Invalid JSON response", status_code: response.code.to_i, response_body: response.body)
|
|
92
|
+
warn "Warning: model does not support the temperature parameter; retrying without it"
|
|
93
|
+
post(path, body.reject { |key, _| key == :temperature })
|
|
164
94
|
end
|
|
165
95
|
end
|
|
166
96
|
end
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Spacy
|
|
4
4
|
# A helper class for OpenAI API interactions, designed to work with spaCy's
|
|
5
|
-
# linguistic analysis via the block-based {Language#
|
|
5
|
+
# linguistic analysis via the block-based {Language#with_llm} /
|
|
6
|
+
# {Language#with_openai} API.
|
|
7
|
+
#
|
|
8
|
+
# With a custom +base_url+, this helper also works with any OpenAI-compatible
|
|
9
|
+
# server such as Ollama, LM Studio, llama.cpp server, or vLLM.
|
|
6
10
|
#
|
|
7
11
|
# @example Basic usage with linguistic_summary
|
|
8
12
|
# nlp = Spacy::Language.new("en_core_web_sm")
|
|
@@ -10,6 +14,11 @@ module Spacy
|
|
|
10
14
|
# doc = nlp.read("Apple Inc. was founded by Steve Jobs.")
|
|
11
15
|
# ai.chat(system: "Analyze the linguistic data.", user: doc.linguistic_summary)
|
|
12
16
|
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Local model via Ollama
|
|
19
|
+
# nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
20
|
+
# ai.chat(user: "Say hello.")
|
|
21
|
+
# end
|
|
13
22
|
class OpenAIHelper
|
|
14
23
|
# @return [String] the default model for chat requests
|
|
15
24
|
attr_reader :model
|
|
@@ -18,19 +27,25 @@ module Spacy
|
|
|
18
27
|
# @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
|
|
19
28
|
# @param model [String] the default model for chat requests
|
|
20
29
|
# @param max_completion_tokens [Integer] default maximum tokens in responses
|
|
21
|
-
# @param
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
# @param max_tokens [Integer, nil] alias for max_completion_tokens (takes precedence)
|
|
31
|
+
# @param temperature [Float, nil] default sampling temperature (omitted from
|
|
32
|
+
# requests when nil; models that reject it are retried without it)
|
|
33
|
+
# @param base_url [String, nil] OpenAI-compatible API endpoint
|
|
34
|
+
# (e.g., "http://localhost:11434/v1" for Ollama)
|
|
35
|
+
def initialize(access_token: nil, model: OpenAIClient::DEFAULT_MODEL,
|
|
36
|
+
max_completion_tokens: 1000, max_tokens: nil,
|
|
37
|
+
temperature: nil, base_url: nil)
|
|
24
38
|
@access_token = access_token || ENV["OPENAI_API_KEY"]
|
|
25
39
|
raise "Error: OPENAI_API_KEY is not set" unless @access_token
|
|
26
40
|
|
|
27
41
|
@model = model
|
|
28
|
-
@default_max_completion_tokens = max_completion_tokens
|
|
42
|
+
@default_max_completion_tokens = max_tokens || max_completion_tokens
|
|
29
43
|
@default_temperature = temperature
|
|
30
|
-
@client = OpenAIClient.new(access_token: @access_token
|
|
44
|
+
@client = OpenAIClient.new(access_token: @access_token,
|
|
45
|
+
base_url: base_url || OpenAIClient::API_ENDPOINT)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
|
-
# Sends a chat completion request
|
|
48
|
+
# Sends a chat completion request.
|
|
34
49
|
#
|
|
35
50
|
# Provides convenient `system:` and `user:` keyword arguments as shortcuts
|
|
36
51
|
# for building simple message arrays. For more complex conversations, pass
|
|
@@ -41,41 +56,63 @@ module Spacy
|
|
|
41
56
|
# @param messages [Array<Hash>, nil] full message array (overrides system:/user:)
|
|
42
57
|
# @param model [String, nil] model override (defaults to instance model)
|
|
43
58
|
# @param max_completion_tokens [Integer, nil] token limit override
|
|
59
|
+
# @param max_tokens [Integer, nil] alias for max_completion_tokens (takes precedence)
|
|
44
60
|
# @param temperature [Float, nil] temperature override
|
|
45
61
|
# @param response_format [Hash, nil] response format (e.g., { type: "json_object" })
|
|
62
|
+
# @param schema [Hash, nil] JSON Schema for structured outputs; when given,
|
|
63
|
+
# the model output is constrained to the schema and the parsed Hash is
|
|
64
|
+
# returned. Objects in the schema must set `additionalProperties: false`
|
|
65
|
+
# and list all properties in `required`.
|
|
46
66
|
# @param raw [Boolean] if true, returns the full API response Hash instead of text
|
|
47
|
-
# @return [String, Hash, nil] the response text,
|
|
67
|
+
# @return [String, Hash, nil] the response text, parsed Hash (if schema:),
|
|
68
|
+
# full response Hash (if raw:), or nil on API error or when the schema
|
|
69
|
+
# output cannot be parsed as JSON (e.g., truncated by the token limit)
|
|
48
70
|
def chat(system: nil, user: nil, messages: nil,
|
|
49
|
-
model: nil, max_completion_tokens: nil,
|
|
50
|
-
temperature: nil, response_format: nil, raw: false)
|
|
71
|
+
model: nil, max_completion_tokens: nil, max_tokens: nil,
|
|
72
|
+
temperature: nil, response_format: nil, schema: nil, raw: false)
|
|
51
73
|
msgs = messages || build_messages(system: system, user: user)
|
|
52
74
|
raise ArgumentError, "No messages provided. Use system:/user: or messages:" if msgs.empty?
|
|
53
75
|
|
|
76
|
+
if schema
|
|
77
|
+
response_format = {
|
|
78
|
+
type: "json_schema",
|
|
79
|
+
json_schema: { name: "response", strict: true, schema: schema }
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
54
83
|
response = @client.chat(
|
|
55
84
|
model: model || @model,
|
|
56
85
|
messages: msgs,
|
|
57
|
-
max_completion_tokens: max_completion_tokens || @default_max_completion_tokens,
|
|
86
|
+
max_completion_tokens: max_tokens || max_completion_tokens || @default_max_completion_tokens,
|
|
58
87
|
temperature: temperature || @default_temperature,
|
|
59
88
|
response_format: response_format
|
|
60
89
|
)
|
|
61
90
|
|
|
62
|
-
|
|
91
|
+
return response if raw
|
|
92
|
+
|
|
93
|
+
choice = response.dig("choices", 0)
|
|
94
|
+
if choice&.dig("finish_reason") == "length"
|
|
95
|
+
warn "Warning: response was truncated (finish_reason: length); consider increasing max_completion_tokens"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
content = choice&.dig("message", "content")
|
|
99
|
+
schema && content ? parse_json_content(content) : content
|
|
63
100
|
rescue OpenAIClient::APIError => e
|
|
64
|
-
|
|
101
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
65
102
|
nil
|
|
66
103
|
end
|
|
67
104
|
|
|
68
|
-
# Generates text embeddings using
|
|
105
|
+
# Generates text embeddings using the embeddings API.
|
|
69
106
|
#
|
|
70
107
|
# @param text [String] the text to embed
|
|
71
108
|
# @param model [String] the embeddings model
|
|
72
109
|
# @param dimensions [Integer, nil] number of dimensions (nil uses model default)
|
|
73
110
|
# @return [Array<Float>, nil] the embedding vector, or nil on error
|
|
74
|
-
def embeddings(text, model:
|
|
111
|
+
def embeddings(text, model: OpenAIClient::DEFAULT_EMBEDDINGS_MODEL, dimensions: nil)
|
|
75
112
|
response = @client.embeddings(model: model, input: text, dimensions: dimensions)
|
|
76
113
|
response.dig("data", 0, "embedding")
|
|
77
114
|
rescue OpenAIClient::APIError => e
|
|
78
|
-
|
|
115
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
79
116
|
nil
|
|
80
117
|
end
|
|
81
118
|
|
|
@@ -87,5 +124,12 @@ module Spacy
|
|
|
87
124
|
msgs << { role: "user", content: user } if user
|
|
88
125
|
msgs
|
|
89
126
|
end
|
|
127
|
+
|
|
128
|
+
def parse_json_content(text)
|
|
129
|
+
JSON.parse(text)
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
warn "Error: response is not valid JSON (possibly truncated output); returning nil"
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
90
134
|
end
|
|
91
135
|
end
|
data/lib/ruby-spacy/version.rb
CHANGED