llm_chain 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: efda7d777bfd7333f75b65ccc74d46abaff6c70e2a30a31709988811ed54cfff
4
+ data.tar.gz: 4ea72695094eff0ee6d1a1acc029f66ea34be3a0052d39d33d70a9c6f5a1e1d7
5
+ SHA512:
6
+ metadata.gz: 94423d0dc1cc4d6305aeb94dfc06fe50882726df1d1f72d03596317db4c37cb517fc9ad272236717439878fac7ca46d23354911e64bd1801b9e250d61d237a4b
7
+ data.tar.gz: a1ca64366d7343ae2ad8622372d19c97291a0046cf7056af0e9f79902c31e113d8dac8008e687ee53c9bd32f5f24243ded5b74d7dd256f6c23ba9aedb319862d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 FuryCow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # LLMChain
2
+
3
+ A Ruby gem for interacting with Large Language Models (LLMs) through a unified interface, with native Ollama and local model support.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/llm_chain.svg)](https://badge.fury.io/rb/llm_chain)
6
+ [![Tests](https://github.com/your_username/llm_chain/actions/workflows/tests.yml/badge.svg)](https://github.com/your_username/llm_chain/actions)
7
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
8
+
9
+ ## Features
10
+
11
+ - Unified interface for multiple LLMs (Qwen, Llama2, Mistral, etc.)
12
+ - Native [Ollama](https://ollama.ai/) integration for local models
13
+ - Prompt templating system
14
+ - Streaming response support
15
+ - RAG-ready with vector database integration
16
+ - Automatic model verification
17
+
18
+ ## Installation
19
+
20
+ Add to your Gemfile:
21
+
22
+ ```ruby
23
+ gem 'llm_chain'
24
+ ```
25
+ Or install directly:
26
+
27
+ ```
28
+ gem install llm_chain
29
+ ```
30
+
31
+ ## Prerequisites
32
+ Install [Ollama](https://ollama.ai/)
33
+
34
+ Pull desired models:
35
+
36
+ ```bash
37
+ ollama pull qwen:7b
38
+ ollama pull llama2:13b
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ basic example:
44
+
45
+ ```ruby
46
+ require 'llm_chain'
47
+
48
+ memory = LLMChain::Memory::Array.new(max_size: 1)
49
+ chain = LLMChain::Chain.new(model: "qwen3:1.7b", memory: memory)
50
+ puts chain.ask("What is 2+2?")
51
+ ```
52
+
53
+ Using redis as redistributed memory store:
54
+
55
+ ```ruby
56
+ # redis_url: 'redis://localhost:6379' is default or either set REDIS_URL env var
57
+ # max_size: 10 is default
58
+ # namespace: 'llm_chain' is default
59
+ memory = LLMChain::Memory::Redis.new(redis_url: 'redis://localhost:6379', max_size: 10, namespace: 'my_app')
60
+
61
+ chain = LLMChain::Chain.new(model: "qwen3:1.7b", memory: memory)
62
+ puts chain.ask("What is 2+2?")
63
+ ```
64
+
65
+ Model-specific Clients:
66
+
67
+ ```ruby
68
+ # Qwen with custom options (Without RAG support)
69
+ qwen = LLMChain::Clients::Qwen.new(
70
+ model: "qwen3:1.7b",
71
+ temperature: 0.8,
72
+ top_p: 0.95
73
+ )
74
+ puts qwen.chat("Write Ruby code for Fibonacci sequence")
75
+ ```
76
+
77
+ Streaming Responses:
78
+
79
+ ```ruby
80
+ LLMChain::Chain.new(model: "qwen3:1.7b").ask('How are you?', stream: true) do |chunk|
81
+ print chunk
82
+ end
83
+ ```
84
+
85
+ Chain pattern:
86
+
87
+ ```ruby
88
+ chain = LLMChain::Chain.new(
89
+ model: "qwen3:1.7b",
90
+ memory: LLMChain::Memory::Array.new
91
+ )
92
+
93
+ # Conversation with context
94
+ chain.ask("What's 2^10?")
95
+ chain.ask("Now multiply that by 5")
96
+ ```
97
+
98
+ ## Supported Models
99
+
100
+ | Model Family | Backend/Service | Notes |
101
+ |-------------|----------------|-------|
102
+ | OpenAI (GPT-3.5, GPT-4) | Web API | Supports all OpenAI API models (Not tested) |
103
+ | LLaMA2 (7B, 13B, 70B) | Ollama | Local inference via Ollama |
104
+ | Qwen/Qwen3 (0.5B-72B) | Ollama | Supports all Qwen model sizes |
105
+ | Mistral/Mixtral | Ollama | Including Mistral 7B and Mixtral 8x7B (In progress) |
106
+ | Gemma (2B, 7B) | Ollama | Google's lightweight models (In progress) |
107
+ | Claude (Haiku, Sonnet, Opus) | Anthropic API | Web API access (In progress) |
108
+ | Command R+ | Cohere API | Optimized for RAG (In progress) |
109
+
110
+ ## Retrieval-Augmented Generation (RAG)
111
+
112
+ ```ruby
113
+ # Initialize components
114
+ embedder = LLMChain::Embeddings::Clients::Local::OllamaClient.new(model: "nomic-embed-text")
115
+ rag_store = LLMChain::Embeddings::Clients::Local::WeaviateVectorStore.new(embedder: embedder, weaviate_url: 'http://localhost:8080') # Replace with your Weaviate URL if needed
116
+ retriever = LLMChain::Embeddings::Clients::Local::WeaviateRetriever.new(embedder: embedder)
117
+ memory = LLMChain::Memory::Array.new
118
+ tools = []
119
+
120
+ # Create chain
121
+ chain = LLMChain::Chain.new(
122
+ model: "qwen3:1.7b",
123
+ memory: memory, # LLMChain::Memory::Array.new is default
124
+ tools: tools, # There is no tools supported yet
125
+ retriever: retriever # LLMChain::Embeddings::Clients::Local::WeaviateRetriever.new is default
126
+ )
127
+
128
+ # simple Chain definition, with default settings
129
+
130
+ simple_chain = LLMChain::Chain.new(model: "qwen3:1.7b")
131
+
132
+ # Example of adding documents to vector database
133
+ documents = [
134
+ {
135
+ text: "Ruby supports four OOP principles: encapsulation, inheritance, polymorphism and abstraction",
136
+ metadata: { source: "ruby-docs", page: 42 }
137
+ },
138
+ {
139
+ text: "Modules in Ruby are used for namespaces and mixins",
140
+ metadata: { source: "ruby-guides", author: "John Doe" }
141
+ },
142
+ {
143
+ text: "2 + 2 is equals to 4",
144
+ matadata: { source: 'mad_brain', author: 'John Doe' }
145
+ }
146
+ ]
147
+
148
+ # Ingest documents into Weaviate
149
+ documents.each do |doc|
150
+ rag_store.add_document(
151
+ text: doc[:text],
152
+ metadata: doc[:metadata]
153
+ )
154
+ end
155
+
156
+ # Simple query without RAG
157
+ response = chain.ask("What is 2+2?", rag_context: false) # rag_context: false is default
158
+ puts response
159
+
160
+ # Query with RAG context
161
+ response = chain.ask(
162
+ "What OOP principles does Ruby support?",
163
+ rag_context: true,
164
+ rag_options: { limit: 3 }
165
+ )
166
+ puts response
167
+
168
+ # Streamed response with RAG
169
+ chain.ask("Explain Ruby modules", stream: true, rag_context: true) do |chunk|
170
+ print chunk
171
+ end
172
+ ```
173
+
174
+ ## Error handling
175
+
176
+ ```ruby
177
+ begin
178
+ chain.ask("Explain DNS")
179
+ rescue LLMChain::Error => e
180
+ puts "Error: #{e.message}"
181
+ # Auto-fallback logic can be implemented here
182
+ end
183
+ ```
184
+
185
+ ## Contributing
186
+ Bug reports and pull requests are welcome on GitHub at:
187
+ https://github.com/FuryCow/llm_chain
188
+
189
+ ## License
190
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,117 @@
1
+ require 'json'
2
+
3
+ module LLMChain
4
+ class Chain
5
+ attr_reader :model, :memory, :tools, :retriever
6
+
7
+ # @param model [String] Имя модели (gpt-4, llama3 и т.д.)
8
+ # @param memory [#recall, #store] Объект памяти
9
+ # @param tools [Array<Tool>] Массив инструментов
10
+ # @param retriever [#search] RAG-ретривер (Weaviate, Pinecone и т.д.)
11
+ # @param client_options [Hash] Опции для клиента LLM
12
+ def initialize(model: nil, memory: nil, tools: [], retriever: nil, **client_options)
13
+ @model = model
14
+ @memory = memory || Memory::Array.new
15
+ @tools = tools
16
+ @retriever = retriever || Embeddings::Clients::Local::WeaviateRetriever.new
17
+ @client = ClientRegistry.client_for(model, **client_options)
18
+ end
19
+
20
+ # Основной метод для взаимодействия с цепочкой
21
+ # @param prompt [String] Входной промпт
22
+ # @param stream [Boolean] Использовать ли потоковый вывод
23
+ # @param rag_context [Boolean] Использовать ли RAG-контекст
24
+ # @param rag_options [Hash] Опции для RAG-поиска
25
+ # @yield [String] Передает чанки ответа если stream=true
26
+ def ask(prompt, stream: false, rag_context: false, rag_options: {}, &block)
27
+ # 1. Сбор контекста
28
+ context = memory.recall(prompt)
29
+ tool_responses = process_tools(prompt)
30
+ rag_documents = retrieve_rag_context(prompt, rag_options) if rag_context
31
+
32
+ # 2. Построение промпта
33
+ full_prompt = build_prompt(
34
+ prompt: prompt,
35
+ memory_context: context,
36
+ tool_responses: tool_responses,
37
+ rag_documents: rag_documents
38
+ )
39
+
40
+ # 3. Генерация ответа
41
+ response = generate_response(full_prompt, stream: stream, &block)
42
+
43
+ # 4. Сохранение в память
44
+ memory.store(prompt, response)
45
+ response
46
+ end
47
+
48
+ private
49
+
50
+ def retrieve_rag_context(query, options = {})
51
+ return [] unless @retriever
52
+
53
+ limit = options[:limit] || 3
54
+ @retriever.search(query, limit: limit)
55
+ rescue => e
56
+ puts "[RAG Error] #{e.message}"
57
+ []
58
+ end
59
+
60
+ def process_tools(prompt)
61
+ @tools.each_with_object({}) do |tool, acc|
62
+ if tool.match?(prompt)
63
+ response = tool.call(prompt)
64
+ acc[tool.name] = response unless response.nil?
65
+ end
66
+ end
67
+ end
68
+
69
+ def build_prompt(prompt:, memory_context: nil, tool_responses: {}, rag_documents: nil)
70
+ parts = []
71
+
72
+ if memory_context&.any?
73
+ parts << "Dialogue history:"
74
+ memory_context.each do |item|
75
+ parts << "User: #{item[:prompt]}"
76
+ parts << "Assistant: #{item[:response]}"
77
+ end
78
+ end
79
+
80
+ if rag_documents&.any?
81
+ parts << "Relevant documents:"
82
+ rag_documents.each_with_index do |doc, i|
83
+ parts << "Document #{i + 1}: #{doc['content']}"
84
+ parts << "Metadata: #{doc['metadata'].to_json}" if doc['metadata']
85
+ end
86
+ end
87
+
88
+ unless tool_responses.empty?
89
+ parts << "Tool results:"
90
+ tool_responses.each do |name, response|
91
+ parts << "#{name}: #{response}"
92
+ end
93
+ end
94
+
95
+ parts << "Qurrent question: #{prompt}"
96
+
97
+ parts.join("\n\n")
98
+ end
99
+
100
+ def generate_response(prompt, stream: false, &block)
101
+ if stream
102
+ stream_response(prompt, &block)
103
+ else
104
+ @client.chat(prompt)
105
+ end
106
+ end
107
+
108
+ def stream_response(prompt)
109
+ buffer = ""
110
+ @client.stream_chat(prompt) do |chunk|
111
+ buffer << chunk
112
+ yield chunk if block_given?
113
+ end
114
+ buffer
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,25 @@
1
+ module LLMChain
2
+ module ClientRegistry
3
+ @clients = {}
4
+
5
+ def self.register_client(name, klass)
6
+ @clients[name.to_s] = klass
7
+ end
8
+
9
+ def self.client_for(model, **options)
10
+ puts model
11
+ instance = case model
12
+ when /gpt|openai/
13
+ Clients::OpenAI
14
+ when /qwen/
15
+ Clients::Qwen
16
+ when /llama2/
17
+ Clients::Llama2
18
+ else
19
+ raise UnknownModelError, "Unknown model: #{model}"
20
+ end
21
+
22
+ instance.new(**options.merge(model: model))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module LLMChain
2
+ module Clients
3
+ class Base
4
+ def initialize(model)
5
+ @model = model
6
+ end
7
+
8
+ def chat(_prompt)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def stream_chat(_prompt)
13
+ raise NotImplementedError
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module LLMChain
2
+ module Clients
3
+ class Llama2 < OllamaBase
4
+ DEFAULT_MODEL = "llama2:13b".freeze
5
+ DEFAULT_OPTIONS = {
6
+ temperature: 0.7,
7
+ top_k: 40,
8
+ num_ctx: 4096
9
+ }.freeze
10
+
11
+ def initialize(model: DEFAULT_MODEL, base_url: nil, **options)
12
+ super(model: model, base_url: base_url, default_options: DEFAULT_OPTIONS.merge(options))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,84 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module LLMChain
5
+ module Clients
6
+ class OllamaBase < Base
7
+ DEFAULT_BASE_URL = "http://localhost:11434".freeze
8
+ API_ENDPOINT = "/api/generate".freeze
9
+
10
+ def initialize(model:, base_url: nil, default_options: {})
11
+ @base_url = base_url || DEFAULT_BASE_URL
12
+ @model = model
13
+ @default_options = {
14
+ temperature: 0.7,
15
+ top_p: 0.9,
16
+ num_ctx: 2048
17
+ }.merge(default_options)
18
+ end
19
+
20
+ def chat(prompt, **options)
21
+ response = connection.post(API_ENDPOINT) do |req|
22
+ req.headers['Content-Type'] = 'application/json'
23
+ req.body = build_request_body(prompt, options)
24
+ end
25
+
26
+ handle_response(response)
27
+ rescue Faraday::Error => e
28
+ handle_error(e)
29
+ end
30
+
31
+ protected
32
+
33
+ def build_request_body(prompt, options)
34
+ {
35
+ model: @model,
36
+ prompt: prompt,
37
+ stream: options[:stream] || false,
38
+ options: @default_options.merge(options)
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def connection
45
+ @connection ||= Faraday.new(url: @base_url) do |f|
46
+ f.request :json
47
+ f.response :json
48
+ f.adapter Faraday.default_adapter
49
+ f.options.timeout = 300 # 5 минут для больших моделей
50
+ end
51
+ end
52
+
53
+ def handle_response(response)
54
+ if response.success?
55
+ response.body["response"] || raise(LLMChain::Error, "Empty response from Ollama")
56
+ else
57
+ raise LLMChain::Error, "Ollama API error: #{response.body['error'] || response.body}"
58
+ end
59
+ end
60
+
61
+ def handle_error(error)
62
+ case error
63
+ when Faraday::ResourceNotFound
64
+ raise LLMChain::Error, <<~ERROR
65
+ Ollama API error (404). Possible reasons:
66
+ 1. Model '#{@model}' not found
67
+ 2. API endpoint not available
68
+
69
+ Solutions:
70
+ 1. Check available models: `ollama list`
71
+ 2. Pull the model: `ollama pull #{@model}`
72
+ 3. Verify server: `curl #{@base_url}/api/tags`
73
+ ERROR
74
+ when Faraday::ConnectionFailed
75
+ raise LLMChain::Error, "Cannot connect to Ollama at #{@base_url}"
76
+ when Faraday::TimeoutError
77
+ raise LLMChain::Error, "Ollama request timed out. Try smaller prompt or faster model."
78
+ else
79
+ raise LLMChain::Error, "Ollama communication error: #{error.message}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,109 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module LLMChain
5
+ module Clients
6
+ class OpenAI < Base
7
+ BASE_URL = "https://api.openai.com/v1".freeze
8
+ DEFAULT_MODEL = "gpt-3.5-turbo".freeze
9
+ DEFAULT_OPTIONS = {
10
+ temperature: 0.7,
11
+ max_tokens: 1000,
12
+ top_p: 1.0,
13
+ frequency_penalty: 0,
14
+ presence_penalty: 0
15
+ }.freeze
16
+
17
+ def initialize(api_key: nil, model: nil, organization_id: nil, **options)
18
+ @api_key = api_key || ENV.fetch('OPENAI_API_KEY') { raise LLMChain::Error, "OPENAI_API_KEY is required" }
19
+ @model = model || DEFAULT_MODEL
20
+ @organization_id = organization_id || ENV['OPENAI_ORGANIZATION_ID']
21
+ @default_options = DEFAULT_OPTIONS.merge(options)
22
+ end
23
+
24
+ def chat(messages, stream: false, **options)
25
+ params = build_request_params(messages, stream: stream, **options)
26
+
27
+ if stream
28
+ stream_chat(params, &block)
29
+ else
30
+ response = connection.post("chat/completions") do |req|
31
+ req.headers = headers
32
+ req.body = params.to_json
33
+ end
34
+ handle_response(response)
35
+ end
36
+ rescue Faraday::Error => e
37
+ raise LLMChain::Error, "OpenAI API request failed: #{e.message}"
38
+ end
39
+
40
+ def stream_chat(params, &block)
41
+ buffer = ""
42
+ connection.post("chat/completions") do |req|
43
+ req.headers = headers
44
+ req.body = params.to_json
45
+
46
+ req.options.on_data = Proc.new do |chunk, _bytes, _env|
47
+ processed = process_stream_chunk(chunk)
48
+ next unless processed
49
+
50
+ buffer << processed
51
+ block.call(processed) if block_given?
52
+ end
53
+ end
54
+ buffer
55
+ end
56
+
57
+ private
58
+
59
+ def build_request_params(messages, stream: false, **options)
60
+ {
61
+ model: @model,
62
+ messages: prepare_messages(messages),
63
+ stream: stream,
64
+ **@default_options.merge(options)
65
+ }.compact
66
+ end
67
+
68
+ def prepare_messages(input)
69
+ case input
70
+ when String then [{ role: "user", content: input }]
71
+ when Array then input
72
+ else raise ArgumentError, "Messages should be String or Array"
73
+ end
74
+ end
75
+
76
+ def process_stream_chunk(chunk)
77
+ return if chunk.strip.empty?
78
+
79
+ data = JSON.parse(chunk.gsub(/^data: /, ''))
80
+ data.dig("choices", 0, "delta", "content").to_s
81
+ rescue JSON::ParserError
82
+ nil
83
+ end
84
+
85
+ def headers
86
+ {
87
+ 'Content-Type' => 'application/json',
88
+ 'Authorization' => "Bearer #{@api_key}",
89
+ 'OpenAI-Organization' => @organization_id.to_s
90
+ }.compact
91
+ end
92
+
93
+ def connection
94
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
95
+ f.request :json
96
+ f.response :raise_error
97
+ f.adapter :net_http
98
+ end
99
+ end
100
+
101
+ def handle_response(response)
102
+ data = JSON.parse(response.body)
103
+ content = data.dig("choices", 0, "message", "content")
104
+
105
+ content || raise(LLMChain::Error, "Unexpected API response: #{data.to_json}")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,149 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module LLMChain
5
+ module Clients
6
+ class Qwen < OllamaBase
7
+ # Доступные версии моделей
8
+ MODEL_VERSIONS = {
9
+ qwen: {
10
+ default: "qwen:7b",
11
+ versions: ["qwen:7b", "qwen:14b", "qwen:72b", "qwen:0.5b"]
12
+ },
13
+ qwen3: {
14
+ default: "qwen3:latest",
15
+ versions: [
16
+ "qwen3:latest", "qwen3:0.6b", "qwen3:1.7b", "qwen3:4b",
17
+ "qwen3:8b", "qwen3:14b", "qwen3:30b", "qwen3:32b", "qwen3:235b"
18
+ ]
19
+ }
20
+ }.freeze
21
+
22
+ COMMON_DEFAULT_OPTIONS = {
23
+ temperature: 0.7,
24
+ top_p: 0.9,
25
+ repeat_penalty: 1.1
26
+ }.freeze
27
+
28
+ VERSION_SPECIFIC_OPTIONS = {
29
+ qwen: {
30
+ num_gqa: 8,
31
+ stop: ["<|im_end|>", "<|endoftext|>"]
32
+ },
33
+ qwen3: {
34
+ num_ctx: 4096
35
+ }
36
+ }.freeze
37
+
38
+ INTERNAL_TAGS = {
39
+ common: {
40
+ think: /<think>.*?<\/think>\s*/mi,
41
+ reasoning: /<reasoning>.*?<\/reasoning>\s*/mi
42
+ },
43
+ qwen: {
44
+ system: /<\|system\|>.*?<\|im_end\|>\s*/mi
45
+ },
46
+ qwen3: {
47
+ qwen_meta: /<qwen_meta>.*?<\/qwen_meta>\s*/mi
48
+ }
49
+ }.freeze
50
+
51
+ def initialize(model: nil, base_url: nil, **options)
52
+ model ||= detect_default_model
53
+
54
+ @model = model
55
+ validate_model_version(@model)
56
+
57
+ super(
58
+ model: @model,
59
+ base_url: base_url,
60
+ default_options: default_options_for(@model).merge(options)
61
+ )
62
+ end
63
+
64
+ def chat(prompt, show_internal: false, stream: false, **options, &block)
65
+ if stream
66
+ stream_chat(prompt, show_internal: show_internal, **options, &block)
67
+ else
68
+ response = super(prompt, **options)
69
+ process_response(response, show_internal: show_internal)
70
+ end
71
+ end
72
+
73
+ def stream_chat(prompt, show_internal: false, **options, &block)
74
+ buffer = ""
75
+ connection.post(API_ENDPOINT) do |req|
76
+ req.headers['Content-Type'] = 'application/json'
77
+ req.body = build_request_body(prompt, options.merge(stream: true))
78
+
79
+ req.options.on_data = Proc.new do |chunk, _bytes, _env|
80
+ processed = process_stream_chunk(chunk)
81
+ next unless processed
82
+
83
+ buffer << processed
84
+ block.call(processed) if block_given?
85
+ end
86
+ end
87
+
88
+ process_response(buffer, show_internal: show_internal)
89
+ end
90
+
91
+ protected
92
+
93
+ def build_request_body(prompt, options)
94
+ body = super
95
+ version_specific_options = VERSION_SPECIFIC_OPTIONS[model_version]
96
+ body[:options].merge!(version_specific_options) if version_specific_options
97
+ puts body
98
+ body
99
+ end
100
+
101
+ private
102
+
103
+ def model_version
104
+ @model.start_with?('qwen3:') ? :qwen3 : :qwen
105
+ end
106
+
107
+ def detect_default_model
108
+ MODEL_VERSIONS[model_version][:default]
109
+ end
110
+
111
+ def validate_model_version(model)
112
+ valid_models = MODEL_VERSIONS.values.flat_map { |v| v[:versions] }
113
+ unless valid_models.include?(model)
114
+ raise InvalidModelVersion, "Invalid model version. Available: #{valid_models.join(', ')}"
115
+ end
116
+ end
117
+
118
+ def default_options_for(model)
119
+ COMMON_DEFAULT_OPTIONS.merge(
120
+ VERSION_SPECIFIC_OPTIONS[model_version] || {}
121
+ )
122
+ end
123
+
124
+ def process_stream_chunk(chunk)
125
+ parsed = JSON.parse(chunk)
126
+ parsed["response"] if parsed.is_a?(Hash) && parsed["response"]
127
+ rescue JSON::ParserError
128
+ nil
129
+ end
130
+
131
+ def process_response(response, show_internal: false)
132
+ return response unless response.is_a?(String)
133
+
134
+ if show_internal
135
+ response
136
+ else
137
+ clean_response(response)
138
+ end
139
+ end
140
+
141
+ def clean_response(text)
142
+ tags = INTERNAL_TAGS[:common].merge(INTERNAL_TAGS[model_version] || {})
143
+ tags.values.reduce(text) do |processed, regex|
144
+ processed.gsub(regex, '')
145
+ end.gsub(/\n{3,}/, "\n\n").strip
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,65 @@
1
+ require 'net/http'
2
+ require 'json'
3
+
4
+ module LLMChain
5
+ module Embeddings
6
+ module Clients
7
+ module Local
8
+ class OllamaClient
9
+ DEFAULT_MODEL = "nomic-embed-text"
10
+ OLLAMA_API_URL = "http://localhost:11434"
11
+
12
+ def initialize(model: DEFAULT_MODEL, ollama_url: nil)
13
+ @model = model
14
+ @ollama_url = (ollama_url || OLLAMA_API_URL) + "/api/embeddings"
15
+ end
16
+
17
+ # Генерация эмбеддинга для текста
18
+ def embed(text)
19
+ response = send_ollama_request(text)
20
+ validate_response(response)
21
+ parse_response(response)
22
+ rescue => e
23
+ raise EmbeddingError, "Failed to generate embedding: #{e.message}"
24
+ end
25
+
26
+ # Пакетная обработка
27
+ def embed_batch(texts, batch_size: 5)
28
+ texts.each_slice(batch_size).flat_map do |batch|
29
+ batch.map { |text| embed(text) }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def send_ollama_request(text)
36
+ uri = URI(@ollama_url)
37
+ http = Net::HTTP.new(uri.host, uri.port)
38
+ request = Net::HTTP::Post.new(uri)
39
+ request['Content-Type'] = 'application/json'
40
+ request.body = {
41
+ model: @model,
42
+ prompt: text
43
+ }.to_json
44
+
45
+ http.request(request)
46
+ end
47
+
48
+ def validate_response(response)
49
+ unless response.is_a?(Net::HTTPSuccess)
50
+ error = JSON.parse(response.body) rescue {}
51
+ raise EmbeddingError, "API error: #{response.code} - #{error['error'] || response.message}"
52
+ end
53
+ end
54
+
55
+ def parse_response(response)
56
+ data = JSON.parse(response.body)
57
+ data['embedding'] or raise EmbeddingError, "No embedding in response"
58
+ end
59
+
60
+ class EmbeddingError < StandardError; end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ module LLMChain
2
+ module Embeddings
3
+ module Clients
4
+ module Local
5
+ class WeaviateRetriever
6
+ def initialize(embedder: nil)
7
+ @vector_store = WeaviateVectorStore.new(
8
+ embedder: embedder
9
+ )
10
+ end
11
+
12
+ def search(query, limit: 3)
13
+ @vector_store.semantic_search(query, limit: limit)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,68 @@
1
+ require 'weaviate'
2
+
3
+ module LLMChain
4
+ module Embeddings
5
+ module Clients
6
+ module Local
7
+ class WeaviateVectorStore
8
+ def initialize(
9
+ weaviate_url: ENV['WEAVIATE_URL'] || 'http://localhost:8080',
10
+ class_name: 'Document',
11
+ embedder: nil
12
+ )
13
+ @client = Weaviate::Client.new(
14
+ url: weaviate_url,
15
+ model_service: :ollama
16
+ )
17
+ @embedder = embedder || OllamaClient.new
18
+ @class_name = class_name
19
+ create_schema_if_not_exists
20
+ end
21
+
22
+ def add_document(text:, metadata: {})
23
+ embedding = @embedder.embed(text)
24
+
25
+ @client.objects.create(
26
+ class_name: @class_name,
27
+ properties: {
28
+ content: text,
29
+ metadata: metadata.to_json,
30
+ text: text
31
+ },
32
+ vector: embedding
33
+ )
34
+ end
35
+
36
+ # Поиск по семантическому сходству
37
+ def semantic_search(query, limit: 3, certainty: 0.7)
38
+ near_vector = "{ vector: #{@embedder.embed(query)}, certainty: 0.7 }"
39
+
40
+ @client.query.get(
41
+ class_name: @class_name,
42
+ fields: "content metadata text",
43
+ limit: "1",
44
+ offset: "1",
45
+ near_vector: near_vector,
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ def create_schema_if_not_exists
52
+ begin
53
+ @client.schema.get(class_name: @class_name)
54
+ rescue Faraday::ResourceNotFound
55
+ @client.schema.create(
56
+ class_name: @class_name,
57
+ properties: [
58
+ { name: 'content', dataType: ['text'] },
59
+ { name: 'metadata', dataType: ['text'] }
60
+ ]
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,27 @@
1
+ module LLMChain
2
+ module Memory
3
+ class Array
4
+ def initialize(max_size: 10)
5
+ @storage = []
6
+ @max_size = max_size
7
+ end
8
+
9
+ def store(prompt, response)
10
+ @storage << { prompt: prompt, response: response }
11
+ @storage.shift if @storage.size > @max_size
12
+ end
13
+
14
+ def recall(_ = nil)
15
+ @storage.dup
16
+ end
17
+
18
+ def clear
19
+ @storage.clear
20
+ end
21
+
22
+ def size
23
+ @storage.size
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ require 'redis'
2
+ require 'json'
3
+
4
+ module LLMChain
5
+ module Memory
6
+ class Redis
7
+ class Error < StandardError; end
8
+
9
+ def initialize(max_size: 10, redis_url: nil, namespace: 'llm_chain')
10
+ @max_size = max_size
11
+ @redis = ::Redis.new(url: redis_url || ENV['REDIS_URL'] || 'redis://localhost:6379')
12
+ @namespace = namespace
13
+ @session_key = "#{namespace}:session"
14
+ end
15
+
16
+ def store(prompt, response)
17
+ entry = { prompt: prompt, response: response, timestamp: Time.now.to_i }.to_json
18
+ @redis.multi do
19
+ @redis.rpush(@session_key, entry)
20
+ @redis.ltrim(@session_key, -@max_size, -1) # Сохраняем только последние max_size записей
21
+ end
22
+ end
23
+
24
+ def recall(_ = nil)
25
+ entries = @redis.lrange(@session_key, 0, -1)
26
+ entries.map { |e| symbolize_keys(JSON.parse(e)) }
27
+ rescue JSON::ParserError
28
+ []
29
+ rescue ::Redis::CannotConnectError
30
+ raise MemoryError, "Cannot connect to Redis server"
31
+ end
32
+
33
+ def clear
34
+ @redis.del(@session_key)
35
+ end
36
+
37
+ def size
38
+ @redis.llen(@session_key)
39
+ end
40
+
41
+ private
42
+
43
+ def symbolize_keys(hash)
44
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmChain
4
+ VERSION = "0.3.0"
5
+ end
data/lib/llm_chain.rb ADDED
@@ -0,0 +1,26 @@
1
+ require "llm_chain/version"
2
+ require "faraday"
3
+ require "json"
4
+
5
+ module LLMChain
6
+ class Error < StandardError; end
7
+ class UnknownModelError < Error; end
8
+ class InvalidModelVersion < Error; end
9
+ class ClientError < Error; end
10
+ class ServerError < Error; end
11
+ class TimeoutError < Error; end
12
+ class MemoryError < Error; end
13
+ end
14
+
15
+ require "llm_chain/clients/base"
16
+ require "llm_chain/clients/ollama_base"
17
+ require "llm_chain/clients/openai"
18
+ require "llm_chain/clients/qwen"
19
+ require "llm_chain/clients/llama2"
20
+ require "llm_chain/client_registry"
21
+ require "llm_chain/memory/array"
22
+ require "llm_chain/memory/redis"
23
+ require "llm_chain/embeddings/clients/local/ollama_client"
24
+ require "llm_chain/embeddings/clients/local/weaviate_vector_store"
25
+ require "llm_chain/embeddings/clients/local/weaviate_retriever"
26
+ require "llm_chain/chain"
data/sig/llm_chain.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module LlmChain
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: llm_chain
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - FuryCow
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-06-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httparty
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: faraday-net_http
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: json
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: weaviate-ruby
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.9.1
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.9.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ description:
112
+ email:
113
+ - dreamweaver0408@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".rspec"
119
+ - ".rubocop.yml"
120
+ - CODE_OF_CONDUCT.md
121
+ - LICENSE.txt
122
+ - README.md
123
+ - Rakefile
124
+ - lib/llm_chain.rb
125
+ - lib/llm_chain/chain.rb
126
+ - lib/llm_chain/client_registry.rb
127
+ - lib/llm_chain/clients/base.rb
128
+ - lib/llm_chain/clients/llama2.rb
129
+ - lib/llm_chain/clients/ollama_base.rb
130
+ - lib/llm_chain/clients/openai.rb
131
+ - lib/llm_chain/clients/qwen.rb
132
+ - lib/llm_chain/embeddings/clients/local/ollama_client.rb
133
+ - lib/llm_chain/embeddings/clients/local/weaviate_retriever.rb
134
+ - lib/llm_chain/embeddings/clients/local/weaviate_vector_store.rb
135
+ - lib/llm_chain/memory/array.rb
136
+ - lib/llm_chain/memory/redis.rb
137
+ - lib/llm_chain/version.rb
138
+ - sig/llm_chain.rbs
139
+ homepage: https://github.com/FuryCow/llm_chain
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ homepage_uri: https://github.com/FuryCow/llm_chain
144
+ source_code_uri: https://github.com/FuryCow/llm_chain
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: 3.1.0
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 3.4.10
161
+ signing_key:
162
+ specification_version: 4
163
+ summary: Ruby-аналог LangChain для работы с LLM
164
+ test_files: []