ruby_llm-text 0.1.0 → 0.2.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: bb717f8d64403316c14b7da6ae2354f5f72d60a2caae6c892ca3f9fe109343d8
4
- data.tar.gz: 286d134ed832cee3a0289052b5b81693a6b70822080dc9f4fa3535c492f73198
3
+ metadata.gz: df272052ddd3c5340c8c1c1045561c53ecbc712f591b0a4374b67ede8db47847
4
+ data.tar.gz: 7dd889e901cbd9a9bf9c84369c7b1e74b1f3f446e4bff99398f263d12dc5cb1d
5
5
  SHA512:
6
- metadata.gz: 7227c8ae62491a00d308d3d59751435f404d5ee1e405b71db323a3b4edab832dac23bbc7162ef530562bae28d58ef5a765ba89f790d0f4e07469b99964519873
7
- data.tar.gz: bb9eea87d7aafb8251a49ea5137f073fc5eea5a0ffcf29a699c2251bde7cfd31131afb81d51f293befa5faba8e1f397ca0080cd0626f6c394db4fcd2729a5f25
6
+ metadata.gz: 864290308b5e0c4352a1393db5639f43bbeda934ed800e17e8c57c84b78704a05bc149762c9ffb127fbc91f40ae85a6598a3fddb7d6d18b606b80ffd38b09cb3
7
+ data.tar.gz: b8ddb46a92a0ec6ece6acf35670e1f665a8dc769fb617b97f349a595bcfb4a44c0aac2081c805a4abf2d415b4f2a6e643c93f99f0104a9189a0be1c031db3f34
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-02-17
11
+
12
+ ### Added
13
+ - **Phase 2 Text Operations** - Five powerful new methods for advanced text processing:
14
+ - `fix_grammar` - Grammar and spelling correction with optional change explanations
15
+ - `sentiment` - Sentiment analysis with confidence scores and custom categories
16
+ - `key_points` - Extract main points from long text with format options (bullets, numbers, sentences)
17
+ - `rewrite` - Transform text tone and style (professional, casual, academic, creative, etc.)
18
+ - `answer` - Question answering against provided text with boolean question detection
19
+ - **Enhanced Configuration** - Per-method model configuration for all Phase 2 methods
20
+ - **Resilient JSON Parsing** - Robust parsing with fallback mechanisms for varied LLM response formats
21
+ - **Extended String Extensions** - All Phase 2 methods available as String monkey-patches
22
+ - **Comprehensive Manual Testing** - Updated test script covering all 9 methods with real API calls
23
+ - **Complete Documentation** - Comprehensive README with API reference for all methods
24
+
25
+ ### Improved
26
+ - **Error Handling** - Graceful fallbacks for JSON parsing failures
27
+ - **Test Coverage** - 93 tests with 240 assertions covering all functionality
28
+ - **Code Quality** - RuboCop compliant codebase with consistent formatting
29
+
30
+ ### Dependencies
31
+ - ruby_llm ~> 1.0 (unchanged)
32
+ - Ruby >= 3.2.0 (unchanged)
33
+
10
34
  ## [0.1.0] - 2025-02-16
11
35
 
12
36
  ### Added
@@ -27,5 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
27
51
  - ruby_llm ~> 1.0 (core dependency)
28
52
  - Ruby >= 3.2.0
29
53
 
30
- [Unreleased]: https://github.com/patrols/ruby_llm-text/compare/v0.1.0...HEAD
54
+ [Unreleased]: https://github.com/patrols/ruby_llm-text/compare/v0.2.0...HEAD
55
+ [0.2.0]: https://github.com/patrols/ruby_llm-text/compare/v0.1.0...v0.2.0
31
56
  [0.1.0]: https://github.com/patrols/ruby_llm-text/releases/tag/v0.1.0
data/README.md CHANGED
@@ -7,7 +7,7 @@ ActiveSupport-style LLM utilities for Ruby that make AI operations feel like nat
7
7
 
8
8
  ## Overview
9
9
 
