ruby_llm-providers-typesafe 0.1.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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/.flayignore +1 -0
  3. data/.github/workflows/ci.yml +32 -0
  4. data/.github/workflows/gitleaks.yml +22 -0
  5. data/.github/workflows/release.yml +33 -0
  6. data/.overcommit.yml +31 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +35 -0
  9. data/Archspec.rb +23 -0
  10. data/LICENSE +21 -0
  11. data/README.md +84 -0
  12. data/lib/ruby_llm/providers/typesafe/system_one/evaluations.rb +95 -0
  13. data/lib/ruby_llm/providers/typesafe/system_one/models.rb +35 -0
  14. data/lib/ruby_llm/providers/typesafe/system_one/rerank.rb +57 -0
  15. data/lib/ruby_llm/providers/typesafe/system_one.rb +19 -0
  16. data/lib/ruby_llm/providers/typesafe.rb +68 -0
  17. data/lib/ruby_llm/typesafe/evaluation.rb +75 -0
  18. data/lib/ruby_llm/typesafe/questions.rb +74 -0
  19. data/lib/ruby_llm/typesafe.rb +47 -0
  20. data/models.json +62 -0
  21. data/spec/fixtures/vcr_cassettes/rubyllm_rerank_typesafe_jev_latest_orders_documents_by_relevance.yml +49 -0
  22. data/spec/fixtures/vcr_cassettes/rubyllm_typesafe_evaluation_typesafe_jev_latest_answers_noul_choice_and_score_questions.yml +46 -0
  23. data/spec/ruby_llm/models_spec.rb +11 -0
  24. data/spec/ruby_llm/providers/typesafe/system_one_spec.rb +162 -0
  25. data/spec/ruby_llm/providers/typesafe_spec.rb +58 -0
  26. data/spec/ruby_llm/rerank_request_spec.rb +38 -0
  27. data/spec/ruby_llm/rerank_spec.rb +23 -0
  28. data/spec/ruby_llm/typesafe/evaluation_spec.rb +27 -0
  29. data/spec/ruby_llm/typesafe_spec.rb +60 -0
  30. data/spec/spec_helper.rb +26 -0
  31. data/spec/support/models.rb +9 -0
  32. data/spec/support/rubyllm_configuration.rb +14 -0
  33. data/spec/support/vcr_configuration.rb +16 -0
  34. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed59a9e9d3a05dc3295e783fed03656a1c7991bc7569f6b3f998447d1ff0af13
