yuno-proofreader 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +435 -0
  5. data/exe/yuno-proofreader +16 -0
  6. data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
  7. data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
  8. data/lib/yuno/proofreader/analysis/entries.rb +73 -0
  9. data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
  10. data/lib/yuno/proofreader/analysis/graph.rb +115 -0
  11. data/lib/yuno/proofreader/analysis/judge.rb +103 -0
  12. data/lib/yuno/proofreader/analysis/judge.yml +27 -0
  13. data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
  14. data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
  15. data/lib/yuno/proofreader/analysis/links.rb +54 -0
  16. data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
  17. data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
  18. data/lib/yuno/proofreader/analysis/report.rb +110 -0
  19. data/lib/yuno/proofreader/analysis/runner.rb +171 -0
  20. data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
  21. data/lib/yuno/proofreader/analysis/settings.rb +90 -0
  22. data/lib/yuno/proofreader/analysis/signals.rb +102 -0
  23. data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
  24. data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
  25. data/lib/yuno/proofreader/analysis/weights.rb +77 -0
  26. data/lib/yuno/proofreader/analysis/weights.yml +39 -0
  27. data/lib/yuno/proofreader/audit/agenda.rb +86 -0
  28. data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
  29. data/lib/yuno/proofreader/audit/answers.rb +74 -0
  30. data/lib/yuno/proofreader/audit/attempt.rb +69 -0
  31. data/lib/yuno/proofreader/audit/batch.rb +64 -0
  32. data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
  33. data/lib/yuno/proofreader/audit/cadence.rb +67 -0
  34. data/lib/yuno/proofreader/audit/instructions.rb +30 -0
  35. data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
  36. data/lib/yuno/proofreader/audit/prompt.rb +157 -0
  37. data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
  38. data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
  39. data/lib/yuno/proofreader/audit/report.rb +153 -0
  40. data/lib/yuno/proofreader/audit/runner.rb +144 -0
  41. data/lib/yuno/proofreader/audit/steps.rb +56 -0
  42. data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
  43. data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
  44. data/lib/yuno/proofreader/cli/command_log.rb +42 -0
  45. data/lib/yuno/proofreader/cli/flags.rb +48 -0
  46. data/lib/yuno/proofreader/cli/init_command.rb +116 -0
  47. data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
  48. data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
  49. data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
  50. data/lib/yuno/proofreader/cli.rb +218 -0
  51. data/lib/yuno/proofreader/config.rb +25 -0
  52. data/lib/yuno/proofreader/context.rb +55 -0
  53. data/lib/yuno/proofreader/env_file.rb +76 -0
  54. data/lib/yuno/proofreader/errors.rb +17 -0
  55. data/lib/yuno/proofreader/findings/finding.rb +95 -0
  56. data/lib/yuno/proofreader/findings/matching.rb +34 -0
  57. data/lib/yuno/proofreader/findings/merger.rb +43 -0
  58. data/lib/yuno/proofreader/findings/outline.rb +73 -0
  59. data/lib/yuno/proofreader/findings/plan.rb +24 -0
  60. data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
  61. data/lib/yuno/proofreader/findings/source.rb +79 -0
  62. data/lib/yuno/proofreader/findings/text.rb +37 -0
  63. data/lib/yuno/proofreader/http/client.rb +131 -0
  64. data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
  65. data/lib/yuno/proofreader/http/robots.rb +72 -0
  66. data/lib/yuno/proofreader/init/ask.rb +67 -0
  67. data/lib/yuno/proofreader/init/detector.rb +100 -0
  68. data/lib/yuno/proofreader/init/interview.rb +210 -0
  69. data/lib/yuno/proofreader/init/parsing.rb +47 -0
  70. data/lib/yuno/proofreader/init/web.rb +33 -0
  71. data/lib/yuno/proofreader/llm/client.rb +140 -0
  72. data/lib/yuno/proofreader/llm/defaults.rb +22 -0
  73. data/lib/yuno/proofreader/llm/defaults.yml +6 -0
  74. data/lib/yuno/proofreader/llm/errors.rb +26 -0
  75. data/lib/yuno/proofreader/llm/factory.rb +45 -0
  76. data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
  77. data/lib/yuno/proofreader/llm/json_text.rb +23 -0
  78. data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
  79. data/lib/yuno/proofreader/llm/provider.rb +58 -0
  80. data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
  81. data/lib/yuno/proofreader/llm/providers.rb +38 -0
  82. data/lib/yuno/proofreader/llm/types.rb +13 -0
  83. data/lib/yuno/proofreader/llm/usage.rb +17 -0
  84. data/lib/yuno/proofreader/log.rb +129 -0
  85. data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
  86. data/lib/yuno/proofreader/pages/report.rb +45 -0
  87. data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
  88. data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
  89. data/lib/yuno/proofreader/settings_file.rb +51 -0
  90. data/lib/yuno/proofreader/site.rb +74 -0
  91. data/lib/yuno/proofreader/site_reader.rb +131 -0
  92. data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
  93. data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
  94. data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
  95. data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
  96. data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
  97. data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
  98. data/lib/yuno/proofreader/sitemap/report.rb +80 -0
  99. data/lib/yuno/proofreader/store/cells.rb +67 -0
  100. data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
  101. data/lib/yuno/proofreader/store/memory.rb +90 -0
  102. data/lib/yuno/proofreader/store/records.rb +28 -0
  103. data/lib/yuno/proofreader/store/schema.rb +109 -0
  104. data/lib/yuno/proofreader/store/sheets.rb +183 -0
  105. data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
  106. data/lib/yuno/proofreader/version.rb +7 -0
  107. data/lib/yuno/proofreader.rb +101 -0
  108. data/proofreader.example.yml +134 -0
  109. metadata +194 -0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Init
