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,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