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