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,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# Turns the result of an audit into the lines the audit command prints.
|
|
7
|
+
class Report
|
|
8
|
+
SHOWN = 30
|
|
9
|
+
TEXT = 60
|
|
10
|
+
KINDS = { added: "added", reappeared: "reappeared", seen_again: "seen again", closed: "closed",
|
|
11
|
+
suppressed: "suppressed", ignored: "ignored", duplicate: "duplicates",
|
|
12
|
+
close_skipped: "close skipped" }.freeze
|
|
13
|
+
QUIET = %i[seen_again suppressed ignored duplicate close_skipped].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(result, verbose: false)
|
|
16
|
+
@result = result
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def lines
|
|
21
|
+
@result.status == :skipped ? skipped_lines : audited_lines
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# ---- a page that has not changed ----
|
|
27
|
+
|
|
28
|
+
def skipped_lines
|
|
29
|
+
since = @result.skipped_since ? " since #{stamp(@result.skipped_since)}" : ""
|
|
30
|
+
["Skipped #{@result.page}: unchanged#{since}. No model was called.", "Use --force to audit it anyway.",
|
|
31
|
+
@result.dry_run ? "Dry run: nothing was written." : "Its last_audited date was updated."]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ---- an audit ----
|
|
35
|
+
|
|
36
|
+
def audited_lines
|
|
37
|
+
[headline, *call_lines, findings_line, "", summary_line, *verified_line, *new_lines, *closed_lines,
|
|
38
|
+
*quiet_lines, *discarded_lines, *note_lines, "", footer]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def headline
|
|
42
|
+
versions = @result.fetched.snapshots.map(&:language).join(", ")
|
|
43
|
+
"#{'[dry run] ' if @result.dry_run}Audited #{@result.page} (#{versions})"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call_lines
|
|
47
|
+
audit = @result.calls.find { |call| call.role == :audit }
|
|
48
|
+
review = @result.calls.find { |call| call.role == :review }
|
|
49
|
+
[" #{call_line('Audit: ', audit)}", " #{call_line('Review: ', review)}"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call_line(label, call)
|
|
53
|
+
return "#{label}not run" unless call
|
|
54
|
+
|
|
55
|
+
"#{label}#{call.model}, #{call.seconds.round} s, tokens in/out/thinking " \
|
|
56
|
+
"#{delimited(call.input_tokens)} / #{delimited(call.output_tokens)} / #{delimited(call.thinking_tokens)}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def findings_line
|
|
60
|
+
review = @result.review_count.nil? ? "no review" : "#{@result.review_count} from the review"
|
|
61
|
+
"Findings: #{@result.audit_count} from the audit, #{review}."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def summary_line
|
|
65
|
+
counts = @result.plan.counts
|
|
66
|
+
parts = KINDS.filter_map { |kind, label| "#{counts[kind]} #{label}" if counts[kind] }
|
|
67
|
+
"Suggestions: #{parts.empty? ? 'no changes' : parts.join(', ')}."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def verified_line
|
|
71
|
+
additions = @result.plan.additions
|
|
72
|
+
return [] if additions.empty?
|
|
73
|
+
|
|
74
|
+
tally = additions.map(&:verified).tally
|
|
75
|
+
["Of the #{additions.size} new suggestions, #{tally.fetch('yes', 0)} quote text that is on the page " \
|
|
76
|
+
"exactly, #{tally.fetch('loose', 0)} loosely and #{tally.fetch('no', 0)} not at all."]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def new_lines
|
|
80
|
+
additions = @result.plan.additions
|
|
81
|
+
return [] if additions.empty?
|
|
82
|
+
|
|
83
|
+
shown = @verbose ? additions : additions.first(SHOWN)
|
|
84
|
+
more = additions.size - shown.size
|
|
85
|
+
["", "New suggestions:", *shown.map { |record| addition_line(record) },
|
|
86
|
+
*(more.positive? ? [" ... and #{more} more (use --verbose)"] : [])]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def addition_line(record)
|
|
90
|
+
mark = record.reason.start_with?("[Reappeared") ? "!" : "+"
|
|
91
|
+
place = record.approximate_place.to_s.empty? ? "" : " (#{record.approximate_place})"
|
|
92
|
+
" #{mark} [#{record.found_by}] #{record.category}, #{record.language}: " \
|
|
93
|
+
"#{quoted(record.original)} -> #{quoted(record.suggested)}#{place}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def closed_lines
|
|
97
|
+
closed = decisions { |decision| decision.kind == :closed }
|
|
98
|
+
return [] if closed.empty?
|
|
99
|
+
|
|
100
|
+
["", "Closed:", *closed.map { |decision| " - #{decision.summary} (#{decision.reason})" }]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def quiet_lines
|
|
104
|
+
quiet = decisions { |decision| QUIET.include?(decision.kind) }
|
|
105
|
+
return [] if !@verbose || quiet.empty?
|
|
106
|
+
|
|
107
|
+
["", "Also decided:", *quiet.map { |d| " = #{d.kind.to_s.tr('_', ' ')}: #{d.summary} (#{d.reason})" }]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def discarded_lines
|
|
111
|
+
items = @result.discarded
|
|
112
|
+
return [] if items.empty?
|
|
113
|
+
unless @verbose
|
|
114
|
+
return ["",
|
|
115
|
+
"#{items.size} items in the model answers could not be used (--verbose lists them)."]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
["", "Items in the model answers that could not be used:", *items.map { |item| " - #{item}" }]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def note_lines
|
|
122
|
+
return [] if @result.notes.empty?
|
|
123
|
+
|
|
124
|
+
["", "Notes:", *@result.notes.map { |note| " - #{note}" }]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def footer
|
|
128
|
+
return "Dry run: nothing was written to the sheet." if @result.dry_run
|
|
129
|
+
if @result.complete
|
|
130
|
+
return "Written to the sheet. The audit is complete, so the page is skipped until it changes."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
"Written to the sheet. The audit was not complete, so the page's fingerprint was not recorded and " \
|
|
134
|
+
"it will be audited again."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ---- helpers ----
|
|
138
|
+
|
|
139
|
+
def decisions(&) = @result.plan.decisions.select(&)
|
|
140
|
+
|
|
141
|
+
def quoted(text)
|
|
142
|
+
flat = Findings::Text.collapse(text)
|
|
143
|
+
flat = "#{flat[0, TEXT - 1]}…" if flat.length > TEXT
|
|
144
|
+
%("#{flat}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def stamp(time) = time.strftime("%Y-%m-%d %H:%M")
|
|
148
|
+
|
|
149
|
+
def delimited(number) = number.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# Audits one page from start to finish: fetches it, asks the model, asks a second model for an independent
|
|
7
|
+
# review, merges the answers, compares them with the suggestions already in the store and writes the changes.
|
|
8
|
+
# Everything it needs is passed in, so it runs with fakes in tests. A page that has not changed since its
|
|
9
|
+
# last complete audit is skipped without calling a model.
|
|
10
|
+
class Runner
|
|
11
|
+
Result = Data.define(:status, :page, :fetched, :plan, :calls, :audit_count, :review_count, :missing_steps,
|
|
12
|
+
:discarded, :notes, :complete, :dry_run, :skipped_since)
|
|
13
|
+
|
|
14
|
+
def initialize(site:, store:, fetcher:, audit_client:, review_client:, prompt: Prompt.new(site),
|
|
15
|
+
clock: -> { Time.now.utc }, progress: ->(_message) {}, log: Log.null)
|
|
16
|
+
@site = site
|
|
17
|
+
@store = store
|
|
18
|
+
@fetcher = fetcher
|
|
19
|
+
@audit_client = audit_client
|
|
20
|
+
@review_client = review_client
|
|
21
|
+
@prompt = prompt
|
|
22
|
+
@clock = clock
|
|
23
|
+
@progress = progress
|
|
24
|
+
@log = log
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# query: a page name from the Pages tab, or any address of the page.
|
|
28
|
+
def run(query, dry_run: false, force: false)
|
|
29
|
+
record = find_record(query)
|
|
30
|
+
say("Fetching #{record.name} (#{record.urls.keys.join(', ')})...")
|
|
31
|
+
fetched = @fetcher.fetch(record)
|
|
32
|
+
raise Error, nothing_fetched(fetched) unless fetched.snapshots.any?(&:ok?)
|
|
33
|
+
|
|
34
|
+
now = @clock.call
|
|
35
|
+
return skip(record, fetched, now, dry_run) if unchanged?(record, fetched, force)
|
|
36
|
+
|
|
37
|
+
audit_page(record, fetched, now, dry_run)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def say(message) = @progress.call(message)
|
|
43
|
+
|
|
44
|
+
def find_record(query)
|
|
45
|
+
records = @store.pages
|
|
46
|
+
raise Error, "The Pages tab is empty. Run: yuno-proofreader pages import" if records.empty?
|
|
47
|
+
|
|
48
|
+
wanted = query.to_s.strip
|
|
49
|
+
records.find { |record| record.name == wanted || record.urls.value?(wanted) } ||
|
|
50
|
+
raise(Error, "No page #{wanted.inspect} in the Pages tab. Page names are in its first column.")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def nothing_fetched(fetched)
|
|
54
|
+
problems = fetched.snapshots.map { |snapshot| "#{snapshot.language}: #{snapshot.problem}" }.join("; ")
|
|
55
|
+
"None of the language versions of #{fetched.name} could be fetched (#{problems}). " \
|
|
56
|
+
"Nothing was sent to the model and nothing was written."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ---- a page that has not changed ----
|
|
60
|
+
|
|
61
|
+
def unchanged?(record, fetched, force)
|
|
62
|
+
current = fetched.fingerprint
|
|
63
|
+
known = record.fingerprint.to_s.strip
|
|
64
|
+
!force && !current.nil? && !known.empty? && current == known
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The date is updated, so an unchanged page moves to the back of the queue. The fingerprint stays.
|
|
68
|
+
def skip(record, fetched, now, dry_run)
|
|
69
|
+
@store.record_audit(record, fingerprint: record.fingerprint.to_s.strip, at: now) unless dry_run
|
|
70
|
+
Result.new(status: :skipped, page: record.name, fetched:, plan: nil, calls: [], audit_count: 0,
|
|
71
|
+
review_count: 0, missing_steps: [], discarded: [], notes: [], complete: true, dry_run:,
|
|
72
|
+
skipped_since: record.last_audited)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ---- an audit ----
|
|
76
|
+
|
|
77
|
+
def audit_page(record, fetched, now, dry_run)
|
|
78
|
+
attempt = attempt_for(fetched)
|
|
79
|
+
plan = plan_for(record, fetched, attempt, now)
|
|
80
|
+
complete = complete?(fetched, attempt)
|
|
81
|
+
write(record, fetched, plan, complete, now) unless dry_run
|
|
82
|
+
audited_result(record:, fetched:, plan:, attempt:, complete:, dry_run:)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def attempt_for(fetched)
|
|
86
|
+
user = @prompt.user_message(fetched)
|
|
87
|
+
first = ask(:audit, @audit_client, @prompt.audit_system, user)
|
|
88
|
+
review = try_review(user, first.call.model)
|
|
89
|
+
audit_read = Answers.audit(first.answer.data, steps: @prompt.steps, languages: @site.languages)
|
|
90
|
+
Attempt.new(first:, review:, audit_read:, review_read: read_review(review))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ask(role, client, system, user, avoid: [])
|
|
94
|
+
say("Asking the #{role} model (this can take a minute or two)...")
|
|
95
|
+
answer = client.generate(system:, user:, avoid:)
|
|
96
|
+
say("The #{role} model answered: #{answer.model}, #{answer.seconds.round} s.")
|
|
97
|
+
Asked.new(answer:, call: Call.new(role:, model: answer.model, seconds: answer.seconds,
|
|
98
|
+
input_tokens: answer.input_tokens, output_tokens: answer.output_tokens,
|
|
99
|
+
thinking_tokens: answer.thinking_tokens))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The review asks a model other than the audit's, when there is one. If it fails, the audit stands alone.
|
|
103
|
+
def try_review(user, audit_model)
|
|
104
|
+
asked = ask(:review, @review_client, @prompt.review_system, user, avoid: [audit_model])
|
|
105
|
+
Review.new(asked:, error: nil)
|
|
106
|
+
rescue LlmError => e
|
|
107
|
+
say("The second review failed: #{e.message}")
|
|
108
|
+
@log.warn("second review failed", message: e.message)
|
|
109
|
+
Review.new(asked: nil, error: e.message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def read_review(review)
|
|
113
|
+
data = review.asked&.answer&.data
|
|
114
|
+
return unless data.is_a?(Hash) && data["issues"].is_a?(Array)
|
|
115
|
+
|
|
116
|
+
Answers.review(data, languages: @site.languages)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plan_for(record, fetched, attempt, now)
|
|
120
|
+
merged = Findings::Merger.call(attempt.audit_read.findings, attempt.review_read&.findings)
|
|
121
|
+
Findings::Reconciler.call(page: fetched, findings: merged, existing: @store.suggestions(record.name),
|
|
122
|
+
ignore_terms: @site.context.ignore_terms, now:)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Only a complete audit records the page's fingerprint, because that is what lets the next run skip it.
|
|
126
|
+
def complete?(fetched, attempt)
|
|
127
|
+
fetched.ok? && fetched.snapshots.none?(&:truncated) && attempt.missing_steps.empty? && attempt.reviewed?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def write(record, fetched, plan, complete, now)
|
|
131
|
+
plan.apply(@store)
|
|
132
|
+
@store.record_audit(record, fingerprint: complete ? fetched.fingerprint : "", at: now)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def audited_result(record:, fetched:, plan:, attempt:, complete:, dry_run:)
|
|
136
|
+
Result.new(status: :audited, page: record.name, fetched:, plan:, calls: attempt.calls,
|
|
137
|
+
audit_count: attempt.audit_read.findings.size, review_count: attempt.review_read&.findings&.size,
|
|
138
|
+
missing_steps: attempt.missing_steps, discarded: attempt.discarded,
|
|
139
|
+
notes: attempt.notes(fetched), complete:, dry_run:, skipped_since: nil)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Audit
|
|
8
|
+
# One check of the step-by-step audit: what to look for, and which categories its findings may have.
|
|
9
|
+
Step = Data.define(:number, :name, :categories, :text)
|
|
10
|
+
|
|
11
|
+
# The steps of the audit, read from prompts/steps.yml.
|
|
12
|
+
module Steps
|
|
13
|
+
PATH = File.join(__dir__, "prompts", "steps.yml")
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def default = @default ||= load
|
|
18
|
+
|
|
19
|
+
def load(path = PATH)
|
|
20
|
+
entries = YAML.safe_load_file(path)
|
|
21
|
+
raise Error, "#{path} must hold a list of steps" unless entries.is_a?(Array) && !entries.empty?
|
|
22
|
+
|
|
23
|
+
steps = entries.each_with_index.map { |entry, index| build(entry, index + 1, path) }
|
|
24
|
+
numbers = steps.map(&:number)
|
|
25
|
+
raise Error, "#{path}: step numbers must be unique" unless numbers.uniq.size == numbers.size
|
|
26
|
+
|
|
27
|
+
steps.sort_by(&:number)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(entry, position, path)
|
|
31
|
+
raise Error, "#{path}: step #{position} must be a mapping" unless entry.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
number = Integer(entry["number"], exception: false)
|
|
34
|
+
name = entry["name"].to_s.strip
|
|
35
|
+
text = entry["text"].to_s.strip
|
|
36
|
+
categories = Array(entry["categories"]).map { |category| category.to_s.strip.downcase }.reject(&:empty?).uniq
|
|
37
|
+
problem = problem_with(number, name, text, categories)
|
|
38
|
+
raise Error, "#{path}: step #{position} #{problem}" if problem
|
|
39
|
+
|
|
40
|
+
Step.new(number:, name:, categories:, text:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def problem_with(number, name, text, categories)
|
|
44
|
+
return "needs a number from 1" unless number&.positive?
|
|
45
|
+
return "needs a name" if name.empty?
|
|
46
|
+
return "needs a text" if text.empty?
|
|
47
|
+
return "needs at least one category" if categories.empty?
|
|
48
|
+
|
|
49
|
+
unknown = categories - Findings::Finding::CATEGORIES
|
|
50
|
+
"has unknown categories: #{unknown.join(', ')}" unless unknown.empty?
|
|
51
|
+
end
|
|
52
|
+
private_class_method :build, :problem_with
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
# The command that ranks the pages of the site by importance: analyze.
|
|
6
|
+
module AnalysisCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def analyze(args)
|
|
10
|
+
options, rest, unknown = split_args(args, %w[dry-run no-ai reuse-ai])
|
|
11
|
+
problem = analyze_args_problem(options, rest, unknown)
|
|
12
|
+
return usage_error(problem) if problem
|
|
13
|
+
|
|
14
|
+
mode = ai_mode(options)
|
|
15
|
+
site = Site.load(rest.first || CLI::DEFAULT_SITE_FILE)
|
|
16
|
+
site.ready_for_audit! if mode == :fresh
|
|
17
|
+
result = analysis_runner(site, mode).run(judgement: mode, dry_run: options.key?("dry-run"))
|
|
18
|
+
Analysis::Report.new(result).lines.each { |line| @out.puts line }
|
|
19
|
+
0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def analyze_args_problem(options, rest, unknown)
|
|
23
|
+
return "Unknown option: #{unknown.first}" unless unknown.empty?
|
|
24
|
+
return "--no-ai and --reuse-ai cannot be used together" if options.key?("no-ai") && options.key?("reuse-ai")
|
|
25
|
+
|
|
26
|
+
"Too many arguments for analyze" if rest.size > 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ai_mode(options)
|
|
30
|
+
return :none if options.key?("no-ai")
|
|
31
|
+
|
|
32
|
+
options.key?("reuse-ai") ? :reuse : :fresh
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The model client is built first, so a missing API key stops the command before anything is read.
|
|
36
|
+
def analysis_runner(site, mode)
|
|
37
|
+
client = mode == :fresh ? Llm.client_for(site, env: @env, transport: @llm_transport, log:) : nil
|
|
38
|
+
Analysis::Runner.new(site:, store: store_for(site), http: http_client, client:, log:,
|
|
39
|
+
progress: ->(message) { @out.puts message })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
# The commands that work with the prompts and the audit: prompt and audit. The queue and the batch form of
|
|
6
|
+
# audit (audit --next) are in QueueCommands.
|
|
7
|
+
module AuditCommands
|
|
8
|
+
DISPLAY_LIMIT = 300
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def prompt(args)
|
|
13
|
+
options, rest, unknown = split_args(args, %w[review full])
|
|
14
|
+
problem = prompt_args_problem(rest, unknown)
|
|
15
|
+
return usage_error(problem) if problem
|
|
16
|
+
|
|
17
|
+
site = Site.load(rest[1] || CLI::DEFAULT_SITE_FILE).ready_for_audit!
|
|
18
|
+
page = find_page(Sitemap::Loader.call(site, http_client).pairing, rest.first)
|
|
19
|
+
fetched = Pages::Fetcher.new(site, http_client).fetch(page)
|
|
20
|
+
prompt_lines(Audit::Prompt.new(site), fetched, options).each { |line| @out.puts line }
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def prompt_args_problem(rest, unknown)
|
|
25
|
+
return "Unknown option: #{unknown.first}" unless unknown.empty?
|
|
26
|
+
return "prompt needs a page name or address" if rest.empty?
|
|
27
|
+
|
|
28
|
+
"Too many arguments for prompt" if rest.size > 2
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def prompt_lines(builder, fetched, options)
|
|
32
|
+
kind = options.key?("review") ? "review" : "audit"
|
|
33
|
+
system = kind == "review" ? builder.review_system : builder.audit_system
|
|
34
|
+
message = builder.user_message(fetched)
|
|
35
|
+
shown = options.key?("full") ? message : builder.user_message(fetched, html_limit: DISPLAY_LIMIT)
|
|
36
|
+
[*prompt_summary(kind, fetched, system, message), "", "--- system prompt (#{kind}) ---", system, "",
|
|
37
|
+
"--- user message ---", shown]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def prompt_summary(kind, fetched, system, message)
|
|
41
|
+
languages = fetched.snapshots.select(&:ok?).map(&:language).join(", ")
|
|
42
|
+
tokens = ((system.length + message.length) / 4.0).round
|
|
43
|
+
[%(#{kind.capitalize} prompt for page "#{fetched.name}" (versions: #{languages})),
|
|
44
|
+
"System prompt: #{count(system.length)} characters. User message: #{count(message.length)} characters.",
|
|
45
|
+
"Roughly #{count(tokens)} tokens in all (a very rough estimate of 4 characters per token).",
|
|
46
|
+
"Nothing was sent to any model."]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def count(number) = number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
50
|
+
|
|
51
|
+
def audit(args)
|
|
52
|
+
count, remaining, problem = take_count(args)
|
|
53
|
+
return usage_error(problem) if problem
|
|
54
|
+
|
|
55
|
+
options, rest, unknown = split_args(remaining, %w[dry-run force verbose next include-not-due])
|
|
56
|
+
problem = audit_args_problem(options, rest, unknown, count)
|
|
57
|
+
return usage_error(problem) if problem
|
|
58
|
+
|
|
59
|
+
batch = options.key?("next")
|
|
60
|
+
site = Site.load(rest[batch ? 0 : 1] || CLI::DEFAULT_SITE_FILE).ready_for_audit!
|
|
61
|
+
batch ? audit_batch(site, options, count) : audit_page(site, rest.first, options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def audit_page(site, page, options)
|
|
65
|
+
result = audit_runner(site).run(page, dry_run: options.key?("dry-run"), force: options.key?("force"))
|
|
66
|
+
Audit::LogFields.record(log, result)
|
|
67
|
+
Audit::Report.new(result, verbose: options.key?("verbose")).lines.each { |line| @out.puts line }
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def audit_args_problem(options, rest, unknown, count)
|
|
72
|
+
return "Unknown option: #{unknown.first}" unless unknown.empty?
|
|
73
|
+
return batch_args_problem(rest) if options.key?("next")
|
|
74
|
+
return "--count and --include-not-due only go with --next" if count || options.key?("include-not-due")
|
|
75
|
+
return "audit needs a page name or address, or --next" if rest.empty?
|
|
76
|
+
|
|
77
|
+
"Too many arguments for audit" if rest.size > 2
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def batch_args_problem(rest)
|
|
81
|
+
"audit --next takes no page name, only a settings file" if rest.size > 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def audit_runner(site, store = store_for(site))
|
|
85
|
+
audit_client = Llm.client_for(site, env: @env, transport: @llm_transport, log:)
|
|
86
|
+
review_client = Llm.client_for(site, env: @env, review: true, transport: @llm_transport, log:)
|
|
87
|
+
Audit::Runner.new(site:, store:, fetcher: Pages::Fetcher.new(site, http_client), audit_client:,
|
|
88
|
+
review_client:, progress: ->(message) { @out.puts message }, log:)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
# Puts a line in the log when a command starts and when it ends, and makes the log available to the commands.
|
|
6
|
+
# It wraps CLI#run, so every command is covered without each one doing it. A run that fails always ends with
|
|
7
|
+
# an error line, which is what an alert on the log can look for.
|
|
8
|
+
module CommandLog
|
|
9
|
+
def run(argv)
|
|
10
|
+
begin
|
|
11
|
+
log
|
|
12
|
+
rescue ConfigError => e
|
|
13
|
+
@err.puts "Error: #{e.message}"
|
|
14
|
+
return 1
|
|
15
|
+
end
|
|
16
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
log.info("run started", command: argv.first, args: argv.drop(1).join(" "), version: VERSION)
|
|
18
|
+
finish(super, started)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
log.error("run failed", error: e.class.name, message: e.message, seconds: elapsed(started))
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Reads LOG_LEVEL and LOG_FORMAT the first time it is needed.
|
|
27
|
+
def log = @log ||= Log.from_env(@env, @err)
|
|
28
|
+
|
|
29
|
+
def finish(code, started)
|
|
30
|
+
seconds = elapsed(started)
|
|
31
|
+
if code.zero?
|
|
32
|
+
log.info("run finished", result: "ok", exit_code: code, seconds:)
|
|
33
|
+
else
|
|
34
|
+
log.error("run failed", result: "failed", exit_code: code, seconds:)
|
|
35
|
+
end
|
|
36
|
+
code
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def elapsed(started) = started ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1) : 0.0
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
# Reads the options of a command: switches (--yes), single values (--website ADDRESS or --website=ADDRESS) and
|
|
6
|
+
# values that may be given several times (--language de --language es:/es).
|
|
7
|
+
module Flags
|
|
8
|
+
Parsed = Data.define(:switches, :values, :lists, :rest, :problem)
|
|
9
|
+
KINDS = { switches: :switch, values: :value, lists: :list }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def parse(args, switches: [], values: [], lists: [])
|
|
14
|
+
kinds = { switches:, values:, lists: }.flat_map do |group, names|
|
|
15
|
+
names.map do |name|
|
|
16
|
+
[name, KINDS.fetch(group)]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
state = { switches: {}, values: {}, lists: lists.to_h { |name| [name, []] }, rest: [], problem: nil }
|
|
20
|
+
queue = args.dup
|
|
21
|
+
consume(queue.shift, queue, state, kinds.to_h) until queue.empty? || state[:problem]
|
|
22
|
+
Parsed.new(**state)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def consume(arg, queue, state, kinds)
|
|
26
|
+
return state[:rest] << arg unless arg.start_with?("--")
|
|
27
|
+
|
|
28
|
+
name, inline = arg.delete_prefix("--").split("=", 2)
|
|
29
|
+
kind = kinds[name]
|
|
30
|
+
return state[:problem] = "Unknown option: --#{name}" unless kind
|
|
31
|
+
|
|
32
|
+
kind == :switch ? take_switch(name, inline, state) : take_value(name, kind, inline, queue, state)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def take_switch(name, inline, state)
|
|
36
|
+
state[:problem] = "--#{name} takes no value" if inline
|
|
37
|
+
state[:switches][name] = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def take_value(name, kind, inline, queue, state)
|
|
41
|
+
value = inline || (queue.shift unless queue.first.to_s.start_with?("--"))
|
|
42
|
+
return state[:problem] = "--#{name} needs a value" if value.nil?
|
|
43
|
+
|
|
44
|
+
kind == :list ? state[:lists][name] << value : state[:values][name] = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|