6
+ # The two things init looks up on the web: the homepage of the site, and its sitemap. Both honor robots.txt
7
+ # and the pace of the fetcher.
8
+ class Web
9
+ def initialize(http:)
10
+ @http = http
11
+ end
12
+
13
+ # [html, address] of the homepage. Raises FetchError when it cannot be read.
14
+ def homepage(origin)
15
+ site = Site.from_hash("website" => origin, "languages" => [{ "code" => "en", "prefix" => "" }])
16
+ url = "#{origin}/"
17
+ page = Sitemap::Page.new(name: url, urls: { "en" => url }, lastmod: "", notes: [])
18
+ snapshot = Pages::Fetcher.new(site, @http).fetch(page).snapshots.first
19
+ raise FetchError, snapshot.problem.to_s unless snapshot.ok?
20
+
21
+ [snapshot.raw, snapshot.final_url]
22
+ end
23
+
24
+ # [how many pages the sitemap lists, whether they could be paired by language]. Raises an Error when no
25
+ # sitemap can be used.
26
+ def sitemap(settings)
27
+ pairing = Sitemap::Loader.call(Site.from_hash(settings), @http).pairing
28
+ [pairing.pages.size, pairing.resolved?]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Llm
6
+ # Asks a list of models in turn until one answers with JSON. A model that is over its limit or does not
7
+ # exist is skipped for the rest of the run, and a round in which only overloaded models were met is
8
+ # repeated after a pause. It knows nothing about a particular provider or model. What goes wrong is written
9
+ # to the log as warnings, and each answer, with its tokens, at debug level.
10
+ class Client
11
+ RETRY_ROUNDS = 3
12
+ RETRY_WAIT = 20
13
+ SKIP_MINUTE = 65
14
+ SKIP_DAY = 21_600
15
+ MAX_OUTPUT_TOKENS = 24_000
16
+ REAL_SLEEP = ->(seconds) { sleep(seconds) }
17
+ REAL_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
18
+ Failure = Data.define(:model, :message, :kind)
19
+
20
+ attr_reader :models
21
+
22
+ def initialize(provider:, models:, sleeper: REAL_SLEEP, clock: REAL_CLOCK, retry_wait: RETRY_WAIT,
23
+ max_output_tokens: MAX_OUTPUT_TOKENS, log: Log.null)
24
+ raise ArgumentError, "The client needs at least one model" if models.empty?
25
+
26
+ @provider = provider
27
+ @models = models.dup.freeze
28
+ @sleeper = sleeper
29
+ @clock = clock
30
+ @retry_wait = retry_wait
31
+ @max_output_tokens = max_output_tokens
32
+ @log = log
33
+ @skipped = {}
34
+ end
35
+
36
+ # Whether there is a model other than this one, for a second opinion that is independent of the first.
37
+ def independent_of?(model) = @models.any? { |name| name != model }
38
+
39
+ # avoid: models to try last, such as the one that gave the answer this one should be independent of.
40
+ def generate(system:, user:, avoid: [])
41
+ failures = []
42
+ RETRY_ROUNDS.times do |round|
43
+ outcome = round_of(order(avoid), system, user, failures)
44
+ return outcome if outcome.is_a?(Answer)
45
+ break unless outcome == :transient && round < RETRY_ROUNDS - 1
46
+
47
+ pause(round)
48
+ end
49
+ raise UnavailableError, unavailable_message(failures)
50
+ end
51
+
52
+ private
53
+
54
+ def pause(round)
55
+ seconds = @retry_wait * (round + 1)
56
+ @log.warn("waiting before trying the models again", seconds:, round: round + 1)
57
+ @sleeper.call(seconds)
58
+ end
59
+
60
+ def order(avoid)
61
+ preferred = @models.reject { |model| avoid.include?(model) }
62
+ preferred + (@models - preferred)
63
+ end
64
+
65
+ # Tries every model once. Returns the Answer, or :transient when waiting might help, or nil.
66
+ def round_of(models, system, user, failures)
67
+ transient = false
68
+ models.each do |model|
69
+ next if skipped?(model)
70
+
71
+ result = attempt(model, system, user, failures)
72
+ return result if result.is_a?(Answer)
73
+
74
+ transient ||= result == :transient
75
+ end
76
+ transient ? :transient : nil
77
+ end
78
+
79
+ def attempt(model, system, user, failures)
80
+ started = @clock.call
81
+ completion = @provider.complete(model:, system:, user:, max_output_tokens: @max_output_tokens)
82
+ log_answer(answer_from(model, completion, @clock.call - started))
83
+ rescue AnswerError => e
84
+ failures << Failure.new(model:, message: e.message, kind: :answer)
85
+ @log.warn("model answer unusable", model:, message: e.message)
86
+ nil
87
+ rescue ProviderError => e
88
+ failures << Failure.new(model:, message: e.message, kind: e.kind)
89
+ @log.warn("model failed", model:, kind: e.kind, message: e.message)
90
+ react(model, e)
91
+ end
92
+
93
+ def answer_from(model, completion, seconds)
94
+ data = JsonText.parse(completion.text, finish: completion.finish)
95
+ Answer.new(data:, model:, finish: completion.finish, input_tokens: completion.input_tokens,
96
+ output_tokens: completion.output_tokens, thinking_tokens: completion.thinking_tokens, seconds:)
97
+ end
98
+
99
+ def log_answer(answer)
100
+ @log.debug("model answered", model: answer.model, seconds: answer.seconds, finish: answer.finish,
101
+ input_tokens: answer.input_tokens, output_tokens: answer.output_tokens,
102
+ thinking_tokens: answer.thinking_tokens)
103
+ answer
104
+ end
105
+
106
+ def react(model, error)
107
+ case error.kind
108
+ when :auth then raise error
109
+ when :rate_limited then skip(model, error.daily ? SKIP_DAY : SKIP_MINUTE)
110
+ when :not_found, :forbidden then skip(model, SKIP_DAY)
111
+ when :overloaded, :network then :transient
112
+ end
113
+ end
114
+
115
+ def skip(model, seconds)
116
+ @skipped[model] = @clock.call + seconds
117
+ @log.warn("model paused", model:, seconds:)
118
+ nil
119
+ end
120
+
121
+ def skipped?(model)
122
+ limit = @skipped[model]
123
+ !limit.nil? && @clock.call < limit
124
+ end
125
+
126
+ def unavailable_message(failures)
127
+ detail = failures.last(6).map { |failure| "#{failure.model}: #{failure.message}" }.join(" | ")
128
+ detail = "every model is paused after limits" if detail.empty?
129
+ "No model could answer: #{detail}.#{choose_hint(failures)}"
130
+ end
131
+
132
+ def choose_hint(failures)
133
+ return "" unless failures.any? { |failure| %i[not_found forbidden].include?(failure.kind) }
134
+
135
+ " Run yuno-proofreader models to see which models your key can use, then set models in proofreader.yml."
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Llm
8
+ # The built-in model lists, read from defaults.yml.
9
+ module Defaults
10
+ PATH = File.join(__dir__, "defaults.yml")
11
+
12
+ module_function
13
+
14
+ def models_for(provider_id)
15
+ Array(table[provider_id.to_s]).map { |name| name.to_s.strip }.reject(&:empty?)
16
+ end
17
+
18
+ def table = @table ||= YAML.safe_load_file(PATH) || {}
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ # Fallback model lists, used only when proofreader.yml does not set `models`.
2
+ # Model names change often, so look this list over at every release. Used models can be replace at any time in proofreader.yml.
3
+ gemini:
4
+ - gemini-3.8-flash
5
+ - gemini-3.7-flash
6
+ - gemini-3.6-flash
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Llm
6
+ # A model call that failed. kind says what to do about it: :rate_limited (daily says whether the limit
7
+ # is a daily one), :overloaded, :network, :empty, :blocked, :not_found, :forbidden, :bad_request,
8
+ # :auth (the key is wrong, so nothing will work) or :other.
9
+ class ProviderError < LlmError
10
+ attr_reader :kind, :daily
11
+
12
+ def initialize(message, kind: :other, daily: false)
13
+ super(message)
14
+ @kind = kind
15
+ @daily = daily
16
+ end
17
+ end
18
+
19
+ # A model answered, but not with JSON that can be used.
20
+ class AnswerError < LlmError; end
21
+
22
+ # None of the models could answer.
23
+ class UnavailableError < LlmError; end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ # Builds the pieces for calling models from the site settings and the environment.
6
+ module Llm
7
+ # The models in use and where the list came from (:settings or :defaults).
8
+ Selection = Data.define(:names, :source)
9
+
10
+ class << self
11
+ # The provider of the site, with its API key from the environment.
12
+ def provider_for(site, env: ENV, transport: nil)
13
+ klass = Providers.resolve(site.provider)
14
+ key = env[klass.api_key_env].to_s.strip
15
+ if key.empty?
16
+ raise ConfigError,
17
+ "Missing environment variable #{klass.api_key_env}: your #{klass.id} API key."
18
+ end
19
+
20
+ klass.new(api_key: key, **(transport ? { transport: } : {}))
21
+ end
22
+
23
+ # The settings win. For the second review, review_models win over models. Without any, the provider's
24
+ # built-in list is used.
25
+ def models_for(site, provider_class, review: false)
26
+ listed = review && !site.review_models.empty? ? site.review_models : site.models
27
+ return Selection.new(names: listed, source: :settings) unless listed.empty?
28
+
29
+ defaults = Defaults.models_for(provider_class.id)
30
+ if defaults.empty?
31
+ raise ConfigError, "Site: set models in proofreader.yml. #{provider_class.id} has no built-in list " \
32
+ "(yuno-proofreader models shows what your key can use)."
33
+ end
34
+ Selection.new(names: defaults, source: :defaults)
35
+ end
36
+
37
+ # options go to the client: log:, sleeper:, clock:, retry_wait:, max_output_tokens:
38
+ def client_for(site, env: ENV, review: false, transport: nil, **)
39
+ provider = provider_for(site, env:, transport:)
40
+ Client.new(provider:, models: models_for(site, provider.class, review:).names, **)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Yuno
7
+ module Proofreader
8
+ module Llm
9
+ # Sends one request to a model API and returns the answer whatever its status. Network errors are raised
10
+ # as they are. The provider turns them into ProviderError.
11
+ class HttpTransport
12
+ CONNECT_TIMEOUT = 15
13
+
14
+ def call(method, url, headers:, timeout:, body: nil)
15
+ uri = URI.parse(url)
16
+ response = connection(uri, timeout).request(build(method, uri, headers, body))
17
+ Http::RawResponse.new(status: response.code.to_i, headers: response.to_hash.transform_values do |v|
18
+ v.join(", ")
19
+ end,
20
+ body: response.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub)
21
+ end
22
+
23
+ private
24
+
25
+ def connection(uri, timeout)
26
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
27
+ http.use_ssl = uri.scheme == "https"
28
+ http.open_timeout = CONNECT_TIMEOUT
29
+ http.read_timeout = timeout
30
+ end
31
+ end
32
+
33
+ def build(method, uri, headers, body)
34
+ request = (method == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri, headers)
35
+ request.body = body if body
36
+ request
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Llm
8
+ # Reads the JSON a model wrote. Models sometimes wrap it in a markdown code fence.
9
+ module JsonText
10
+ module_function
11
+
12
+ def parse(text, finish: nil)
13
+ clean = text.to_s.strip.sub(/\A```(?:json)?\s*/i, "").sub(/\s*```\z/, "").strip
14
+ JSON.parse(clean)
15
+ rescue JSON::ParserError
16
+ cut = finish.to_s == "MAX_TOKENS" ? " The answer was cut off at the output limit." : ""
17
+ raise AnswerError, "The answer is not valid JSON (finish: #{finish.inspect}).#{cut} " \
18
+ "It starts: #{clean[0, 120].inspect}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ # The command that works with the model provider: models.
6
+ module ModelCommands
7
+ private
8
+
9
+ def models(args)
10
+ file, problem = single_file(args, "models")
11
+ return usage_error(problem) if problem
12
+
13
+ site = Site.load(file)
14
+ provider = Llm.provider_for(site, env: @env, transport: @llm_transport)
15
+ selection = Llm.models_for(site, provider.class)
16
+ used = selection.names.map { |name| provider.normalize(name) }
17
+ print_models(provider, provider.models, used, selection.source)
18
+ 0
19
+ end
20
+
21
+ def print_models(provider, available, used, source)
22
+ @out.puts "Models your #{provider.class.id} key can use for text generation:"
23
+ available.each { |name| @out.puts " #{name}#{' (in use)' if used.include?(name)}" }
24
+ origin = source == :settings ? "from your settings" : "built-in default"
25
+ @out.puts "", "In use (#{origin}): #{used.join(', ')}"
26
+ missing = used - available
27
+ return if missing.empty?
28
+
29
+ @out.puts "Not available to this key: #{missing.join(', ')}. A model that fails is skipped, " \
30
+ "so set models in proofreader.yml to the ones you want."
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+
7
+ module Yuno
8
+ module Proofreader
9
+ module Llm
10
+ # What every model provider offers. A provider is a light wrapper around one family of models: it knows
11
+ # the address, how the request is written and how errors are reported. The rest (model list, retries,
12
+ # JSON handling) is shared. To add a provider, subclass this, define the two class methods and the two
13
+ # public methods, and register the class (see providers/gemini.rb).
14
+ class Provider
15
+ NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
16
+ Net::ProtocolError, Net::HTTPBadResponse].freeze
17
+ TIMEOUT = 180
18
+
19
+ # The name used for it in the settings file, in lowercase.
20
+ def self.id
21
+ raise NotImplementedError, "#{name} must define .id"
22
+ end
23
+
24
+ # The environment variable that holds its API key.
25
+ def self.api_key_env
26
+ raise NotImplementedError, "#{name} must define .api_key_env"
27
+ end
28
+
29
+ def initialize(api_key:, transport: HttpTransport.new, timeout: TIMEOUT)
30
+ @api_key = api_key
31
+ @transport = transport
32
+ @timeout = timeout
33
+ end
34
+
35
+ # Sends one request. Returns a Completion, or raises ProviderError saying what kind of failure it was.
36
+ def complete(model:, system:, user:, max_output_tokens:)
37
+ raise NotImplementedError, "#{self.class.name} must define #complete"
38
+ end
39
+
40
+ # The names of the models this key can use for text generation.
41
+ def models
42
+ raise NotImplementedError, "#{self.class.name} must define #models"
43
+ end
44
+
45
+ # A model name as the provider's API wants it. Providers that write names differently override this.
46
+ def normalize(name) = name.to_s
47
+
48
+ private
49
+
50
+ def request(method, url, headers: {}, body: nil)
51
+ @transport.call(method, url, headers:, timeout: @timeout, body:)
52
+ rescue *NETWORK_ERRORS => e
53
+ raise ProviderError.new("network problem (#{e.class}: #{e.message})", kind: :network)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Yuno
7
+ module Proofreader
8
+ module Llm
9
+ # The model providers that are installed. Each wrapper registers itself when it is loaded.
10
+ module Providers
11
+ # The wrapper for Google's Gemini models.
12
+ class Gemini < Provider
13
+ BASE = "https://generativelanguage.googleapis.com/v1beta"
14
+ MAX_PAGES = 10
15
+ OVERLOADED = [500, 502, 503, 504].freeze
16
+ KINDS = { 401 => :auth, 403 => :forbidden, 404 => :not_found, 429 => :rate_limited }.freeze
17
+
18
+ def self.id = "gemini"
19
+
20
+ def self.api_key_env = "GEMINI_API_KEY"
21
+
22
+ def complete(model:, system:, user:, max_output_tokens:)
23
+ body = JSON.generate(payload(system, user, max_output_tokens))
24
+ url = "#{BASE}/models/#{normalize(model)}:generateContent"
25
+ response = request(:post, url, headers: auth_headers.merge("Content-Type" => "application/json"), body:)
26
+ raise failure(response) unless response.status == 200
27
+
28
+ completion_from(parse_body(response))
29
+ end
30
+
31
+ def models
32
+ names = []
33
+ token = nil
34
+ MAX_PAGES.times do
35
+ page = list_page(token)
36
+ names.concat(usable_names(page))
37
+ token = page["nextPageToken"].to_s
38
+ break if token.empty?
39
+ end
40
+ names.uniq.sort
41
+ end
42
+
43
+ def normalize(name) = name.to_s.delete_prefix("models/")
44
+
45
+ private
46
+
47
+ def auth_headers = { "x-goog-api-key" => @api_key }
48
+
49
+ def payload(system, user, max_output_tokens)
50
+ {
51
+ systemInstruction: { parts: [{ text: system }] },
52
+ contents: [{ role: "user", parts: [{ text: user }] }],
53
+ generationConfig: { temperature: 0, responseMimeType: "application/json",
54
+ maxOutputTokens: max_output_tokens }
55
+ }
56
+ end
57
+
58
+ def list_page(token)
59
+ url = "#{BASE}/models?pageSize=200"
60
+ url += "&pageToken=#{URI.encode_www_form_component(token)}" if token
61
+ response = request(:get, url, headers: auth_headers)
62
+ raise failure(response) unless response.status == 200
63
+
64
+ parse_body(response)
65
+ end
66
+
67
+ def usable_names(page)
68
+ Array(page["models"]).filter_map do |model|
69
+ next unless Array(model["supportedGenerationMethods"]).include?("generateContent")
70
+
71
+ normalize(model["name"])
72
+ end
73
+ end
74
+
75
+ def parse_body(response)
76
+ data = JSON.parse(response.body)
77
+ return data if data.is_a?(Hash)
78
+
79
+ raise ProviderError.new("The answer is not a JSON object.", kind: :other)
80
+ rescue JSON::ParserError
81
+ raise ProviderError.new("The answer is not JSON (HTTP #{response.status}).", kind: :other)
82
+ end
83
+
84
+ def completion_from(data)
85
+ candidate = Array(data["candidates"]).first
86
+ raise no_candidate(data) unless candidate
87
+
88
+ finish = candidate["finishReason"].to_s
89
+ text = candidate_text(candidate)
90
+ if text.strip.empty?
91
+ raise ProviderError.new("The answer is empty (finish: #{finish.inspect}).",
92
+ kind: :empty)
93
+ end
94
+
95
+ input, output, thinking = token_counts(data["usageMetadata"])
96
+ Completion.new(text:, finish:, input_tokens: input, output_tokens: output, thinking_tokens: thinking)
97
+ end
98
+
99
+ def candidate_text(candidate)
100
+ parts = Array(candidate.dig("content", "parts")).reject { |part| part["thought"] }
101
+ parts.map { |part| part["text"].to_s }.join
102
+ end
103
+
104
+ def token_counts(usage)
105
+ usage ||= {}
106
+ [usage["promptTokenCount"].to_i, usage["candidatesTokenCount"].to_i, usage["thoughtsTokenCount"].to_i]
107
+ end
108
+
109
+ def no_candidate(data)
110
+ reason = data.dig("promptFeedback", "blockReason")
111
+ return ProviderError.new("The request was blocked (#{reason}).", kind: :blocked) if reason
112
+
113
+ ProviderError.new("The answer has no candidates.", kind: :empty)
114
+ end
115
+
116
+ def failure(response)
117
+ body = response.body.to_s
118
+ kind = classify(response.status, body)
119
+ ProviderError.new("HTTP #{response.status}: #{api_message(body)}", kind:,
120
+ daily: daily?(kind, body))
121
+ end
122
+
123
+ def classify(status, body)
124
+ return :overloaded if OVERLOADED.include?(status)
125
+ return KINDS[status] if KINDS.key?(status)
126
+ return :other unless status == 400
127
+
128
+ body.match?(/API key not valid/i) ? :auth : :bad_request
129
+ end
130
+
131
+ # A limit per day is not worth waiting for. A limit per minute passes.
132
+ def daily?(kind, body) = kind == :rate_limited && body.include?("PerDay")
133
+
134
+ def api_message(body)
135
+ text = JSON.parse(body).dig("error", "message")
136
+ (text || body).to_s.strip[0, 200]
137
+ rescue JSON::ParserError, TypeError, NoMethodError
138
+ body.to_s.strip[0, 200]
139
+ end
140
+ end
141
+
142
+ register(Gemini)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Llm
6
+ # The providers that are installed. Each wrapper registers itself when it is loaded.
7
+ module Providers
8
+ @registry = {}
9
+
10
+ class << self
11
+ def register(provider)
12
+ @registry[provider.id] = provider
13
+ end
14
+
15
+ def ids = @registry.keys
16
+
17
+ # The provider named in the settings. Without a name, the only installed one.
18
+ def resolve(id)
19
+ name = id.to_s.strip.downcase
20
+ return only_provider if name.empty?
21
+
22
+ @registry.fetch(name) do
23
+ raise ConfigError, "Site: unknown provider #{name.inspect}. Available: #{ids.join(', ')}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def only_provider
30
+ return @registry.values.first if @registry.size == 1
31
+
32
+ raise ConfigError, "Site: set provider to one of: #{ids.join(', ')}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Llm
6
+ # What a provider returns for one call.
7
+ Completion = Data.define(:text, :finish, :input_tokens, :output_tokens, :thinking_tokens)
8
+
9
+ # What the client returns: the parsed JSON, which model answered, and what it cost.
10
+ Answer = Data.define(:data, :model, :finish, :input_tokens, :output_tokens, :thinking_tokens, :seconds)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Llm
6
+ # What model calls used, for the log. calls: anything with seconds and input, output and thinking tokens.
7
+ module Usage
8
+ module_function
9
+
10
+ def of(calls)
11
+ { model_calls: calls.size, input_tokens: calls.sum(&:input_tokens), output_tokens: calls.sum(&:output_tokens),
12
+ thinking_tokens: calls.sum(&:thinking_tokens), model_seconds: calls.sum(&:seconds).round(1) }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end