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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +435 -0
- data/exe/yuno-proofreader +16 -0
- data/lib/yuno/proofreader/analysis/adjustments.rb +45 -0
- data/lib/yuno/proofreader/analysis/crawler.rb +95 -0
- data/lib/yuno/proofreader/analysis/entries.rb +73 -0
- data/lib/yuno/proofreader/analysis/extractor.rb +209 -0
- data/lib/yuno/proofreader/analysis/graph.rb +115 -0
- data/lib/yuno/proofreader/analysis/judge.rb +103 -0
- data/lib/yuno/proofreader/analysis/judge.yml +27 -0
- data/lib/yuno/proofreader/analysis/judge_prompt.rb +72 -0
- data/lib/yuno/proofreader/analysis/judgments.rb +70 -0
- data/lib/yuno/proofreader/analysis/links.rb +54 -0
- data/lib/yuno/proofreader/analysis/list_order.rb +33 -0
- data/lib/yuno/proofreader/analysis/reasons.rb +49 -0
- data/lib/yuno/proofreader/analysis/report.rb +110 -0
- data/lib/yuno/proofreader/analysis/runner.rb +171 -0
- data/lib/yuno/proofreader/analysis/scorer.rb +128 -0
- data/lib/yuno/proofreader/analysis/settings.rb +90 -0
- data/lib/yuno/proofreader/analysis/signals.rb +102 -0
- data/lib/yuno/proofreader/analysis/structured_data.rb +57 -0
- data/lib/yuno/proofreader/analysis/url_tree.rb +49 -0
- data/lib/yuno/proofreader/analysis/weights.rb +77 -0
- data/lib/yuno/proofreader/analysis/weights.yml +39 -0
- data/lib/yuno/proofreader/audit/agenda.rb +86 -0
- data/lib/yuno/proofreader/audit/agenda_report.rb +65 -0
- data/lib/yuno/proofreader/audit/answers.rb +74 -0
- data/lib/yuno/proofreader/audit/attempt.rb +69 -0
- data/lib/yuno/proofreader/audit/batch.rb +64 -0
- data/lib/yuno/proofreader/audit/batch_report.rb +87 -0
- data/lib/yuno/proofreader/audit/cadence.rb +67 -0
- data/lib/yuno/proofreader/audit/instructions.rb +30 -0
- data/lib/yuno/proofreader/audit/log_fields.rb +28 -0
- data/lib/yuno/proofreader/audit/prompt.rb +157 -0
- data/lib/yuno/proofreader/audit/prompts/instructions.yml +46 -0
- data/lib/yuno/proofreader/audit/prompts/steps.yml +67 -0
- data/lib/yuno/proofreader/audit/report.rb +153 -0
- data/lib/yuno/proofreader/audit/runner.rb +144 -0
- data/lib/yuno/proofreader/audit/steps.rb +56 -0
- data/lib/yuno/proofreader/cli/analysis_commands.rb +43 -0
- data/lib/yuno/proofreader/cli/audit_commands.rb +92 -0
- data/lib/yuno/proofreader/cli/command_log.rb +42 -0
- data/lib/yuno/proofreader/cli/flags.rb +48 -0
- data/lib/yuno/proofreader/cli/init_command.rb +116 -0
- data/lib/yuno/proofreader/cli/model_commands.rb +34 -0
- data/lib/yuno/proofreader/cli/queue_commands.rb +89 -0
- data/lib/yuno/proofreader/cli/sheet_commands.rb +57 -0
- data/lib/yuno/proofreader/cli.rb +218 -0
- data/lib/yuno/proofreader/config.rb +25 -0
- data/lib/yuno/proofreader/context.rb +55 -0
- data/lib/yuno/proofreader/env_file.rb +76 -0
- data/lib/yuno/proofreader/errors.rb +17 -0
- data/lib/yuno/proofreader/findings/finding.rb +95 -0
- data/lib/yuno/proofreader/findings/matching.rb +34 -0
- data/lib/yuno/proofreader/findings/merger.rb +43 -0
- data/lib/yuno/proofreader/findings/outline.rb +73 -0
- data/lib/yuno/proofreader/findings/plan.rb +24 -0
- data/lib/yuno/proofreader/findings/reconciler.rb +219 -0
- data/lib/yuno/proofreader/findings/source.rb +79 -0
- data/lib/yuno/proofreader/findings/text.rb +37 -0
- data/lib/yuno/proofreader/http/client.rb +131 -0
- data/lib/yuno/proofreader/http/net_http_transport.rb +55 -0
- data/lib/yuno/proofreader/http/robots.rb +72 -0
- data/lib/yuno/proofreader/init/ask.rb +67 -0
- data/lib/yuno/proofreader/init/detector.rb +100 -0
- data/lib/yuno/proofreader/init/interview.rb +210 -0
- data/lib/yuno/proofreader/init/parsing.rb +47 -0
- data/lib/yuno/proofreader/init/web.rb +33 -0
- data/lib/yuno/proofreader/llm/client.rb +140 -0
- data/lib/yuno/proofreader/llm/defaults.rb +22 -0
- data/lib/yuno/proofreader/llm/defaults.yml +6 -0
- data/lib/yuno/proofreader/llm/errors.rb +26 -0
- data/lib/yuno/proofreader/llm/factory.rb +45 -0
- data/lib/yuno/proofreader/llm/http_transport.rb +41 -0
- data/lib/yuno/proofreader/llm/json_text.rb +23 -0
- data/lib/yuno/proofreader/llm/model_commands.rb +34 -0
- data/lib/yuno/proofreader/llm/provider.rb +58 -0
- data/lib/yuno/proofreader/llm/providers/gemini.rb +146 -0
- data/lib/yuno/proofreader/llm/providers.rb +38 -0
- data/lib/yuno/proofreader/llm/types.rb +13 -0
- data/lib/yuno/proofreader/llm/usage.rb +17 -0
- data/lib/yuno/proofreader/log.rb +129 -0
- data/lib/yuno/proofreader/pages/fetcher.rb +75 -0
- data/lib/yuno/proofreader/pages/report.rb +45 -0
- data/lib/yuno/proofreader/pages/snapshot.rb +48 -0
- data/lib/yuno/proofreader/pages/trimmer.rb +36 -0
- data/lib/yuno/proofreader/settings_file.rb +51 -0
- data/lib/yuno/proofreader/site.rb +74 -0
- data/lib/yuno/proofreader/site_reader.rb +131 -0
- data/lib/yuno/proofreader/sitemap/aligner.rb +78 -0
- data/lib/yuno/proofreader/sitemap/importer.rb +31 -0
- data/lib/yuno/proofreader/sitemap/loader.rb +127 -0
- data/lib/yuno/proofreader/sitemap/mismatch.rb +76 -0
- data/lib/yuno/proofreader/sitemap/pairer.rb +223 -0
- data/lib/yuno/proofreader/sitemap/parser.rb +73 -0
- data/lib/yuno/proofreader/sitemap/report.rb +80 -0
- data/lib/yuno/proofreader/store/cells.rb +67 -0
- data/lib/yuno/proofreader/store/google_gateway.rb +198 -0
- data/lib/yuno/proofreader/store/memory.rb +90 -0
- data/lib/yuno/proofreader/store/records.rb +28 -0
- data/lib/yuno/proofreader/store/schema.rb +109 -0
- data/lib/yuno/proofreader/store/sheets.rb +183 -0
- data/lib/yuno/proofreader/store/tab_setup.rb +74 -0
- data/lib/yuno/proofreader/version.rb +7 -0
- data/lib/yuno/proofreader.rb +101 -0
- data/proofreader.example.yml +134 -0
- 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
|