10
- `ruby_llm-text` provides intuitive one-liner utility methods for common LLM tasks like summarizing text, translation, data extraction, and classification. It integrates seamlessly with the [ruby_llm](https://github.com/crmne/ruby_llm) ecosystem, providing a simple interface without requiring chat objects, message arrays, or configuration boilerplate.
10
+ `ruby_llm-text` provides intuitive one-liner utility methods for common LLM tasks including text summarization, translation, data extraction, classification, grammar correction, sentiment analysis, key point extraction, text rewriting, and question answering. It integrates seamlessly with the [ruby_llm](https://github.com/crmne/ruby_llm) ecosystem, providing a simple interface without requiring chat objects, message arrays, or configuration boilerplate.
11
11
 
12
12
  ## Installation
13
13
 
@@ -53,6 +53,16 @@ data = RubyLLM::Text.extract(text, schema: { name: :string, age: :integer })
53
53
  # Classify text
54
54
  review = "This product is amazing!"
55
55
  sentiment = RubyLLM::Text.classify(review, categories: ["positive", "negative", "neutral"])
56
+
57
+ # Fix grammar and spelling
58
+ corrected = RubyLLM::Text.fix_grammar("Their going to the stor tommorow")
59
+
60
+ # Get sentiment with confidence
61
+ sentiment_analysis = RubyLLM::Text.sentiment("I love this product!")
62
+ # => {"label" => "positive", "confidence" => 0.95}
63
+
64
+ # Extract key points from long text
65
+ points = RubyLLM::Text.key_points("Long meeting notes...", max_points: 3)
56
66
  ```
57
67
 
58
68
  ## API Reference
@@ -66,12 +76,14 @@ RubyLLM::Text.summarize(text, length: :medium, max_words: nil, model: nil)
66
76
  ```
67
77
 
68
78
  **Parameters:**
79
+
69
80
  - `text` (String): The text to summarize
70
81
  - `length` (Symbol|String): Predefined length (`:short`, `:medium`, `:detailed`) or custom description
71
82
  - `max_words` (Integer, optional): Maximum word count for summary
72
83
  - `model` (String, optional): Specific model to use
73
84
 
74
85
  **Examples:**
86
+
75
87
  ```ruby
76
88
  # Basic usage
77
89
  RubyLLM::Text.summarize("Long article text...")
@@ -95,12 +107,14 @@ RubyLLM::Text.translate(text, to:, from: nil, model: nil)
95
107
  ```
96
108
 
97
109
  **Parameters:**
110
+
98
111
  - `text` (String): The text to translate
99
112
  - `to` (String): Target language (e.g., "en", "spanish", "français")
100
113
  - `from` (String, optional): Source language for better accuracy
101
114
  - `model` (String, optional): Specific model to use
102
115
 
103
116
  **Examples:**
117
+
104
118
  ```ruby
105
119
  # Basic translation
106
120
  RubyLLM::Text.translate("Bonjour", to: "en")
@@ -121,17 +135,20 @@ RubyLLM::Text.extract(text, schema:, model: nil)
121
135
  ```
122
136
 
123
137
  **Parameters:**
138
+
124
139
  - `text` (String): The text to extract data from
125
140
  - `schema` (Hash): Data structure specification
126
141
  - `model` (String, optional): Specific model to use
127
142
 
128
143
  **Schema Types:**
144
+
129
145
  - `:string` - Text fields
130
146
  - `:integer`, `:number` - Numeric fields
131
147
  - `:boolean` - True/false fields
132
148
  - `:array` - List fields
133
149
 
134
150
  **Examples:**
151
+
135
152
  ```ruby
136
153
  # Extract person details
137
154
  text = "John Smith is 30 years old and works as a software engineer in San Francisco."
@@ -163,11 +180,13 @@ RubyLLM::Text.classify(text, categories:, model: nil)
163
180
  ```
164
181
 
165
182
  **Parameters:**
183
+
166
184
  - `text` (String): The text to classify
167
185
  - `categories` (Array): List of possible categories
168
186
  - `model` (String, optional): Specific model to use
169
187
 
170
188
  **Examples:**
189
+
171
190
  ```ruby
172
191
  # Sentiment analysis
173
192
  review = "This product exceeded my expectations!"
@@ -188,6 +207,173 @@ priority = RubyLLM::Text.classify(email,
188
207
  )
189
208
  ```
190
209
 
210
+ ### Fix Grammar
211
+
212
+ Correct grammar, spelling, and punctuation errors.
213
+
214
+ ```ruby
215
+ RubyLLM::Text.fix_grammar(text, explain: false, preserve_style: false, model: nil)
216
+ ```
217
+
218
+ **Parameters:**
219
+
220
+ - `text` (String): The text to correct
221
+ - `explain` (Boolean, optional): Return explanations of changes made (default: false)
222
+ - `preserve_style` (Boolean, optional): Keep original tone and style (default: false)
223
+ - `model` (String, optional): Specific model to use
224
+
225
+ **Examples:**
226
+
227
+ ```ruby
228
+ # Basic grammar correction
229
+ RubyLLM::Text.fix_grammar("Their going to the stor tommorow")
230
+ # => "They're going to the store tomorrow"
231
+
232
+ # With explanations
233
+ result = RubyLLM::Text.fix_grammar("bad grammer here", explain: true)
234
+ # => {"corrected" => "bad grammar here", "changes" => ["grammer → grammar"]}
235
+
236
+ # Preserve casual style
237
+ RubyLLM::Text.fix_grammar("hey whats up", preserve_style: true)
238
+ # => "Hey, what's up?" (keeps casual tone)
239
+ ```
240
+
241
+ ### Sentiment
242
+
243
+ Analyze sentiment with confidence scores.
244
+
245
+ ```ruby
246
+ RubyLLM::Text.sentiment(text, categories: ["positive", "negative", "neutral"], simple: false, model: nil)
247
+ ```
248
+
249
+ **Parameters:**
250
+
251
+ - `text` (String): The text to analyze
252
+ - `categories` (Array, optional): Custom sentiment categories (default: positive/negative/neutral)
253
+ - `simple` (Boolean, optional): Return just the label without confidence (default: false)
254
+ - `model` (String, optional): Specific model to use
255
+
256
+ **Examples:**
257
+
258
+ ```ruby
259
+ # Basic sentiment analysis with confidence
260
+ RubyLLM::Text.sentiment("I love this product!")
261
+ # => {"label" => "positive", "confidence" => 0.95}
262
+
263
+ # Simple mode (just the label)
264
+ RubyLLM::Text.sentiment("Great service!", simple: true)
265
+ # => "positive"
266
+
267
+ # Custom categories
268
+ RubyLLM::Text.sentiment("I'm excited about tomorrow!",
269
+ categories: ["excited", "calm", "worried", "neutral"])
270
+ # => {"label" => "excited", "confidence" => 0.92}
271
+ ```
272
+
273
+ ### Key Points
274
+
275
+ Extract main points from longer text.
276
+
277
+ ```ruby
278
+ RubyLLM::Text.key_points(text, max_points: nil, format: :sentences, model: nil)
279
+ ```
280
+
281
+ **Parameters:**
282
+
283
+ - `text` (String): The text to extract points from
284
+ - `max_points` (Integer, optional): Maximum number of points to extract
285
+ - `format` (Symbol, optional): Output format (`:sentences`, `:bullets`, `:numbers`)
286
+ - `model` (String, optional): Specific model to use
287
+
288
+ **Examples:**
289
+
290
+ ```ruby
291
+ # Basic key points extraction
292
+ meeting_notes = "We discussed budget, hiring, and marketing plans..."
293
+ points = RubyLLM::Text.key_points(meeting_notes)
294
+ # => ["Budget allocation reviewed", "New hiring plans discussed", ...]
295
+
296
+ # Limit number of points
297
+ RubyLLM::Text.key_points(text, max_points: 3)
298
+
299
+ # Different formats
300
+ RubyLLM::Text.key_points(text, format: :bullets) # Returns clean text
301
+ RubyLLM::Text.key_points(text, format: :numbers) # Returns clean text
302
+ RubyLLM::Text.key_points(text, format: :sentences) # Returns clean sentences
303
+ ```
304
+
305
+ ### Rewrite
306
+
307
+ Transform text tone and style.
308
+
309
+ ```ruby
310
+ RubyLLM::Text.rewrite(text, tone: nil, style: nil, instruction: nil, model: nil)
311
+ ```
312
+
313
+ **Parameters:**
314
+
315
+ - `text` (String): The text to rewrite
316
+ - `tone` (Symbol|String, optional): Target tone (`:professional`, `:casual`, `:academic`, `:creative`, `:brief`)
317
+ - `style` (Symbol|String, optional): Target style (`:concise`, `:detailed`, `:formal`, `:informal`)
318
+ - `instruction` (String, optional): Custom rewriting instruction
319
+ - `model` (String, optional): Specific model to use
320
+
321
+ **Examples:**
322
+
323
+ ```ruby
324
+ # Change tone
325
+ RubyLLM::Text.rewrite("hey whats up", tone: :professional)
326
+ # => "Good morning. How are you doing?"
327
+
328
+ # Change style
329
+ RubyLLM::Text.rewrite("This is a long explanation...", style: :concise)
330
+ # => "Brief explanation."
331
+
332
+ # Custom instructions
333
+ RubyLLM::Text.rewrite("Hello there", instruction: "make it sound like a pirate")
334
+ # => "Ahoy there, matey!"
335
+
336
+ # Combine multiple transformations
337
+ RubyLLM::Text.rewrite(text, tone: :professional, style: :concise)
338
+ ```
339
+
340
+ ### Answer
341
+
342
+ Answer questions based on provided text.
343
+
344
+ ```ruby
345
+ RubyLLM::Text.answer(text, question, include_confidence: false, model: nil)
346
+ ```
347
+
348
+ **Parameters:**
349
+
350
+ - `text` (String): The text to search for answers
351
+ - `question` (String): The question to answer
352
+ - `include_confidence` (Boolean, optional): Include confidence score (default: false)
353
+ - `model` (String, optional): Specific model to use
354
+
355
+ **Examples:**
356
+
357
+ ```ruby
358
+ article = "Ruby was created by Yukihiro Matsumoto in 1995..."
359
+
360
+ # Basic question answering
361
+ RubyLLM::Text.answer(article, "Who created Ruby?")
362
+ # => "Yukihiro Matsumoto"
363
+
364
+ # Boolean questions
365
+ RubyLLM::Text.answer(article, "Is Ruby a programming language?")
366
+ # => true
367
+
368
+ # With confidence scores
369
+ result = RubyLLM::Text.answer(article, "When was Ruby created?", include_confidence: true)
370
+ # => {"answer" => "1995", "confidence" => 0.98}
371
+
372
+ # When answer not found
373
+ RubyLLM::Text.answer(article, "What is Python?")
374
+ # => "information not available"
375
+ ```
376
+
191
377
  ## Configuration
192
378
 
193
379
  This gem uses `ruby_llm`'s configuration for API keys and default models:
@@ -213,10 +399,16 @@ RubyLLM::Text.configure do |config|
213
399
  config.translate_model = "claude-sonnet-4-5" # Use Claude for translation
214
400
  config.extract_model = "gpt-4.1" # Use GPT-4 for extraction
215
401
  config.classify_model = "gpt-4.1-mini"
402
+ config.grammar_model = "gpt-4.1-mini" # Good for grammar correction
403
+ config.sentiment_model = "claude-haiku-4-5" # Fast for sentiment analysis
404
+ config.key_points_model = "gpt-4.1-mini" # Good for summarization tasks
405
+ config.rewrite_model = "gpt-4.1" # Creative rewriting tasks
406
+ config.answer_model = "claude-sonnet-4-5" # Strong reasoning for Q&A
216
407
  end
217
408
  ```
218
409
 
219
410
  **Per-call overrides:**
411
+
220
412
  ```ruby
221
413
  # Override model for specific calls
222
414
  RubyLLM::Text.summarize(text, model: "claude-sonnet-4-5")
@@ -237,6 +429,11 @@ require 'ruby_llm/text/string_ext'
237
429
  "Bonjour".translate(to: "en")
238
430
  "John is 30".extract(schema: { name: :string, age: :integer })
239
431
  "Great product!".classify(categories: ["positive", "negative"])
432
+ "Their going to the stor".fix_grammar
433
+ "I love this!".sentiment
434
+ "Long meeting notes...".key_points(max_points: 3)
435
+ "hey whats up".rewrite(tone: :professional)
436
+ "Ruby was created in 1995".answer("When was Ruby created?")
240
437
  ```
241
438
 
242
439
  ## Integration with ruby_llm
@@ -253,12 +450,16 @@ The gem provides clear error messages for common issues:
253
450
 
254
451
  ```ruby
255
452
  # Missing required parameters
256
- RubyLLM::Text.extract("text") # ArgumentError: schema is required
257
-
453
+ RubyLLM::Text.extract("text") # ArgumentError: schema is required for extraction
258
454
  RubyLLM::Text.classify("text", categories: []) # ArgumentError: categories are required
455
+ RubyLLM::Text.answer("text", "") # ArgumentError: question is required
456
+ RubyLLM::Text.rewrite("text") # ArgumentError: Must specify at least one of: tone, style, or instruction
259
457
 
260
458
  # API errors are wrapped with context
261
459
  RubyLLM::Text.summarize("text") # RubyLLM::Text::Error: LLM call failed: [original error]
460
+
461
+ # Graceful fallbacks for parsing issues
462
+ RubyLLM::Text.sentiment("text") # Falls back to simple mode if JSON parsing fails
262
463
  ```
263
464
 
264
465
  ## Development
@@ -301,7 +502,7 @@ export ANTHROPIC_API_KEY="your-key"
301
502
  bin/manual-test
302
503
  ```
303
504
 
304
- This script tests all four methods with real LLM APIs and provides helpful output for verification.
505
+ This script tests all nine methods with real LLM APIs and provides helpful output for verification.
305
506
 
306
507
  ## Contributing
307
508
 
@@ -0,0 +1,136 @@
1
+ module RubyLLM
2
+ module Text
3
+ module Answer
4
+ def self.call(text, question, include_confidence: false, model: nil, **options)
5
+ raise ArgumentError, "question is required" if question.nil? || question.strip.empty?
6
+
7
+ model ||= RubyLLM::Text.config.model_for(:answer)
8
+
9
+ prompt = build_prompt(text, question, include_confidence: include_confidence)
10
+
11
+ if include_confidence
12
+ # For structured output with confidence score
13
+ schema = build_confidence_schema(question)
14
+ response = Base.call_llm(prompt, model: model, schema: schema, **options)
15
+
16
+ begin
17
+ result = JSON.parse(Base.clean_json_response(response))
18
+ rescue JSON::ParserError
19
+ # Fallback: if JSON parsing fails, return best-effort structured response
20
+ cleaned_response = Base.clean_json_response(response)
21
+ result = {
22
+ "answer" => cleaned_response,
23
+ "confidence" => nil
24
+ }
25
+ end
26
+
27
+ # Convert confidence to float when present (preserve nil as "unknown")
28
+ if result.key?("confidence") && !result["confidence"].nil?
29
+ result["confidence"] = result["confidence"].to_f
30
+ end
31
+
32
+ # Handle boolean answers
33
+ if is_boolean_answer?(result["answer"])
34
+ result["answer"] = parse_boolean(result["answer"])
35
+ end
36
+
37
+ result
38
+ else
39
+ response = Base.call_llm(prompt, model: model, **options)
40
+
41
+ # Handle boolean answers for simple responses
42
+ if is_boolean_question?(question)
43
+ parse_boolean(response)
44
+ else
45
+ response
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def self.build_prompt(text, question, include_confidence:)
53
+ if include_confidence
54
+ output_instruction = <<~OUTPUT
55
+ Return a JSON object with:
56
+ - "answer": the answer to the question (use true/false for yes/no questions)
57
+ - "confidence": a confidence score between 0 and 1
58
+
59
+ If the answer cannot be found in the text, return "information not available" as the answer with low confidence.
60
+ OUTPUT
61
+ else
62
+ output_instruction = <<~OUTPUT
63
+ Answer the question based only on the information provided in the text.
64
+ For yes/no questions, respond with true or false.
65
+ If the answer cannot be found in the text, respond with "information not available".
66
+ Return only the answer, no explanation.
67
+ OUTPUT
68
+ end
69
+
70
+ <<~PROMPT
71
+ Based on the following text, answer this question: "#{question}"
72
+
73
+ #{output_instruction}
74
+
75
+ Text:
76
+ #{text}
77
+ PROMPT
78
+ end
79
+
80
+ def self.build_confidence_schema(question)
81
+ # For boolean questions, allow both boolean and string types
82
+ # to handle "information not available" fallback
83
+ answer_type = if is_boolean_question?(question)
84
+ {
85
+ oneOf: [
86
+ { type: "boolean" },
87
+ { type: "string" }
88
+ ]
89
+ }
90
+ else
91
+ { type: "string" }
92
+ end
93
+
94
+ {
95
+ type: "object",
96
+ properties: {
97
+ answer: answer_type,
98
+ confidence: { type: "number", minimum: 0, maximum: 1 }
99
+ },
100
+ required: [ "answer", "confidence" ]
101
+ }
102
+ end
103
+
104
+ def self.is_boolean_question?(question)
105
+ question_lower = question.downcase.strip
106
+ question_lower.start_with?("is ", "are ", "was ", "were ", "do ", "does ", "did ", "can ", "could ", "will ", "would ", "should ") ||
107
+ question_lower.start_with?("has ", "have ", "had ") ||
108
+ question_lower.include?(" or not") ||
109
+ (question_lower.end_with?("?") && question_lower.include?("yes or no"))
110
+ end
111
+
112
+ def self.is_boolean_answer?(answer)
113
+ return true if answer.is_a?(TrueClass) || answer.is_a?(FalseClass)
114
+ return false unless answer.is_a?(String)
115
+
116
+ answer_lower = answer.downcase.strip
117
+ [ "true", "false", "yes", "no" ].include?(answer_lower)
118
+ end
119
+
120
+ def self.parse_boolean(answer)
121
+ return answer if answer.is_a?(TrueClass) || answer.is_a?(FalseClass)
122
+ return answer unless answer.is_a?(String)
123
+
124
+ answer_lower = answer.downcase.strip
125
+ case answer_lower
126
+ when "true", "yes"
127
+ true
128
+ when "false", "no"
129
+ false
130
+ else
131
+ answer # Return as-is if not clearly boolean
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -7,7 +7,11 @@ module RubyLLM
7
7
 
8
8
  chat = RubyLLM.chat(model: model)
9
9
  chat = chat.with_temperature(temperature)
10
- chat = chat.with_schema(schema) if schema
10
+ if schema
11
+ # Convert plain Hash schemas to RubyLLM::Schema objects if needed
12
+ schema_obj = build_schema(schema)
13
+ chat = chat.with_schema(schema_obj)
14
+ end
11
15
 
12
16
  # Apply any additional options
13
17
  options.each do |key, value|
@@ -20,6 +24,123 @@ module RubyLLM
20
24
  rescue => e
21
25
  raise RubyLLM::Text::Error, "LLM call failed: #{e.message}"
22
26
  end
27
+
28
+ def self.clean_json_response(response)
29
+ # Remove markdown code block formatting if present
30
+ cleaned = response.gsub(/^```json\n/, "").gsub(/\n```$/, "").strip
31
+
32
+ # If still no JSON, try to extract JSON from mixed content
33
+ if !cleaned.start_with?("{") && cleaned.include?("{")
34
+ # Find JSON object in the response with proper brace matching
35
+ brace_count = 0
36
+ start_pos = cleaned.index("{")
37
+ if start_pos
38
+ end_pos = start_pos
39
+ cleaned[start_pos..-1].each_char.with_index(start_pos) do |char, i|
40
+ if char == "{"
41
+ brace_count += 1
42
+ elsif char == "}"
43
+ brace_count -= 1
44
+ if brace_count == 0
45
+ end_pos = i
46
+ break
47
+ end
48
+ end
49
+ end
50
+
51
+ if brace_count == 0
52
+ cleaned = cleaned[start_pos..end_pos]
53
+ end
54
+ end
55
+ end
56
+
57
+ cleaned
58
+ end
59
+
60
+ def self.build_schema(schema)
61
+ # If already a schema object, return as-is
62
+ return schema if schema.respond_to?(:schema)
63
+
64
+ return nil unless schema.is_a?(Hash)
65
+
66
+ schema_class = Class.new(RubyLLM::Schema)
67
+
68
+ # Handle JSON Schema-style hashes (e.g., {type: "object", properties: {...}})
69
+ # Check both symbol and string keys to handle parsed JSON
70
+ schema_type = schema[:type] || schema["type"]
71
+ schema_properties = schema[:properties] || schema["properties"]
72
+
73
+ if schema_type == "object" && schema_properties
74
+ required_fields = schema[:required] || schema["required"] || []
75
+
76
+ schema_properties.each do |field, spec|
77
+ # Build constraint options
78
+ constraints = {}
79
+
80
+ # Handle required fields
81
+ constraints[:required] = required_fields.include?(field.to_s) || required_fields.include?(field)
82
+
83
+ # Extract constraints from spec
84
+ [ :enum, :minimum, :maximum ].each do |constraint|
85
+ value = spec[constraint] || spec[constraint.to_s]
86
+ constraints[constraint] = value if value
87
+ end
88
+
89
+ # Handle oneOf union types - default to string for compatibility
90
+ # Check both symbol and string keys
91
+ if spec[:oneOf] || spec["oneOf"]
92
+ schema_class.string field, **constraints # Use string as most flexible type
93
+ else
94
+ case spec[:type] || spec["type"]
95
+ when "string"
96
+ schema_class.string field, **constraints
97
+ when "number", "integer"
98
+ schema_class.number field, **constraints
99
+ when "boolean"
100
+ schema_class.boolean field, **constraints
101
+ when "array"
102
+ # Handle array with items specification
103
+ items_spec = spec[:items] || spec["items"]
104
+ if items_spec
105
+ items_type = items_spec[:type] || items_spec["type"]
106
+ case items_type
107
+ when "string"
108
+ schema_class.array field, :string, **constraints
109
+ when "number", "integer"
110
+ schema_class.array field, :number, **constraints
111
+ when "boolean"
112
+ schema_class.array field, :boolean, **constraints
113
+ else
114
+ schema_class.array field, :string, **constraints
115
+ end
116
+ else
117
+ schema_class.array field, :string, **constraints
118
+ end
119
+ else
120
+ schema_class.string field, **constraints # fallback to string
121
+ end
122
+ end
123
+ end
124
+ else
125
+ # Handle simple symbol-based schemas (e.g., {name: :string, age: :integer})
126
+ schema.each do |field, type|
127
+ case type
128
+ when :string
129
+ schema_class.string field
130
+ when :integer, :number
131
+ schema_class.number field
132
+ when :boolean
133
+ schema_class.boolean field
134
+ when :array
135
+ schema_class.array field
136
+ else
137
+ schema_class.string field # fallback to string
138
+ end
139
+ end
140
+ end
141
+
142
+ schema_class
143
+ end
23
144
  end
24
145
 
25
146
  class Error < StandardError; end
@@ -4,7 +4,7 @@ module RubyLLM
4
4
  # Method-specific model overrides (optional)
5
5
  # If not set, falls back to RubyLLM.config.default_model
6
6
  attr_accessor :summarize_model, :translate_model,
7
- :extract_model, :classify_model
7
+ :extract_model, :classify_model, :grammar_model, :sentiment_model, :key_points_model, :rewrite_model, :answer_model
8
8
 
9
9
  # Default temperature for text operations
10
10
  attr_accessor :temperature
@@ -15,6 +15,11 @@ module RubyLLM
15
15
  @translate_model = nil
16
16
  @extract_model = nil
17
17
  @classify_model = nil
18
+ @grammar_model = nil
19
+ @sentiment_model = nil
20
+ @key_points_model = nil
21
+ @rewrite_model = nil
22
+ @answer_model = nil
18
23
  end
19
24
 
20
25
  def model_for(method_name)
@@ -1,5 +1,3 @@
1
- require "ruby_llm/schema"
2
-
3
1
  module RubyLLM
4
2
  module Text
5
3
  module Extract
@@ -7,40 +5,22 @@ module RubyLLM
7
5
  model ||= RubyLLM::Text.config.model_for(:extract)
8
6
  raise ArgumentError, "schema is required for extraction" unless schema
9
7
 
10
- # Convert simple hash schema to RubyLLM::Schema
11
- schema_obj = build_schema(schema)
12
8
  prompt = build_prompt(text, schema)
13
9
 
14
- Base.call_llm(prompt, model: model, schema: schema_obj, **options)
10
+ Base.call_llm(prompt, model: model, schema: schema, **options)
15
11
  end
16
12
 
17
13
  private
18
14
 
19
- def self.build_schema(schema)
20
- # If already a schema object, return as-is
21
- return schema if schema.respond_to?(:schema)
22
-
23
- # Build dynamic schema class from hash
24
- schema_class = Class.new(RubyLLM::Schema)
25
- schema.each do |field, type|
26
- case type
27
- when :string
28
- schema_class.string field
29
- when :integer, :number
30
- schema_class.number field
31
- when :boolean
32
- schema_class.boolean field
33
- when :array
34
- schema_class.array field
35
- else
36
- schema_class.string field # fallback to string
37
- end
38
- end
39
- schema_class
40
- end
41
-
42
15
  def self.build_prompt(text, schema)
43
- fields = schema.keys.join(", ")
16
+ # Support both simple field-hash schemas and JSON Schema-style hashes
17
+ properties = schema[:properties] || schema["properties"] if schema.respond_to?(:[])
18
+ field_keys = if properties.is_a?(Hash)
19
+ properties.keys
20
+ else
21
+ schema.keys
22
+ end
23
+ fields = field_keys.join(", ")
44
24
 
45
25
  <<~PROMPT
46
26
  Extract the following information from the text: #{fields}
@@ -0,0 +1,68 @@
1
+ module RubyLLM
2
+ module Text
3
+ module Grammar
4
+ def self.call(text, explain: false, preserve_style: false, model: nil, **options)
5
+ model ||= RubyLLM::Text.config.model_for(:grammar)
6
+
7
+ prompt = build_prompt(text, explain: explain, preserve_style: preserve_style)
8
+
9
+ if explain
10
+ # For structured output with explanations
11
+ schema = {
12
+ type: "object",
13
+ properties: {
14
+ corrected: { type: "string" },
15
+ changes: {
16
+ type: "array",
17
+ items: { type: "string" }
18
+ }
19
+ },
20
+ required: [ "corrected", "changes" ]
21
+ }
22
+ response = Base.call_llm(prompt, model: model, schema: schema, **options)
23
+
24
+ begin
25
+ JSON.parse(Base.clean_json_response(response))
26
+ rescue JSON::ParserError
27
+ # Fallback: if JSON parsing fails, return best-effort structured response
28
+ cleaned_response = Base.clean_json_response(response)
29
+ {
30
+ "corrected" => cleaned_response,
31
+ "changes" => []
32
+ }
33
+ end
34
+ else
35
+ Base.call_llm(prompt, model: model, **options)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def self.build_prompt(text, explain:, preserve_style:)
42
+ style_instruction = preserve_style ?
43
+ "Preserve the original tone, style, and level of formality." :
44
+ ""
45
+
46
+ if explain
47
+ output_instruction = <<~OUTPUT
48
+ Return a JSON object with:
49
+ - "corrected": the corrected text
50
+ - "changes": an array of changes made (e.g., "their → they're", "tommorow → tomorrow")
51
+ OUTPUT
52
+ else
53
+ output_instruction = "Return only the corrected text with no explanation or additional commentary."
54
+ end
55
+
56
+ <<~PROMPT
57
+ Fix grammar, spelling, punctuation, and word choice errors in the following text.
58
+ #{style_instruction}
59
+
60
+ #{output_instruction}
61
+
62
+ Text:
63
+ #{text}
64
+ PROMPT
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ module RubyLLM
2
+ module Text
3
+ module KeyPoints
4
+ def self.call(text, max_points: nil, format: :sentences, model: nil, **options)
5
+ model ||= RubyLLM::Text.config.model_for(:key_points)
6
+
7
+ prompt = build_prompt(text, max_points: max_points, format: format)
8
+ response = Base.call_llm(prompt, model: model, **options)
9
+
10
+ # Parse response into array of strings
11
+ parse_response(response, format)
12
+ end
13
+
14
+ private
15
+
16
+ def self.build_prompt(text, max_points:, format:)
17
+ count_instruction = max_points ? " (maximum #{max_points} points)" : ""
18
+
19
+ format_instruction = case format
20
+ when :bullets
21
+ "Format each point with a bullet (•) at the start."
22
+ when :numbers
23
+ "Format as a numbered list (1. 2. 3. etc.)."
24
+ when :sentences
25
+ "Format as complete sentences, one per line."
26
+ else
27
+ "Format as complete sentences, one per line."
28
+ end
29
+
30
+ <<~PROMPT
31
+ Extract the key points from the following text#{count_instruction}.
32
+ #{format_instruction}
33
+ Return only the key points, no preamble or explanation.
34
+ Each point should be on a separate line.
35
+
36
+ Text:
37
+ #{text}
38
+ PROMPT
39
+ end
40
+
41
+ def self.parse_response(response, format)
42
+ lines = response.strip.split("\n").map(&:strip).reject(&:empty?)
43
+
44
+ # Clean up formatting markers regardless of format for robust output
45
+ lines.map do |line|
46
+ # Always clean common formatting markers to handle LLM inconsistencies
47
+ cleaned = line.gsub(/^[•\*\-]\s*/, "") # Remove bullets
48
+ cleaned = cleaned.gsub(/^\d+\.\s*/, "") # Remove numbers
49
+ cleaned
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
1
+ module RubyLLM
2
+ module Text
3
+ module Rewrite
4
+ TONES = {
5
+ casual: "friendly, informal, and conversational",
6
+ professional: "business-appropriate, formal, and polished",
7
+ academic: "scholarly, formal, and precise",
8
+ creative: "engaging, descriptive, and imaginative",
9
+ brief: "brief, direct, and to-the-point"
10
+ }.freeze
11
+
12
+ STYLES = {
13
+ concise: "Make it shorter and more direct while preserving meaning",
14
+ detailed: "Expand with more context, examples, and explanation",
15
+ formal: "Use formal language and professional terminology",
16
+ informal: "Use informal, friendly language"
17
+ }.freeze
18
+
19
+ def self.call(text, tone: nil, style: nil, instruction: nil, model: nil, **options)
20
+ model ||= RubyLLM::Text.config.model_for(:rewrite)
21
+
22
+ # Validate that at least one transformation is specified
23
+ if tone.nil? && style.nil? && instruction.nil?
24
+ raise ArgumentError, "Must specify at least one of: tone, style, or instruction"
25
+ end
26
+
27
+ prompt = build_prompt(text, tone: tone, style: style, instruction: instruction)
28
+ Base.call_llm(prompt, model: model, **options)
29
+ end
30
+
31
+ private
32
+
33
+ def self.build_prompt(text, tone:, style:, instruction:)
34
+ transformations = []
35
+
36
+ if tone
37
+ tone_description = TONES[tone] || tone.to_s
38
+ transformations << "Tone: #{tone_description}"
39
+ end
40
+
41
+ if style
42
+ style_description = STYLES[style] || style.to_s
43
+ transformations << "Style: #{style_description}"
44
+ end
45
+
46
+ if instruction
47
+ transformations << "Additional instruction: #{instruction}"
48
+ end
49
+
50
+ transformation_text = transformations.join("\n")
51
+
52
+ <<~PROMPT
53
+ Rewrite the following text according to these requirements:
54
+
55
+ #{transformation_text}
56
+
57
+ Return only the rewritten text, no explanation or commentary.
58
+
59
+ Text:
60
+ #{text}
61
+ PROMPT
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ module RubyLLM
2
+ module Text
3
+ module Sentiment
4
+ DEFAULT_CATEGORIES = [ "positive", "negative", "neutral" ].freeze
5
+
6
+ def self.call(text, categories: DEFAULT_CATEGORIES, simple: false, model: nil, **options)
7
+ model ||= RubyLLM::Text.config.model_for(:sentiment)
8
+
9
+ prompt = build_prompt(text, categories: categories, simple: simple)
10
+
11
+ if simple
12
+ Base.call_llm(prompt, model: model, **options)
13
+ else
14
+ # For structured output with confidence score
15
+ schema = {
16
+ type: "object",
17
+ properties: {
18
+ label: { type: "string", enum: categories },
19
+ confidence: { type: "number", minimum: 0, maximum: 1 }
20
+ },
21
+ required: [ "label", "confidence" ]
22
+ }
23
+ response = Base.call_llm(prompt, model: model, schema: schema, **options)
24
+
25
+ begin
26
+ result = JSON.parse(Base.clean_json_response(response))
27
+ # Ensure confidence is a float
28
+ result["confidence"] = result["confidence"].to_f
29
+ result
30
+ rescue JSON::ParserError
31
+ # If JSON parsing fails, fall back to simple mode (no schema), as documented.
32
+ simple_prompt = build_prompt(text, categories: categories, simple: true)
33
+ Base.call_llm(simple_prompt, model: model, **options)
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def self.build_prompt(text, categories:, simple:)
41
+ categories_list = categories.join(", ")
42
+
43
+ if simple
44
+ output_instruction = "Return only the sentiment category name, nothing else."
45
+ else
46
+ output_instruction = <<~OUTPUT
47
+ Return a JSON object with:
48
+ - "label": the sentiment category
49
+ - "confidence": a confidence score between 0 and 1 (where 1 is completely confident)
50
+ OUTPUT
51
+ end
52
+
53
+ <<~PROMPT
54
+ Analyze the sentiment of the following text.
55
+
56
+ Categories: #{categories_list}
57
+
58
+ #{output_instruction}
59
+
60
+ Text:
61
+ #{text}
62
+ PROMPT
63
+ end
64
+ end
65
+ end
66
+ end
@@ -15,4 +15,24 @@ class String
15
15
  def classify(**options)
16
16
  RubyLLM::Text.classify(self, **options)
17
17
  end
18
+
19
+ def fix_grammar(**options)
20
+ RubyLLM::Text.fix_grammar(self, **options)
21
+ end
22
+
23
+ def sentiment(**options)
24
+ RubyLLM::Text.sentiment(self, **options)
25
+ end
26
+
27
+ def key_points(**options)
28
+ RubyLLM::Text.key_points(self, **options)
29
+ end
30
+
31
+ def rewrite(**options)
32
+ RubyLLM::Text.rewrite(self, **options)
33
+ end
34
+
35
+ def answer(question, **options)
36
+ RubyLLM::Text.answer(self, question, **options)
37
+ end
18
38
  end
@@ -1,5 +1,5 @@
1
1
  module RubyLLM
2
2
  module Text
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
data/lib/ruby_llm/text.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require "ruby_llm"
2
+ require "ruby_llm/schema"
3
+ require "json"
2
4
  require_relative "text/version"
3
5
  require_relative "text/configuration"
4
6
  require_relative "text/base"
@@ -6,6 +8,11 @@ require_relative "text/summarize"
6
8
  require_relative "text/translate"
7
9
  require_relative "text/extract"
8
10
  require_relative "text/classify"
11
+ require_relative "text/grammar"
12
+ require_relative "text/sentiment"
13
+ require_relative "text/key_points"
14
+ require_relative "text/rewrite"
15
+ require_relative "text/answer"
9
16
 
10
17
  module RubyLLM
11
18
  module Text
@@ -35,6 +42,26 @@ module RubyLLM
35
42
  def classify(text, **options)
36
43
  Classify.call(text, **options)
37
44
  end
45
+
46
+ def fix_grammar(text, **options)
47
+ Grammar.call(text, **options)
48
+ end
49
+
50
+ def sentiment(text, **options)
51
+ Sentiment.call(text, **options)
52
+ end
53
+
54
+ def key_points(text, **options)
55
+ KeyPoints.call(text, **options)
56
+ end
57
+
58
+ def rewrite(text, **options)
59
+ Rewrite.call(text, **options)
60
+ end
61
+
62
+ def answer(text, question, **options)
63
+ Answer.call(text, question, **options)
64
+ end
38
65
  end
39
66
  end
40
67
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-text
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick Rendal Olsen
@@ -79,8 +79,9 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '13.0'
82
- description: Intuitive one-liner utility methods for common LLM tasks like summarize,
83
- translate, extract, and classify.
82
+ description: Intuitive one-liner utility methods for common LLM tasks like text summarization,
83
+ translation, data extraction, classification, grammar correction, sentiment analysis,
84
+ key point extraction, text rewriting, and question answering.
84
85
  email:
85
86
  - patrick@rendal.me
86
87
  executables: []
@@ -91,10 +92,15 @@ files:
91
92
  - LICENSE
92
93
  - README.md
93
94
  - lib/ruby_llm/text.rb
95
+ - lib/ruby_llm/text/answer.rb
94
96
  - lib/ruby_llm/text/base.rb
95
97
  - lib/ruby_llm/text/classify.rb
96
98
  - lib/ruby_llm/text/configuration.rb
97
99
  - lib/ruby_llm/text/extract.rb
100
+ - lib/ruby_llm/text/grammar.rb
101
+ - lib/ruby_llm/text/key_points.rb
102
+ - lib/ruby_llm/text/rewrite.rb
103
+ - lib/ruby_llm/text/sentiment.rb
98
104
  - lib/ruby_llm/text/string_ext.rb
99
105
  - lib/ruby_llm/text/summarize.rb
100
106
  - lib/ruby_llm/text/translate.rb