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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Analysis
8
+ # Addresses as the analysis compares them: absolute, without a fragment, and keyed so that
9
+ # https://www.example.com/a/ and https://example.com/a count as the same page.
10
+ module Links
11
+ SKIPPED = ["#", "mailto:", "tel:", "javascript:"].freeze
12
+
13
+ module_function
14
+
15
+ def absolute(base, href)
16
+ text = href.to_s.strip
17
+ return if text.empty? || text.start_with?(*SKIPPED)
18
+
19
+ uri = URI.join(base.to_s, text)
20
+ return unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
21
+
22
+ uri.fragment = nil
23
+ uri.to_s
24
+ rescue URI::Error
25
+ nil
26
+ end
27
+
28
+ def host(url)
29
+ URI.parse(url.to_s).host.to_s.downcase.delete_prefix("www.")
30
+ rescue URI::Error
31
+ ""
32
+ end
33
+
34
+ def same_site?(url, other) = !host(url).empty? && host(url) == host(other)
35
+
36
+ # The page an address stands for: host without www., path without a trailing slash, and the query.
37
+ def key(url)
38
+ uri = URI.parse(url.to_s)
39
+ path = uri.path.to_s.chomp("/")
40
+ query = uri.query ? "?#{uri.query}" : ""
41
+ "#{uri.host.to_s.downcase.delete_prefix('www.')}#{path.empty? ? '/' : path}#{query}"
42
+ rescue URI::Error
43
+ url.to_s
44
+ end
45
+
46
+ def segments(url)
47
+ URI.parse(url.to_s).path.to_s.split("/").reject(&:empty?)
48
+ rescue URI::Error
49
+ []
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # Whether the order of a list says which entries matter most.
7
+ module ListOrder
8
+ MIN_ENTRIES = 3
9
+
10
+ module_function
11
+
12
+ # mode: "editorial" (always), "neutral" (never) or "auto" (unless the list is in alphabetical order, which
13
+ # is how a list sorted by a machine rather than by a person usually looks).
14
+ def editorial?(list, mode)
15
+ case mode
16
+ when "editorial" then true
17
+ when "neutral" then false
18
+ else !alphabetical?(list)
19
+ end
20
+ end
21
+
22
+ def alphabetical?(list)
23
+ names = list.list_members.map { |entry| sortable(entry.name) }
24
+ names.size >= MIN_ENTRIES && names.none?(&:empty?) && names.each_cons(2).all? do |first, second|
25
+ first <= second
26
+ end
27
+ end
28
+
29
+ def sortable(name) = name.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase.strip
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # Says in a few words why a page ranks where it does.
7
+ module Reasons
8
+ TEMPLATES = {
9
+ home: "the homepage", navigation: "in the homepage's navigation", footer: "in the homepage's footer",
10
+ list: "%<rank>d. of %<size>d on %<from>s", cards: "card %<rank>d of %<size>d on %<from>s",
11
+ text: "linked in the text of %<from>s", address: "under %<from>s"
12
+ }.freeze
13
+ ON_HOMEPAGE = %i[home navigation].freeze
14
+ MIN_MENTIONS = 3
15
+ MAX = 3
16
+
17
+ module_function
18
+
19
+ # home: the key of the homepage, so a route that starts there is not described twice.
20
+ def of(item, home: nil) = [reached(item.path), *standing_out(item, home)].first(MAX)
21
+
22
+ def reached(path)
23
+ return "not reached from the homepage" unless path
24
+
25
+ format(TEMPLATES.fetch(path.kind), rank: path.rank.to_i, size: path.size.to_i, from: shown(path.from))
26
+ end
27
+
28
+ def standing_out(item, home)
29
+ notes = []
30
+ notes << "shown on the homepage" if shown_on_homepage?(item, home)
31
+ notes << "listed or linked on #{item.mentioned_by} pages" if item.mentioned_by >= MIN_MENTIONS
32
+ notes << "changed #{item.changed.iso8601}" if item.changed
33
+ notes
34
+ end
35
+
36
+ # Said only when the way the page is reached does not say it already.
37
+ def shown_on_homepage?(item, home)
38
+ return false unless item.values[:presence].to_f >= 1
39
+
40
+ path = item.path
41
+ !(path && (ON_HOMEPAGE.include?(path.kind) || (home && path.from == home)))
42
+ end
43
+
44
+ # A key such as "example.com/trips" as the path a person reads: "/trips".
45
+ def shown(key) = key.to_s.sub(%r{\A[^/]*}, "").then { |path| path.empty? ? "/" : path }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # Turns the result of an analysis into the lines the analyze command prints.
7
+ class Report
8
+ TOP = 15
9
+ BOTTOM = 5
10
+ MISSING = 10
11
+ DISAGREEMENTS = 5
12
+
13
+ def initialize(result)
14
+ @result = result
15
+ @ranking = result.ranking
16
+ end
17
+
18
+ def lines
19
+ [*summary, "", *usage_lines, *model_lines, *note_lines, "", "Most important:", *rows(top), *bottom_lines,
20
+ *missing_lines, "", footer]
21
+ end
22
+
23
+ private
24
+
25
+ def scores = @ranking.scores
26
+
27
+ def summary
28
+ tiers = scores.map(&:tier).tally.sort.map { |tier, count| "tier #{tier}: #{count}" }.join(", ")
29
+ ["#{'[dry run] ' if @result.dry_run}Analysed #{scores.size} pages, after reading #{@result.pages_read} " \
30
+ "of them.",
31
+ "The structure of the site leads to #{percent(@ranking.confidence)} of the pages, and the models' " \
32
+ "judgement makes up #{percent(@ranking.ai_share)} of each score.",
33
+ "Pages per tier: #{tiers}."]
34
+ end
35
+
36
+ def usage_lines
37
+ counted = @ranking.usage.map do |use|
38
+ note = use.note ? " (#{use.note})" : ""
39
+ " #{use.signal}: weight #{use.weight.round}, measured on #{percent(use.coverage)} of the pages#{note}"
40
+ end
41
+ ["How each measurement counted:", *counted]
42
+ end
43
+
44
+ def model_lines
45
+ judged = @result.judged
46
+ return [] unless judged
47
+
48
+ calls = judged.calls.map do |call|
49
+ " #{call.model}, #{call.seconds.round} s, tokens in/out/thinking " \
50
+ "#{call.input_tokens} / #{call.output_tokens} / #{call.thinking_tokens}"
51
+ end
52
+ ["", "The models were asked #{calls.size} time(s):", *calls, *disagreement_lines(judged),
53
+ *judged.problems.map { |problem| " - #{problem}" }]
54
+ end
55
+
56
+ def disagreement_lines(judged)
57
+ return [] if judged.disagreements.empty?
58
+
59
+ shown = judged.disagreements.first(DISAGREEMENTS).map do |view|
60
+ " #{Reasons.shown(view.key)} (#{(view.spread * 100).round} points apart)"
61
+ end
62
+ ["The two models disagree most about:", *shown]
63
+ end
64
+
65
+ def note_lines
66
+ return [] if @result.notes.empty?
67
+
68
+ ["", "Notes:", *@result.notes.map { |note| " - #{note}" }]
69
+ end
70
+
71
+ def top = scores.first(TOP).each_with_index.map { |score, index| [score, index + 1] }
72
+
73
+ def bottom_lines
74
+ count = [BOTTOM, scores.size - TOP].min
75
+ return [] unless count.positive?
76
+
77
+ shown = scores.last(count).each_with_index.map { |score, index| [score, scores.size - count + index + 1] }
78
+ ["", "Least important:", *rows(shown)]
79
+ end
80
+
81
+ def rows(list)
82
+ list.map do |score, place|
83
+ format(" %3<place>d. %3<score>d T%<tier>d %-45<path>s %<notes>s", place:, score: score.score,
84
+ tier: score.tier,
85
+ path: Reasons.shown(score.key),
86
+ notes: score.notes.join("; "))
87
+ end
88
+ end
89
+
90
+ def missing_lines
91
+ missing = @result.not_in_sheet
92
+ return [] if missing.empty?
93
+
94
+ more = missing.size > MISSING ? ", ..." : ""
95
+ ["", "#{missing.size} page(s) are not in the Pages tab, so nothing was written for them " \
96
+ "(run pages import): #{missing.first(MISSING).join(', ')}#{more}"]
97
+ end
98
+
99
+ def footer
100
+ return "Dry run: nothing was written to the sheet." if @result.dry_run
101
+
102
+ "Written: importance, tier and notes for #{@result.written} page(s) in the Pages tab, and the " \
103
+ "measurements of every page in the Analysis tab."
104
+ end
105
+
106
+ def percent(share) = "#{(share * 100).round}%"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Yuno
6
+ module Proofreader
7
+ module Analysis
8
+ # Runs the whole importance analysis: reads the structure of the site, asks the models, scores every page and
9
+ # writes the results to the store. Everything it needs is passed in, so it runs with fakes in tests.
10
+ class Runner
11
+ # judged: what the models answered (a Judge::Result), or nil when they were not asked. not_in_sheet: the
12
+ # pages that have a score but no row in the Pages tab. written: how many rows of Pages were written.
13
+ Result = Data.define(:ranking, :judged, :pages_read, :notes, :not_in_sheet, :written, :dry_run)
14
+
15
+ # client: the model client, needed only when the models are asked.
16
+ def initialize(site:, store:, http:, client: nil, weights: Weights.default, clock: -> { Time.now.utc },
17
+ progress: ->(_message) {}, log: Log.null)
18
+ @site = site
19
+ @store = store
20
+ @http = http
21
+ @client = client
22
+ @weights = weights
23
+ @clock = clock
24
+ @progress = progress
25
+ @log = log
26
+ @language = site.default_language
27
+ end
28
+
29
+ # judgement: :fresh (ask the models), :reuse (take their judgement from the last run, kept in the Analysis
30
+ # tab) or :none (the structure alone).
31
+ def run(judgement: :fresh, dry_run: false)
32
+ @notes = []
33
+ now = @clock.call
34
+ pairing = load_pairing
35
+ inventory, signals = measure(pairing, now)
36
+ views, judged = views_for(judgement, inventory, pairing)
37
+ records = @store.pages
38
+ ranking = Scorer.call(signals, @weights, views:, adjustments: adjustments(pairing, records))
39
+ written = dry_run ? 0 : write(ranking, signals, records, now)
40
+ result = Result.new(ranking:, judged:, pages_read: inventory.facts.size, notes: @notes,
41
+ not_in_sheet: not_in_sheet(ranking, records), written:, dry_run:)
42
+ log_result(result)
43
+ result
44
+ end
45
+
46
+ private
47
+
48
+ def settings = @site.analysis
49
+
50
+ def load_pairing
51
+ @progress.call("Reading the sitemap...")
52
+ pairing = Sitemap::Loader.call(@site, @http).pairing
53
+ return pairing if pairing.resolved?
54
+
55
+ raise SitemapError, "The pages could not be paired by language. Run the sitemap command to see why."
56
+ end
57
+
58
+ def measure(pairing, now)
59
+ inventory = Crawler.new(site: @site, pages: pairing.pages, fetcher: Pages::Fetcher.new(@site, @http),
60
+ extra_hubs: settings.hubs, max_pages: settings.max_pages, progress: @progress).call
61
+ unless inventory.unread.empty?
62
+ @notes << "#{inventory.unread.size} index page(s) were not read, because max_pages " \
63
+ "(#{settings.max_pages}) was reached."
64
+ end
65
+ signals = Signals.call(inventory:, pages: pairing.pages, weights: @weights, language: @language,
66
+ today: now.to_date, order: settings.hub_order)
67
+ [inventory, signals]
68
+ end
69
+
70
+ # ---- the models ----
71
+
72
+ def views_for(mode, inventory, pairing)
73
+ case mode
74
+ when :fresh then fresh_views(inventory, pairing)
75
+ when :reuse then [reused_views, nil]
76
+ else [{}, nil]
77
+ end
78
+ end
79
+
80
+ def fresh_views(inventory, pairing)
81
+ entries = Entries.call(inventory:, pages: pairing.pages, language: @language)
82
+ judged = Judge.new(site: @site, client: @client, weights: @weights, hint: settings.importance_hint,
83
+ progress: @progress).call(entries)
84
+ [judged.views, judged]
85
+ rescue LlmError => e
86
+ @notes << "The models could not be asked (#{e.message}). The scores come from the structure alone."
87
+ [{}, nil]
88
+ end
89
+
90
+ def reused_views
91
+ views = @store.analysis_rows.to_h { |row| view_from(row) }
92
+ return views if views.values.any?(&:value)
93
+
94
+ raise Error, "The Analysis tab holds no judgements from the models to reuse. " \
95
+ "Run analyze without --reuse-ai first."
96
+ end
97
+
98
+ def view_from(row)
99
+ key = Links.key(row[:address].to_s)
100
+ [key, AiView.new(key:, value: share(row[:ai_value]), kind: row[:ai_kind].to_s, place: row[:ai_place].to_s,
101
+ certainty: row[:ai_certainty].to_s, reason: row[:ai_reason].to_s,
102
+ spread: share(row[:ai_spread]))]
103
+ end
104
+
105
+ def share(value) = (value.nil? || value.to_s.strip.empty? ? nil : value.to_f / 100)
106
+
107
+ # ---- the sheet ----
108
+
109
+ def keyed(records) = records.to_h { |record| [Links.key(record.key(@language).to_s), record] }
110
+
111
+ def adjustments(pairing, records)
112
+ boosts = records.to_h { |record| [Links.key(record.key(@language).to_s), record.boost] }.compact
113
+ Adjustments.call(pages: pairing.pages, language: @language, settings:, boosts:)
114
+ end
115
+
116
+ def write(ranking, signals, records, now)
117
+ by_key = keyed(records)
118
+ updates = ranking.scores.each_with_object({}) do |score, found|
119
+ record = by_key[score.key]
120
+ found[record.row] = importance_of(score, now) if record
121
+ end
122
+ @store.record_importance(updates)
123
+ @store.replace_analysis(analysis_rows(ranking, signals, now))
124
+ updates.size
125
+ end
126
+
127
+ def importance_of(score, now)
128
+ { importance: score.score, tier: score.tier, importance_notes: score.notes.join("; "), analyzed_at: now }
129
+ end
130
+
131
+ def not_in_sheet(ranking, records)
132
+ known = keyed(records)
133
+ ranking.scores.reject { |score| known.key?(score.key) }.map { |score| Reasons.shown(score.key) }
134
+ end
135
+
136
+ def analysis_rows(ranking, signals, now)
137
+ measured = signals.to_h { |item| [item.key, item] }
138
+ ranking.scores.map { |score| analysis_row(score, measured[score.key], now) }
139
+ end
140
+
141
+ def analysis_row(score, item, now)
142
+ { page_name: score.page.name, address: score.page.urls[@language] || score.page.urls.values.first,
143
+ importance: score.score, tier: score.tier, changed: item&.changed&.iso8601.to_s,
144
+ route: Reasons.reached(item&.path), analyzed_at: now, **measurements(score), **judgement(score.ai) }
145
+ end
146
+
147
+ def measurements(score)
148
+ score.values.slice(:reach, :presence, :mentions, :freshness).transform_values { |value| value&.round(3) }
149
+ end
150
+
151
+ def judgement(view)
152
+ return {} unless view
153
+
154
+ { ai_value: view.value && (view.value * 100).round, ai_kind: view.kind.to_s, ai_place: view.place,
155
+ ai_certainty: view.certainty, ai_reason: view.reason, ai_spread: view.spread && (view.spread * 100).round }
156
+ end
157
+
158
+ def log_result(result)
159
+ @notes.each { |note| @log.warn("analysis note", message: note) }
160
+ ranking = result.ranking
161
+ @log.info("analysis finished", pages: ranking.scores.size, pages_read: result.pages_read,
162
+ written: result.written, dry_run: result.dry_run,
163
+ not_in_sheet: result.not_in_sheet.size,
164
+ structure_confidence: ranking.confidence.round(2),
165
+ ai_share: ranking.ai_share.round(2),
166
+ **Llm::Usage.of(Array(result.judged&.calls)))
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # The importance of one page: a score from 0 to 100, its tier (1 is the most important), the reasons, and the
7
+ # model's view of it when there is one.
8
+ Score = Data.define(:key, :page, :score, :tier, :values, :notes, :ai)
9
+
10
+ # How much a signal counted: its weight in weights.yml, the share of pages it was measured on, and the weight
11
+ # it ended with. note says why a signal counted nothing.
12
+ Usage = Data.define(:signal, :weight, :coverage, :effective, :note)
13
+
14
+ # The pages from the most to the least important. confidence is the share of pages that the navigation or the
15
+ # lists of the site lead to, and ai_share the part of each score the model's judgement made up.
16
+ Ranking = Data.define(:scores, :usage, :confidence, :ai_share)
17
+
18
+ # Combines the measurements of every page, and the model's judgement when there is one, into a score and a
19
+ # tier. A signal counts by the share of pages it was measured on, a page without a value gets the middle value
20
+ # of the others, and a signal that is the same on every page counts nothing. The model's share grows as the
21
+ # structure of the site leads to fewer pages.
22
+ class Scorer
23
+ STRUCTURE = { home: 1.0, navigation: 1.0, footer: 1.0, list: 1.0, cards: 0.5 }.freeze
24
+ UNMEASURED = "could not be measured on any page"
25
+ NO_SPREAD = "the same on every page, so it says nothing"
26
+
27
+ # views: the model's views of the pages, keyed like the signals. Without them the score comes from the
28
+ # structure alone. adjustments: points a person added to pages, keyed the same way.
29
+ def self.call(signals, weights, views: {}, adjustments: {}) = new(signals, weights, views, adjustments).call
30
+
31
+ def initialize(signals, weights, views = {}, adjustments = {})
32
+ @signals = signals
33
+ @weights = weights
34
+ @ai = views
35
+ @adjustments = adjustments
36
+ end
37
+
38
+ def call
39
+ usage = @weights.signals.map { |signal, weight| usage_for(signal, weight) }
40
+ medians = usage.to_h { |use| [use.signal, median(known(use.signal))] }
41
+ valued = @signals.map { |item| [item, value_of(item, usage, medians)] }
42
+ Ranking.new(scores: ranked(valued), usage:, confidence:, ai_share:)
43
+ end
44
+
45
+ private
46
+
47
+ def known(signal) = @signals.filter_map { |item| item.values[signal] }
48
+
49
+ def usage_for(signal, weight)
50
+ values = known(signal)
51
+ coverage = @signals.empty? ? 0.0 : values.size.fdiv(@signals.size)
52
+ note = UNMEASURED if values.empty?
53
+ note ||= NO_SPREAD if values.any? && values.max - values.min < @weights.min_spread
54
+ Usage.new(signal:, weight:, coverage:, effective: note ? 0.0 : weight * coverage, note:)
55
+ end
56
+
57
+ def median(values)
58
+ sorted = values.sort
59
+ return 0.0 if sorted.empty?
60
+
61
+ middle = sorted.size / 2
62
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
63
+ end
64
+
65
+ def home_key = @home_key ||= @signals.find { |item| item.path&.kind == :home }&.key
66
+
67
+ # The homepage leads to every other page, so it always comes first and is never adjusted.
68
+ def value_of(item, usage, medians)
69
+ return 1.0 if item.key == home_key
70
+
71
+ points = @adjustments[item.key]&.points.to_f
72
+ (blended(item, usage, medians) + (points / 100.0)).clamp(0.0, 1.0)
73
+ end
74
+
75
+ def blended(item, usage, medians)
76
+ structural = combined(item, usage, medians)
77
+ return structural if ai_share.zero?
78
+
79
+ ((1 - ai_share) * structural) + (ai_share * (@ai[item.key]&.value || ai_median))
80
+ end
81
+
82
+ def combined(item, usage, medians)
83
+ total = usage.sum(&:effective)
84
+ return 0.0 if total.zero?
85
+
86
+ usage.sum { |use| use.effective * (item.values[use.signal] || medians[use.signal]) } / total
87
+ end
88
+
89
+ def ai_values = @ai_values ||= @ai.values.filter_map(&:value)
90
+
91
+ def ai_median = @ai_median ||= median(ai_values)
92
+
93
+ # ai_share_min when the structure leads to every page, growing to ai_share_max as it leads to fewer.
94
+ # Nothing when the model judged no page.
95
+ def ai_share
96
+ @ai_share ||= begin
97
+ low = @weights.ai_share_min
98
+ ai_values.empty? ? 0.0 : low + ((1 - confidence) * (@weights.ai_share_max - low))
99
+ end
100
+ end
101
+
102
+ # Highest value first. The values are not rounded here, so only pages that really are equal share a place
103
+ # and the better tier. The score shown is rounded.
104
+ def ranked(valued)
105
+ order = valued.each_with_index.sort_by { |(_item, value), index| [-value, index] }.map(&:first)
106
+ first_place = {}
107
+ order.each_with_index.map do |(item, value), index|
108
+ first_place[value] ||= index
109
+ score_for(item, value, tier_at(first_place[value], order.size))
110
+ end
111
+ end
112
+
113
+ def score_for(item, value, tier)
114
+ Score.new(key: item.key, page: item.page, score: (100 * value).round, tier:, values: item.values,
115
+ notes: Reasons.of(item, home: home_key) + Array(@adjustments[item.key]&.notes), ai: @ai[item.key])
116
+ end
117
+
118
+ def tier_at(index, count) = @weights.tiers.count { |share| index.fdiv(count) >= share } + 1
119
+
120
+ def confidence
121
+ return 0.0 if @signals.empty?
122
+
123
+ @confidence ||= @signals.sum { |item| STRUCTURE.fetch(item.path&.kind, 0.0) }.fdiv(@signals.size)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yuno
4
+ module Proofreader
5
+ module Analysis
6
+ # The analysis part of the settings file. Everything in it is optional.
7
+ # importance_hint what the model should know about the business, such as which offers matter most
8
+ # priority_paths points added to the score of the pages at or below a path, from -50 to 50
9
+ # hubs index pages to read even if nothing else leads to them
10
+ # hub_order auto, editorial or neutral: whether the order of the lists says what matters
11
+ # max_pages how many pages the crawl reads at most
12
+ class Settings < Data.define(:importance_hint, :priority_paths, :hubs, :hub_order, :max_pages)
13
+ KEYS = %w[importance_hint priority_paths hubs hub_order max_pages].freeze
14
+ ORDERS = %w[auto editorial neutral].freeze
15
+ MAX_POINTS = 50
16
+ PAGES = (1..500)
17
+ DEFAULT_PAGES = 80
18
+
19
+ def self.default
20
+ new(importance_hint: "", priority_paths: {}, hubs: [], hub_order: "auto", max_pages: DEFAULT_PAGES)
21
+ end
22
+
23
+ def self.from_hash(value)
24
+ return default if value.nil?
25
+ raise ConfigError, "Site: analysis must be a list of key: value pairs" unless value.is_a?(Hash)
26
+
27
+ raw = value.transform_keys(&:to_s)
28
+ unknown = raw.keys - KEYS
29
+ unless unknown.empty?
30
+ raise ConfigError, "Site: analysis: unknown setting(s): #{unknown.join(', ')}. Allowed: #{KEYS.join(', ')}"
31
+ end
32
+
33
+ new(importance_hint: Context.text(raw["importance_hint"], "analysis: importance_hint", Context::MAX_TEXT),
34
+ priority_paths: priority_paths(raw["priority_paths"]), hubs: hubs(raw["hubs"]),
35
+ hub_order: hub_order(raw["hub_order"]), max_pages: max_pages(raw["max_pages"]))
36
+ end
37
+
38
+ def self.priority_paths(value)
39
+ return {} if value.nil?
40
+ unless value.is_a?(Hash)
41
+ raise ConfigError, "Site: analysis: priority_paths must list paths with points, for example /trips: 10"
42
+ end
43
+
44
+ value.to_h { |path, points| [path_of(path), points_for(points, path)] }
45
+ end
46
+
47
+ def self.points_for(value, path)
48
+ number = Integer(value.to_s.strip, 10, exception: false)
49
+ return number if number&.between?(-MAX_POINTS, MAX_POINTS)
50
+
51
+ raise ConfigError, "Site: analysis: priority_paths #{path} needs whole points from -#{MAX_POINTS} to " \
52
+ "#{MAX_POINTS} (got #{value.inspect})"
53
+ end
54
+
55
+ def self.hubs(value)
56
+ items = Array(value)
57
+ raise ConfigError, "Site: analysis: hubs must be a list of paths, written as text" unless items.all?(String)
58
+
59
+ items.map { |item| path_of(item) }.reject { |path| path == "/" }.uniq
60
+ end
61
+
62
+ # A path as it appears in the address: with a leading slash and without a trailing one. "/" stays "/".
63
+ def self.path_of(value)
64
+ path = value.to_s.strip
65
+ path = "/#{path}" unless path.start_with?("/")
66
+ path.length > 1 ? path.chomp("/") : path
67
+ end
68
+
69
+ def self.hub_order(value)
70
+ return "auto" if value.nil?
71
+
72
+ order = value.to_s.strip.downcase
73
+ return order if ORDERS.include?(order)
74
+
75
+ raise ConfigError, "Site: analysis: hub_order must be one of #{ORDERS.join(', ')} (got #{value.inspect})"
76
+ end
77
+
78
+ def self.max_pages(value)
79
+ return DEFAULT_PAGES if value.nil?
80
+
81
+ number = Integer(value.to_s.strip, 10, exception: false)
82
+ return number if number && PAGES.cover?(number)
83
+
84
+ raise ConfigError, "Site: analysis: max_pages must be a whole number from 1 to #{PAGES.last}"
85
+ end
86
+ private_class_method :priority_paths, :points_for, :hubs, :path_of, :hub_order, :max_pages
87
+ end
88
+ end
89
+ end
90
+ end