4
+ data.tar.gz: 6bf922a4552c10571302adba8c53c05aefe3d684490adf259b0c885c8fd4b140
5
+ SHA512:
6
+ metadata.gz: 9d4ac70d8ee3fd3f473abffc61d7c586dd2672b7492b1f3c3548e3c9449aa7707cf17f887caa8796550bf36906f8bfc3c633a90c7a47dfc1405fa1ce1bf56285
7
+ data.tar.gz: f8722a75e6928beba4d2ae6269aa133c580305fd91d6629e4987f487e8795c0bb8c24493de8771097e8995df9f5cf39feedd900f26a9c1be8496d6959be67c02
data/.flayignore ADDED
@@ -0,0 +1 @@
1
+ spec/support/streaming_error_helpers.rb
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ["main"]
6
+ push:
7
+ branches: ["main"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Ruby ${{ matrix.ruby-version }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ ruby-version: ["3.1", "3.2", "3.3", "3.4", "4.0"]
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+
23
+ - uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: ${{ matrix.ruby-version }}
26
+ bundler-cache: true
27
+
28
+ - run: bundle exec rake
29
+ if: matrix.ruby-version != '3.1'
30
+
31
+ - run: bundle exec rake rubocop flay spec
32
+ if: matrix.ruby-version == '3.1'
@@ -0,0 +1,22 @@
1
+ name: Gitleaks
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ["main"]
6
+ push:
7
+ branches: ["main"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ scan:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - uses: gitleaks/gitleaks-action@v2
21
+ env:
22
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,33 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ publish:
14
+ name: Build and publish
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+
19
+ - uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: "3.4"
22
+ bundler-cache: true
23
+
24
+ - run: bundle exec rake
25
+
26
+ - name: Build gem
27
+ run: gem build ruby_llm-providers-typesafe.gemspec
28
+
29
+ - name: Configure RubyGems trusted publishing
30
+ uses: rubygems/configure-rubygems-credentials@v2.1.0
31
+
32
+ - name: Publish to RubyGems
33
+ run: gem push ruby_llm-providers-typesafe-*.gem
data/.overcommit.yml ADDED
@@ -0,0 +1,31 @@
1
+ PreCommit:
2
+ RuboCop:
3
+ enabled: true
4
+ auto_correct: true
5
+ on_warn: fail
6
+
7
+ Flay:
8
+ enabled: true
9
+ include:
10
+ - lib/**/*.rb
11
+ - spec/**/*.rb
12
+ mass_threshold: 70
13
+
14
+ ArchSpec:
15
+ enabled: true
16
+ command: ['bundle', 'exec', 'archspec', 'check']
17
+ on_warn: fail
18
+
19
+ RSpec:
20
+ enabled: true
21
+ command: ['bundle', 'exec', 'rspec']
22
+ on_warn: fail
23
+
24
+ TrailingWhitespace:
25
+ enabled: true
26
+ auto_correct: true
27
+
28
+ Gitleaks:
29
+ enabled: true
30
+ required_executable: gitleaks
31
+ command: ['gitleaks', 'protect', '--staged', '--redact', '--verbose']
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format progress
data/.rubocop.yml ADDED
@@ -0,0 +1,35 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ NewCops: enable
8
+ TargetRubyVersion: 3.1
9
+ Exclude:
10
+ - Archspec.rb
11
+ - vendor/**/*
12
+ - gemfiles/**/*
13
+ SuggestExtensions: false
14
+
15
+ Metrics/AbcSize:
16
+ Max: 80
17
+ Metrics/MethodLength:
18
+ Max: 40
19
+ CountAsOne:
20
+ - array
21
+ - hash
22
+ - heredoc
23
+ Metrics/BlockLength:
24
+ Exclude:
25
+ - spec/**/*.rb
26
+ RSpec/ExampleLength:
27
+ Enabled: false
28
+ RSpec/MultipleExpectations:
29
+ Enabled: false
30
+ RSpec/MultipleMemoizedHelpers:
31
+ Max: 8
32
+ Naming/MethodParameterName:
33
+ AllowedNames:
34
+ - id
35
+ - 'no'
data/Archspec.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'lib/**/*.rb'
4
+
5
+ component :provider,
6
+ in: %w[
7
+ lib/ruby_llm/providers/typesafe.rb
8
+ lib/ruby_llm/providers/typesafe/**/*.rb
9
+ ],
10
+ namespace: 'RubyLLM::Providers::Typesafe'
11
+
12
+ provider.cannot_reference_constants 'RSpec', 'WebMock', 'VCR'
13
+
14
+ preset :ruby_conventions
15
+
16
+ component :api,
17
+ in: %w[
18
+ lib/ruby_llm/typesafe.rb
19
+ lib/ruby_llm/typesafe/**/*.rb
20
+ ],
21
+ namespace: 'RubyLLM::Typesafe'
22
+
23
+ api.cannot_reference_constants 'RubyLLM::Providers', 'RSpec', 'WebMock', 'VCR'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Gradiche
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # ruby_llm-providers-typesafe
2
+
3
+ [TypeSafe](https://typesafe.ai) for [RubyLLM](https://rubyllm.com). TypeSafe's System One models, starting with Jev, answer typed questions about your data with calibrated probabilities instead of generated text.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'ruby_llm-providers-typesafe'
9
+ ```
10
+
11
+ ```ruby
12
+ RubyLLM.configure do |config|
13
+ config.typesafe_api_key = ENV['TYPESAFE_API_KEY']
14
+ end
15
+ ```
16
+
17
+ Outside Bundler, load it with `require 'ruby_llm/providers/typesafe'`.
18
+
19
+ ## Evaluate
20
+
21
+ Ask questions about a state, and get one typed answer per question:
22
+
23
+ ```ruby
24
+ evaluation = RubyLLM::Typesafe.evaluate("Help! My payouts have been failing for 3 days.") do |q|
25
+ q.noul :urgent, "Does this convey urgency?"
26
+ q.choice :team, "Which team should handle this?",
27
+ billing: "Payments, invoicing, refunds",
28
+ technical: "Bugs, outages, integrations",
29
+ sales: "Pricing, upgrades, new accounts"
30
+ q.score :frustration, "How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]
31
+ end
32
+
33
+ evaluation[:urgent].probability # => 0.92
34
+ evaluation[:urgent].yes? # => true
35
+ evaluation[:team].option # => :technical
36
+ evaluation[:team].probabilities # => {billing: 0.08, technical: 0.85, sales: 0.07}
37
+ evaluation[:team].confidence # => 0.82
38
+ evaluation[:frustration].score # => 1.6
39
+ evaluation[:frustration].level # => "Very angry"
40
+ evaluation.model # => "jev-1.13.0"
41
+ evaluation.cost.total # => 0.0000131
42
+ ```
43
+
44
+ There are three question types:
45
+
46
+ * `noul` asks a yes/no question. Its answer is the probability of yes. Pass `yes:` and `no:` to describe what each answer means.
47
+ * `choice` picks one option. Pass descriptions as keywords or a Hash, or an Array of options that need none. The answer uses the keys you passed.
48
+ * `score` rates the state on ordered levels, lowest first. The answer can land between levels.
49
+
50
+ The state can be a String, or a Hash or Array for structured data. Refer to parts of it from your questions with backticked paths such as `` `ticket.messages[0].text` ``. Every question runs in parallel against the same state, so ask independent questions together.
51
+
52
+ `yes?` compares the probability to 0.5 by default. Choose thresholds from your own data with `yes?(threshold: 0.8)`, and use `confidence` to send uncertain choices and scores to a person. See TypeSafe's guides to [writing questions](https://docs.typesafe.ai/concepts/how-to-build-with-system-one) and [confidence](https://docs.typesafe.ai/confidence).
53
+
54
+ Evaluations default to `jev-latest`. Pin a version once you have tuned thresholds against it:
55
+
56
+ ```ruby
57
+ RubyLLM::Typesafe.evaluate(ticket, model: "jev-1.13.0") { |q| q.noul :spam, "Is this message spam?" }
58
+ ```
59
+
60
+ ## Rerank
61
+
62
+ Jev also works with `RubyLLM.rerank`. Every document gets a relevance question in a single request, and its score is the probability that it helps answer the query:
63
+
64
+ ```ruby
65
+ rerank = RubyLLM.rerank("What is the capital of the United States?",
66
+ ["Carson City is the capital of Nevada.",
67
+ "Washington, D.C. is the capital of the United States."],
68
+ model: "jev-latest", provider: :typesafe, top_n: 1)
69
+
70
+ rerank.results.first.document # => "Washington, D.C. is the capital of the United States."
71
+ ```
72
+
73
+ The query and all documents share Jev's 32k-token state budget. Rerank a shortlist from a faster search, not a whole corpus.
74
+
75
+ ## Development
76
+
77
+ ```sh
78
+ bin/setup
79
+ bundle exec rake
80
+ ```
81
+
82
+ Copy `.env.example` to `.env` and set `TYPESAFE_API_KEY` to record VCR cassettes. The first local run calls the API and records them; CI only replays committed cassettes. A failing live example deletes its cassette so the next run hits the API again.
83
+
84
+ `bundle exec rake models` refreshes `models.json` from TypeSafe's model listing. The listing returns aliases such as `jev-latest`; versioned ids such as `jev-1.13.0` are accepted without being listed.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Typesafe < Provider
6
+ class SystemOne < Protocol
7
+ # Renders Typesafe::Questions to the wire and parses the answers
8
+ # into a Typesafe::Evaluation.
9
+ module Evaluations
10
+ def evaluation_url
11
+ 'v1/systemone'
12
+ end
13
+
14
+ def evaluate(state, questions, model:)
15
+ response = @connection.post evaluation_url, render_evaluation_payload(state, questions, model:)
16
+ parse_evaluation_response(response, questions)
17
+ end
18
+
19
+ def render_evaluation_payload(state, questions, model:)
20
+ {
21
+ state: state,
22
+ model: model,
23
+ questions: questions.to_h.to_h { |id, question| [id.to_s, render_question(question)] }
24
+ }
25
+ end
26
+
27
+ def render_question(question)
28
+ rendered = { type: question.type.to_s, instructions: question.instructions }
29
+ criteria = render_criteria(question)
30
+ criteria ? rendered.merge(criteria: criteria) : rendered
31
+ end
32
+
33
+ def parse_evaluation_response(response, questions)
34
+ data = response.body
35
+ answers = questions.to_h.to_h do |id, question|
36
+ [id, parse_answer(question, data.dig('answers', id.to_s) || {})]
37
+ end
38
+ usage = data['usage'] || {}
39
+
40
+ RubyLLM::Typesafe::Evaluation.new(
41
+ answers: answers,
42
+ model: data['model'],
43
+ tokens: Tokens.new(input: usage['input_tokens'], output: usage['output_tokens']),
44
+ model_info: @model,
45
+ raw: data
46
+ )
47
+ end
48
+
49
+ def parse_answer(question, data)
50
+ case question.type
51
+ when :noul then RubyLLM::Typesafe::Evaluation::Noul.new(probability: data['noul'])
52
+ when :choice then parse_choice_answer(question, data)
53
+ when :score then parse_score_answer(question, data)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def render_criteria(question)
60
+ case question.type
61
+ when :noul
62
+ criteria = { true => question.criteria[:yes], false => question.criteria[:no] }.compact
63
+ criteria unless criteria.empty?
64
+ when :choice then question.criteria.to_h { |option, description| [option.to_s, description] }
65
+ when :score then question.criteria
66
+ end
67
+ end
68
+
69
+ def parse_choice_answer(question, data)
70
+ options = question.criteria.keys.to_h { |option| [option.to_s, option] }
71
+
72
+ RubyLLM::Typesafe::Evaluation::Choice.new(
73
+ option: options.fetch(data['choice'], data['choice']),
74
+ probabilities: (data['probabilities'] || {}).to_h do |option, value|
75
+ [options.fetch(option, option), value]
76
+ end,
77
+ confidence: data['confidence']
78
+ )
79
+ end
80
+
81
+ def parse_score_answer(question, data)
82
+ levels = question.criteria
83
+
84
+ RubyLLM::Typesafe::Evaluation::Score.new(
85
+ score: data['score'],
86
+ levels: levels,
87
+ probabilities: levels.each_index.map { |index| data.dig('probabilities', index.to_s) },
88
+ confidence: data['confidence']
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Typesafe < Provider
6
+ class SystemOne < Protocol
7
+ # Model listing for the System One API. Every model is billed per
8
+ # input token; output tokens are free.
9
+ module Models
10
+ INPUT_PRICE_PER_MILLION = 0.042
11
+
12
+ def models_url
13
+ 'v1/models'
14
+ end
15
+
16
+ def parse_list_models_response(response, slug)
17
+ Array(response.body['models']).map do |model_data|
18
+ Model.new(
19
+ id: model_data['name'],
20
+ name: model_data['name'],
21
+ provider: slug,
22
+ created_at: model_data['release_date'],
23
+ modalities: { input: ['text'], output: ['rerank'] },
24
+ capabilities: [],
25
+ pricing: { text_tokens: { standard: { input_per_million: INPUT_PRICE_PER_MILLION,
26
+ output_per_million: 0 } } },
27
+ metadata: { description: model_data['description'] }.compact
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Typesafe < Provider
6
+ class SystemOne < Protocol
7
+ # Reranking as one request: the query and documents form the state,
8
+ # and each document gets its own relevance question. The noul, the
9
+ # probability that the document is relevant, is its score.
10
+ module Rerank
11
+ RELEVANCE_CRITERIA = {
12
+ true => 'The document contains information that answers or directly addresses the query.',
13
+ false => 'The document is off topic or only shares words with the query.'
14
+ }.freeze
15
+
16
+ def rerank_url
17
+ evaluation_url
18
+ end
19
+
20
+ def render_rerank_payload(query, documents, model:, top_n: nil, provider_options: {})
21
+ @top_n = top_n
22
+ questions = documents.each_index.to_h do |index|
23
+ [rerank_question_id(index), { type: 'noul', instructions: relevance_instructions(index),
24
+ criteria: RELEVANCE_CRITERIA }]
25
+ end
26
+
27
+ { state: { query: query, documents: documents }, model: model, questions: questions }
28
+ .merge(provider_options)
29
+ end
30
+
31
+ def parse_rerank_response(response, model:, documents: [])
32
+ data = response.body
33
+ results = documents.each_with_index.map do |document, index|
34
+ RubyLLM::Rerank::Result.new(index: index, document: document,
35
+ score: data.dig('answers', rerank_question_id(index), 'noul'))
36
+ end
37
+ results = results.sort_by { |result| -result.score.to_f }
38
+ results = results.first(@top_n) if @top_n
39
+
40
+ RubyLLM::Rerank.new(results: results, model: data['model'] || model, raw: data,
41
+ input_tokens: data.dig('usage', 'input_tokens'))
42
+ end
43
+
44
+ private
45
+
46
+ def rerank_question_id(index)
47
+ "document_#{index}"
48
+ end
49
+
50
+ def relevance_instructions(index)
51
+ "Does `documents[#{index}]` help answer `query`?"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'system_one/models'
4
+ require_relative 'system_one/evaluations'
5
+ require_relative 'system_one/rerank'
6
+
7
+ module RubyLLM
8
+ module Providers
9
+ class Typesafe < Provider
10
+ # The System One wire format: a state and a map of typed questions go
11
+ # in, one typed answer per question comes back.
12
+ class SystemOne < Protocol
13
+ include Models
14
+ include Evaluations
15
+ include Rerank
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require_relative '../typesafe'
5
+ require_relative 'typesafe/system_one'
6
+
7
+ module RubyLLM
8
+ module Providers
9
+ # TypeSafe API integration. TypeSafe's System One models, such as Jev,
10
+ # answer typed questions about a state instead of generating text.
11
+ class Typesafe < Provider
12
+ protocol :system_one, SystemOne
13
+
14
+ def api_base
15
+ @config.typesafe_api_base || 'https://api.typesafe.ai'
16
+ end
17
+
18
+ def headers
19
+ { 'Authorization' => "Bearer #{@config.typesafe_api_key}" }
20
+ end
21
+
22
+ def evaluate(state, questions, model:) # :nodoc:
23
+ resolve_protocol(nil, model).new(self, model).evaluate(state, questions, model: model_id_for(model))
24
+ end
25
+
26
+ def parse_error(response) # :nodoc:
27
+ body = parse_error_body(response)
28
+ detail = body['detail'] if body.is_a?(Hash)
29
+
30
+ case detail
31
+ when Hash then detail['message'] || super
32
+ when Array then detail.map { |part| detail_message(part) }.join('. ')
33
+ when String then detail
34
+ else super
35
+ end
36
+ end
37
+
38
+ class << self
39
+ def display_name
40
+ 'TypeSafe'
41
+ end
42
+
43
+ def configuration_options
44
+ %i[typesafe_api_key typesafe_api_base]
45
+ end
46
+
47
+ def configuration_requirements
48
+ %i[typesafe_api_key]
49
+ end
50
+
51
+ # The model listing returns aliases only, while versioned ids such
52
+ # as jev-1.13.0 are accepted too.
53
+ def assume_models_exist?
54
+ true
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def detail_message(part)
61
+ part.is_a?(Hash) ? part['msg'].to_s : part.to_s
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ RubyLLM::Provider.register :typesafe, RubyLLM::Providers::Typesafe,
68
+ models: File.expand_path('../../../models.json', __dir__)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Typesafe
5
+ # The answers to one Typesafe.evaluate call, keyed by the ids you gave
6
+ # the questions.
7
+ #
8
+ # evaluation[:urgent].probability # => 0.92
9
+ # evaluation.cost.total # => 0.0000131
10
+ #
11
+ class Evaluation
12
+ include Support::Inspectable
13
+
14
+ # The answer to a yes/no question. +probability+ is the chance the
15
+ # answer is yes, from 0 to 1.
16
+ Noul = Struct.new(:probability, keyword_init: true) do
17
+ # Returns whether +probability+ reaches +threshold+. Tune the
18
+ # threshold on your own data and consequences.
19
+ def yes?(threshold: 0.5)
20
+ probability >= threshold
21
+ end
22
+ end
23
+
24
+ # The answer to a choice question. +option+ is the most probable
25
+ # option, +probabilities+ maps every option to its probability, and
26
+ # +confidence+ (0 to 1) says how concentrated they are.
27
+ Choice = Struct.new(:option, :probabilities, :confidence, keyword_init: true)
28
+
29
+ # The answer to a score question. +score+ is the probability-weighted
30
+ # level index and can land between levels. +probabilities+ holds one
31
+ # probability per level, in the order of +levels+.
32
+ Score = Struct.new(:score, :levels, :probabilities, :confidence, keyword_init: true) do
33
+ # Returns the level description nearest to +score+.
34
+ def level
35
+ levels[score.round]
36
+ end
37
+ end
38
+
39
+ # The answers as a Hash of question id to Noul, Choice, or Score.
40
+ attr_reader :answers
41
+
42
+ # The versioned id of the model that answered, such as "jev-1.13.0".
43
+ attr_reader :model
44
+
45
+ # The raw provider response body.
46
+ attr_reader :raw
47
+
48
+ # The Tokens the evaluation used.
49
+ attr_reader :tokens
50
+
51
+ def initialize(answers:, model:, tokens:, model_info: nil, raw: nil) # :nodoc:
52
+ @answers = answers
53
+ @model = model
54
+ @tokens = tokens
55
+ @model_info = model_info
56
+ @raw = raw
57
+ end
58
+
59
+ # Returns the answer to the question with +id+.
60
+ def [](id)
61
+ answers.fetch(id)
62
+ end
63
+
64
+ # Returns the evaluation's Cost, or a Cost with a +nil+ total when the
65
+ # model has no known pricing.
66
+ def cost
67
+ Cost.new(tokens: tokens, model: @model_info)
68
+ end
69
+
70
+ def inspect_attributes # :nodoc:
71
+ { model: model, answers: answers.keys }
72
+ end
73
+ end
74
+ end
75
+ end