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 +4 -4
- data/CHANGELOG.md +26 -1
- data/README.md +205 -4
- data/lib/ruby_llm/text/answer.rb +136 -0
- data/lib/ruby_llm/text/base.rb +122 -1
- data/lib/ruby_llm/text/configuration.rb +6 -1
- data/lib/ruby_llm/text/extract.rb +9 -29
- data/lib/ruby_llm/text/grammar.rb +68 -0
- data/lib/ruby_llm/text/key_points.rb +54 -0
- data/lib/ruby_llm/text/rewrite.rb +65 -0
- data/lib/ruby_llm/text/sentiment.rb +66 -0
- data/lib/ruby_llm/text/string_ext.rb +20 -0
- data/lib/ruby_llm/text/version.rb +1 -1
- data/lib/ruby_llm/text.rb +27 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df272052ddd3c5340c8c1c1045561c53ecbc712f591b0a4374b67ede8db47847
|
|
4
|
+
data.tar.gz: 7dd889e901cbd9a9bf9c84369c7b1e74b1f3f446e4bff99398f263d12dc5cb1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
data/lib/ruby_llm/text/base.rb
CHANGED
|
@@ -7,7 +7,11 @@ module RubyLLM
|
|
|
7
7
|
|
|
8
8
|
chat = RubyLLM.chat(model: model)
|
|
9
9
|
chat = chat.with_temperature(temperature)
|
|
10
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
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.
|
|
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
|
|
83
|
-
|
|
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
|