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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Analysis
|
|
8
|
+
# The measurements of one page. values holds each signal from 0 to 1, or nil when it could not be measured.
|
|
9
|
+
# mentioned_by is the number of pages that list or link it, and changed the date it last changed, if known.
|
|
10
|
+
PageSignals = Data.define(:key, :page, :path, :values, :mentioned_by, :changed)
|
|
11
|
+
|
|
12
|
+
# Measures every page of the pairing: how well the homepage reaches it, whether the homepage shows it, how many
|
|
13
|
+
# pages list or link it, and how recently it changed.
|
|
14
|
+
class Signals
|
|
15
|
+
FOOTER_PRESENCE = 0.4
|
|
16
|
+
|
|
17
|
+
def self.call(**inputs) = new(**inputs).call
|
|
18
|
+
|
|
19
|
+
def initialize(inventory:, pages:, weights:, language:, today: Date.today, order: "editorial")
|
|
20
|
+
@inventory = inventory
|
|
21
|
+
@pages = pages
|
|
22
|
+
@language = language
|
|
23
|
+
@today = today
|
|
24
|
+
@half_life = weights.half_life
|
|
25
|
+
@graph = Graph.new(inventory, weights, order:)
|
|
26
|
+
@home = Links.key(inventory.home)
|
|
27
|
+
@shown, @footer = homepage_keys
|
|
28
|
+
@mentions = count_mentions
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
counts = @pages.map { |page| @mentions.fetch(Links.key(url_of(page)), 0) }
|
|
33
|
+
ranks = percentile_ranks(counts)
|
|
34
|
+
@pages.each_with_index.map { |page, index| measure(page, counts[index], ranks[index]) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def url_of(page) = page.urls[@language] || page.urls.values.first
|
|
40
|
+
|
|
41
|
+
def measure(page, count, rank)
|
|
42
|
+
url = url_of(page)
|
|
43
|
+
key = Links.key(url)
|
|
44
|
+
changed = changed_on(page, url)
|
|
45
|
+
values = { reach: @graph.paths[key]&.value || 0.0, presence: presence(key), mentions: rank,
|
|
46
|
+
freshness: changed && freshness(changed) }
|
|
47
|
+
PageSignals.new(key:, page:, path: @graph.paths[key], values:, mentioned_by: count, changed:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The pages the homepage shows in its navigation or lists, and the ones only its footer links.
|
|
51
|
+
def homepage_keys
|
|
52
|
+
facts = @inventory.facts_for(@inventory.home)
|
|
53
|
+
return [Set.new, Set.new] unless facts
|
|
54
|
+
|
|
55
|
+
links = facts.chrome_links
|
|
56
|
+
listed = facts.lists.flat_map { |list| list.list_members.map(&:url) }
|
|
57
|
+
shown = (links[:header] + links[:nav] + listed).to_set { |url| Links.key(url) }
|
|
58
|
+
[shown, links[:footer].to_set { |url| Links.key(url) }]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def presence(key)
|
|
62
|
+
return 1.0 if key == @home || @shown.include?(key)
|
|
63
|
+
|
|
64
|
+
@footer.include?(key) ? FOOTER_PRESENCE : 0.0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# How many of the pages read list a page or link to it in their text. Each page counts once.
|
|
68
|
+
def count_mentions
|
|
69
|
+
counts = Hash.new(0)
|
|
70
|
+
@inventory.facts.each_value do |facts|
|
|
71
|
+
listed = facts.lists.flat_map { |list| list.list_members.map(&:url) }
|
|
72
|
+
(listed + facts.main_links).map { |url| Links.key(url) }.uniq.each { |key| counts[key] += 1 }
|
|
73
|
+
end
|
|
74
|
+
counts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Where each value stands among the others, from 0 to 1. Equal values share the middle of their places.
|
|
78
|
+
def percentile_ranks(values)
|
|
79
|
+
values.map do |value|
|
|
80
|
+
below = values.count { |other| other < value }
|
|
81
|
+
(below + (0.5 * values.count(value))).fdiv(values.size)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def changed_on(page, url)
|
|
86
|
+
[page.lastmod, @inventory.facts_for(url)&.date_modified].filter_map { |value| date_of(value) }.max
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def date_of(value)
|
|
90
|
+
return value if value.is_a?(Date)
|
|
91
|
+
|
|
92
|
+
text = value.to_s.strip[/\A\d{4}-\d{2}-\d{2}/]
|
|
93
|
+
text && Date.iso8601(text)
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def freshness(date) = 0.5**([(@today - date).to_i, 0].max / @half_life)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Analysis
|
|
8
|
+
# Reads the JSON-LD blocks of a page. roots are the top-level objects (a block, or each entry of its @graph).
|
|
9
|
+
# nodes are every object with an @type, nested ones included, so a list is found wherever a site puts it:
|
|
10
|
+
# at the top, in an @graph, or under mainEntity or hasPart.
|
|
11
|
+
module StructuredData
|
|
12
|
+
SCHEMA = %r{\Ahttps?://schema\.org/}i
|
|
13
|
+
SELECTOR = 'script[type="application/ld+json"]'
|
|
14
|
+
Result = Data.define(:roots, :nodes, :problems)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def read(doc)
|
|
19
|
+
roots = []
|
|
20
|
+
problems = []
|
|
21
|
+
doc.css(SELECTOR).each_with_index do |script, index|
|
|
22
|
+
roots.concat(roots_of(JSON.parse(script.text)))
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
problems << "JSON-LD block #{index + 1} is not valid JSON"
|
|
25
|
+
end
|
|
26
|
+
Result.new(roots:, nodes: roots.flat_map { |root| typed_nodes(root) }, problems:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The types of a node, without the schema.org prefix.
|
|
30
|
+
def types(node)
|
|
31
|
+
return [] unless node.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
list = node["@type"].is_a?(Array) ? node["@type"] : [node["@type"]]
|
|
34
|
+
list.compact.map { |type| type.to_s.sub(SCHEMA, "") }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def type?(node, name) = types(node).include?(name)
|
|
38
|
+
|
|
39
|
+
def roots_of(data)
|
|
40
|
+
list = data.is_a?(Array) ? data : [data]
|
|
41
|
+
list.grep(Hash).flat_map { |item| item["@graph"].is_a?(Array) ? item["@graph"].grep(Hash) : [item] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The value and everything below it that has an @type, depth first.
|
|
45
|
+
def typed_nodes(value)
|
|
46
|
+
case value
|
|
47
|
+
when Array then value.flat_map { |item| typed_nodes(item) }
|
|
48
|
+
when Hash
|
|
49
|
+
own = value.key?("@type") ? [value] : []
|
|
50
|
+
own + value.values.flat_map { |child| typed_nodes(child) }
|
|
51
|
+
else []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Analysis
|
|
6
|
+
# The pages of the sitemap arranged by their addresses: /a/b is under /a when /a is a page too. Pages with
|
|
7
|
+
# several pages under them are likely index pages.
|
|
8
|
+
class UrlTree
|
|
9
|
+
MIN_CHILDREN = 2
|
|
10
|
+
|
|
11
|
+
attr_reader :urls
|
|
12
|
+
|
|
13
|
+
def initialize(urls)
|
|
14
|
+
@urls = urls.uniq { |url| Links.key(url) }
|
|
15
|
+
@by_key = @urls.to_h { |url| [Links.key(url), url] }
|
|
16
|
+
@children = Hash.new { |hash, key| hash[key] = [] }
|
|
17
|
+
@urls.each do |url|
|
|
18
|
+
above = parent(url)
|
|
19
|
+
@children[Links.key(above)] << url if above
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def include?(url) = @by_key.key?(Links.key(url))
|
|
24
|
+
|
|
25
|
+
def children(url) = @children.fetch(Links.key(url), [])
|
|
26
|
+
|
|
27
|
+
def depth(url) = Links.segments(url).size
|
|
28
|
+
|
|
29
|
+
# The nearest page above this one, or nil.
|
|
30
|
+
def parent(url)
|
|
31
|
+
parts = Links.segments(url)
|
|
32
|
+
host = Links.host(url)
|
|
33
|
+
until parts.empty?
|
|
34
|
+
parts.pop
|
|
35
|
+
found = @by_key["#{host}/#{parts.join('/')}"]
|
|
36
|
+
return found if found
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Pages with at least MIN_CHILDREN pages under them, the shallowest first.
|
|
42
|
+
def hubs
|
|
43
|
+
found = @urls.each_with_index.select { |url, _index| children(url).size >= MIN_CHILDREN }
|
|
44
|
+
found.sort_by { |url, index| [depth(url), index] }.map(&:first)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Yuno
|
|
6
|
+
module Proofreader
|
|
7
|
+
module Analysis
|
|
8
|
+
# How the importance of a page is weighed, read from weights.yml.
|
|
9
|
+
class Weights < Data.define(:signals, :edges, :half_life, :tiers, :min_spread, :ai_share_min, :ai_share_max,
|
|
10
|
+
:disagreement)
|
|
11
|
+
PATH = File.join(__dir__, "weights.yml")
|
|
12
|
+
SIGNALS = %w[reach presence mentions freshness].freeze
|
|
13
|
+
EDGES = %w[navigation footer list cards list_floor text address].freeze
|
|
14
|
+
|
|
15
|
+
def self.default = @default ||= load
|
|
16
|
+
|
|
17
|
+
def self.load(path = PATH)
|
|
18
|
+
data = YAML.safe_load_file(path)
|
|
19
|
+
raise Error, "#{path} must hold the weights" unless data.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
new(signals: numbers(data["signals"], SIGNALS, "signals", path),
|
|
22
|
+
edges: numbers(data["edges"], EDGES, "edges", path, max: 1.0),
|
|
23
|
+
half_life: half_life(data["freshness_half_life_days"], path),
|
|
24
|
+
tiers: tiers(data["tiers"], path),
|
|
25
|
+
min_spread: Float(data.fetch("min_spread", 0.01), exception: false) || 0.01,
|
|
26
|
+
**ai_settings(data, path))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.numbers(value, keys, label, path, max: nil)
|
|
30
|
+
raise Error, "#{path}: #{label} must be a mapping" unless value.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
missing = keys - value.keys.map(&:to_s)
|
|
33
|
+
raise Error, "#{path}: #{label} lacks #{missing.join(', ')}" unless missing.empty?
|
|
34
|
+
|
|
35
|
+
keys.to_h { |key| [key.to_sym, number(value[key], "#{label}.#{key}", path, max:)] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.number(value, label, path, max: nil)
|
|
39
|
+
number = Float(value, exception: false)
|
|
40
|
+
return number if number && number >= 0 && (max.nil? || number <= max)
|
|
41
|
+
|
|
42
|
+
raise Error, "#{path}: #{label} must be a number from 0#{" to #{max}" if max}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.half_life(value, path)
|
|
46
|
+
days = Float(value, exception: false)
|
|
47
|
+
return days if days&.positive?
|
|
48
|
+
|
|
49
|
+
raise Error, "#{path}: freshness_half_life_days must be a number of days above 0"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.tiers(value, path)
|
|
53
|
+
shares = Array(value).map { |share| Float(share, exception: false) }
|
|
54
|
+
return shares if rising_shares?(shares)
|
|
55
|
+
|
|
56
|
+
raise Error, "#{path}: tiers must be rising shares between 0 and 1, such as [0.1, 0.3, 0.7]"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.rising_shares?(shares)
|
|
60
|
+
!shares.empty? && shares.all? { |share| share&.positive? && share < 1 } &&
|
|
61
|
+
shares.each_cons(2).all? { |low, high| low < high }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The AI's share and when two models disagree. A file without these keys gets the defaults.
|
|
65
|
+
def self.ai_settings(data, path)
|
|
66
|
+
low = number(data.fetch("ai_share_min", 0.35), "ai_share_min", path, max: 1.0)
|
|
67
|
+
high = number(data.fetch("ai_share_max", 0.65), "ai_share_max", path, max: 1.0)
|
|
68
|
+
raise Error, "#{path}: ai_share_min must not be above ai_share_max" if low > high
|
|
69
|
+
|
|
70
|
+
{ ai_share_min: low, ai_share_max: high,
|
|
71
|
+
disagreement: number(data.fetch("disagreement", 35), "disagreement", path, max: 100.0) }
|
|
72
|
+
end
|
|
73
|
+
private_class_method :numbers, :number, :half_life, :tiers, :rising_shares?, :ai_settings
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# How the importance of a page is weighed. This is data, not code: change it to retune the ranking.
|
|
2
|
+
#
|
|
3
|
+
# signals How much each measurement counts. Only the ratios matter. A signal measured on few pages counts less,
|
|
4
|
+
# and one that is the same on every page counts nothing.
|
|
5
|
+
# reach How the homepage leads to the page: through its navigation and the lists of the index pages, where
|
|
6
|
+
# earlier entries weigh more. The steps multiply, so pages deep in the structure end lower.
|
|
7
|
+
# presence Shown on the homepage: in its navigation or its lists (1), only in its footer (0.4).
|
|
8
|
+
# mentions How many pages list it or link to it in their text, compared with the other pages.
|
|
9
|
+
# freshness How recently it changed, from the sitemap's lastmod or the page's own date.
|
|
10
|
+
# edges The weight of each kind of step from one page to the next, from 0 to 1.
|
|
11
|
+
# list_floor What the last entry of a long list keeps of the list's weight. The first keeps all of it.
|
|
12
|
+
# cards A list read from the headings of a page instead of from structured data.
|
|
13
|
+
# address A page under another one in its address, for pages nothing else leads to.
|
|
14
|
+
# freshness_half_life_days After how many days freshness has halved.
|
|
15
|
+
# tiers Where the tiers end, as shares of the ranking: [0.1, 0.3, 0.7] puts the top 10% in tier 1, the next
|
|
16
|
+
# 20% in tier 2, the next 40% in tier 3 and the rest in tier 4.
|
|
17
|
+
signals:
|
|
18
|
+
reach: 50
|
|
19
|
+
presence: 20
|
|
20
|
+
mentions: 15
|
|
21
|
+
freshness: 15
|
|
22
|
+
edges:
|
|
23
|
+
navigation: 0.9
|
|
24
|
+
footer: 0.5
|
|
25
|
+
list: 1.0
|
|
26
|
+
cards: 0.85
|
|
27
|
+
list_floor: 0.6
|
|
28
|
+
text: 0.35
|
|
29
|
+
address: 0.25
|
|
30
|
+
freshness_half_life_days: 180
|
|
31
|
+
tiers: [0.1, 0.3, 0.7]
|
|
32
|
+
min_spread: 0.01
|
|
33
|
+
|
|
34
|
+
# ai_share_min, ai_share_max The share of the score the AI's judgement gets: ai_share_min when the navigation and
|
|
35
|
+
# lists of the site lead to every page, growing to ai_share_max as they lead to fewer.
|
|
36
|
+
# disagreement When two models' judgements of a page differ by this many points (of 100) or more, it is reported.
|
|
37
|
+
ai_share_min: 0.35
|
|
38
|
+
ai_share_max: 0.65
|
|
39
|
+
disagreement: 35
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# One page in the order it is to be audited, and why. state is :never (never audited), :overdue (its time has
|
|
7
|
+
# come) or :waiting (audited recently enough). tier is the one that counted: the page's own, or the default.
|
|
8
|
+
Pick = Data.define(:record, :state, :tier, :days_since, :interval, :reason) do
|
|
9
|
+
def due? = state != :waiting
|
|
10
|
+
|
|
11
|
+
# How far into its interval the page is: 1.0 means it comes due now. Nil for a page that was never audited.
|
|
12
|
+
def ratio = days_since && (days_since / interval)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Puts the pages in the order they are to be audited: first the pages never audited, the most important
|
|
16
|
+
# first; then the pages whose time has come, the furthest past their own interval first (the more important
|
|
17
|
+
# first on ties); and last the pages that are not due, the nearest to due first. A page whose last audit was
|
|
18
|
+
# incomplete counts from the retry period and not from the interval of its tier.
|
|
19
|
+
class Agenda
|
|
20
|
+
RANKS = { never: 0, overdue: 1, waiting: 2 }.freeze
|
|
21
|
+
DAY = 86_400.0
|
|
22
|
+
|
|
23
|
+
def self.call(records:, cadence:, now:) = new(records, cadence, now).call
|
|
24
|
+
|
|
25
|
+
def initialize(records, cadence, now)
|
|
26
|
+
@records = records
|
|
27
|
+
@cadence = cadence
|
|
28
|
+
@now = now
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
picks = @records.map { |record| pick_for(record) }
|
|
33
|
+
picks.each_with_index.sort_by { |pick, index| [*order_key(pick), index] }.map(&:first)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def counted_tier(record) = record.tier || @cadence.default_tier
|
|
39
|
+
|
|
40
|
+
def tier_part(record)
|
|
41
|
+
record.tier ? "tier #{record.tier}" : "tier not set, counted as #{@cadence.default_tier}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def plural(number, word) = "#{number} #{word}#{'s' unless number == 1}"
|
|
45
|
+
|
|
46
|
+
def pick_for(record)
|
|
47
|
+
record.last_audited ? audited(record, record.last_audited) : never_audited(record)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def never_audited(record)
|
|
51
|
+
Pick.new(record:, state: :never, tier: counted_tier(record), days_since: nil, interval: nil,
|
|
52
|
+
reason: "never audited (#{tier_part(record)})")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def audited(record, last)
|
|
56
|
+
days = [(@now - last) / DAY, 0.0].max
|
|
57
|
+
retrying = record.fingerprint.to_s.strip.empty?
|
|
58
|
+
interval = retrying ? @cadence.retry_days : @cadence.interval_for(record.tier)
|
|
59
|
+
state = days >= interval ? :overdue : :waiting
|
|
60
|
+
Pick.new(record:, state:, tier: counted_tier(record), days_since: days, interval:,
|
|
61
|
+
reason: "#{timing(state, days, interval)} (#{note(record, interval, retrying)})")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def note(record, interval, retrying)
|
|
65
|
+
days = plural(interval, "day")
|
|
66
|
+
retrying ? "last audit incomplete, retry after #{days}" : "#{tier_part(record)}, every #{days}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def timing(state, days, interval)
|
|
70
|
+
if state == :waiting
|
|
71
|
+
return "audited #{plural(days.floor, 'day')} ago, next in #{plural((interval - days).ceil, 'day')}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
over = (days - interval).floor
|
|
75
|
+
over.zero? ? "due now" : "#{plural(over, 'day')} overdue"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def order_key(pick)
|
|
79
|
+
importance = -pick.record.importance.to_i
|
|
80
|
+
rank = RANKS.fetch(pick.state)
|
|
81
|
+
pick.state == :never ? [rank, importance, 0] : [rank, -pick.ratio, importance]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# Turns the order of the pages into the lines the queue command prints.
|
|
7
|
+
class AgendaReport
|
|
8
|
+
SHOWN = 20
|
|
9
|
+
|
|
10
|
+
def initialize(picks, shown: SHOWN, everything: false)
|
|
11
|
+
@picks = picks
|
|
12
|
+
@shown = shown
|
|
13
|
+
@everything = everything
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def lines
|
|
17
|
+
return ["The Pages tab is empty. Run: yuno-proofreader pages import"] if @picks.empty?
|
|
18
|
+
|
|
19
|
+
[*summary, *listing, *untiered]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def due = @picks.select(&:due?)
|
|
25
|
+
|
|
26
|
+
def pool = @everything ? @picks : due
|
|
27
|
+
|
|
28
|
+
def summary
|
|
29
|
+
return nothing_due if due.empty?
|
|
30
|
+
|
|
31
|
+
never = due.count { |pick| pick.state == :never }
|
|
32
|
+
["#{@picks.size} page(s): #{due.size} due (#{never} never audited, #{due.size - never} overdue), " \
|
|
33
|
+
"#{@picks.size - due.size} waiting."]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def nothing_due
|
|
37
|
+
soonest = @picks.first
|
|
38
|
+
["Nothing is due: all #{@picks.size} page(s) were audited within their cadence.",
|
|
39
|
+
"Next to come due: #{soonest.record.name}, #{soonest.reason}"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def listing
|
|
43
|
+
shown = pool.first(@shown)
|
|
44
|
+
return [] if shown.empty?
|
|
45
|
+
|
|
46
|
+
rows = shown.each_with_index.map do |pick, index|
|
|
47
|
+
format(" %3<place>d. %-40<name>s %<reason>s", place: index + 1, name: pick.record.name,
|
|
48
|
+
reason: pick.reason)
|
|
49
|
+
end
|
|
50
|
+
hidden = pool.size - shown.size
|
|
51
|
+
more = hidden.positive? ? [" ... and #{hidden} more (--count=N for more, --all includes pages not due)"] : []
|
|
52
|
+
["", *rows, *more]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def untiered
|
|
56
|
+
missing = @picks.select { |pick| pick.record.tier.nil? }
|
|
57
|
+
return [] if missing.empty?
|
|
58
|
+
|
|
59
|
+
["", "#{missing.size} page(s) have no tier yet and count as tier #{missing.first.tier}. " \
|
|
60
|
+
"Run: yuno-proofreader analyze"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# Turns what a model answered into findings, and says what was missing or unusable in the answer.
|
|
7
|
+
module Answers
|
|
8
|
+
# missing_steps: the numbers of audit steps the answer left out.
|
|
9
|
+
# discarded: a line for everything in the answer that was ignored, and why.
|
|
10
|
+
Result = Data.define(:findings, :missing_steps, :discarded)
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The answer of the step-by-step audit: { "steps": [{ "step": 1, "issues": [...] }, ...] }.
|
|
15
|
+
def audit(data, steps:, languages:, limit: Prompt::MAX_ISSUES_PER_STEP)
|
|
16
|
+
listed = data.is_a?(Hash) ? data["steps"] : nil
|
|
17
|
+
return no_steps(steps) unless listed.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
by_number = steps.to_h { |step| [step.number, step] }
|
|
20
|
+
reads = listed.each_with_index.map { |entry, index| read_entry(entry, index, by_number, languages, limit) }
|
|
21
|
+
combine(reads, steps)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def no_steps(steps)
|
|
25
|
+
Result.new(findings: [], missing_steps: steps.map(&:number), discarded: ["the answer has no list of steps"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# reads: [step or nil, result] for each entry of the answer.
|
|
29
|
+
def combine(reads, steps)
|
|
30
|
+
results = reads.map(&:last)
|
|
31
|
+
answered = reads.filter_map { |step, _result| step&.number }
|
|
32
|
+
Result.new(findings: results.flat_map(&:findings), missing_steps: steps.map(&:number) - answered,
|
|
33
|
+
discarded: results.flat_map(&:discarded))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The answer of the second review: { "issues": [...] }.
|
|
37
|
+
def review(data, languages:, limit: Prompt::MAX_REVIEW_ISSUES)
|
|
38
|
+
items = data.is_a?(Hash) ? data["issues"] : nil
|
|
39
|
+
return nothing("the answer has no list of issues") unless items.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
parsed = Findings::Finding.parse_all(items.first(limit), languages:)
|
|
42
|
+
notes = parsed.discarded.dup
|
|
43
|
+
notes << "#{items.size - limit} findings beyond the limit of #{limit} were ignored" if items.size > limit
|
|
44
|
+
Result.new(findings: parsed.findings, missing_steps: [], discarded: notes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read_entry(entry, index, by_number, languages, limit)
|
|
48
|
+
step = entry.is_a?(Hash) ? by_number[number_of(entry["step"])] : nil
|
|
49
|
+
return [nil, nothing("entry #{index + 1} of the answer is not a step of this audit")] unless step
|
|
50
|
+
|
|
51
|
+
[step, read_step(entry["issues"], step, languages, limit)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_step(items, step, languages, limit)
|
|
55
|
+
return nothing("step #{step.number}: the issues are not a list") unless items.nil? || items.is_a?(Array)
|
|
56
|
+
|
|
57
|
+
items = Array(items)
|
|
58
|
+
parsed = Findings::Finding.parse_all(items.first(limit), languages:, categories: step.categories,
|
|
59
|
+
fallback: step.categories.first)
|
|
60
|
+
notes = parsed.discarded.map { |note| "step #{step.number}: #{note}" }
|
|
61
|
+
if items.size > limit
|
|
62
|
+
notes << "step #{step.number}: #{items.size - limit} findings beyond the limit of #{limit} were ignored"
|
|
63
|
+
end
|
|
64
|
+
Result.new(findings: parsed.findings, missing_steps: [], discarded: notes)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def number_of(value) = value.to_s.strip[/\A\d+\z/]&.to_i
|
|
68
|
+
|
|
69
|
+
def nothing(note) = Result.new(findings: [], missing_steps: [], discarded: [note])
|
|
70
|
+
private_class_method :no_steps, :combine, :read_entry, :read_step, :number_of, :nothing
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yuno
|
|
4
|
+
module Proofreader
|
|
5
|
+
module Audit
|
|
6
|
+
# One model call: which model answered, how long it took and what it cost.
|
|
7
|
+
Call = Data.define(:role, :model, :seconds, :input_tokens, :output_tokens, :thinking_tokens)
|
|
8
|
+
|
|
9
|
+
# A call that was made, with its answer.
|
|
10
|
+
Asked = Data.define(:answer, :call)
|
|
11
|
+
|
|
12
|
+
# The second review: the call, or why there is none.
|
|
13
|
+
Review = Data.define(:asked, :error)
|
|
14
|
+
|
|
15
|
+
# The two model calls for one page and what was read from them. audit_read and review_read are
|
|
16
|
+
# Answers::Result values. review_read is nil when the review failed or its answer could not be used.
|
|
17
|
+
Attempt = Data.define(:first, :review, :audit_read, :review_read) do
|
|
18
|
+
def calls = [first.call, review.asked&.call].compact
|
|
19
|
+
|
|
20
|
+
def reviewed? = !review_read.nil?
|
|
21
|
+
|
|
22
|
+
def missing_steps = audit_read.missing_steps
|
|
23
|
+
|
|
24
|
+
def discarded = [*audit_read.discarded, *Array(review_read&.discarded)]
|
|
25
|
+
|
|
26
|
+
# What the person should know about this attempt, beyond the numbers.
|
|
27
|
+
def notes(fetched)
|
|
28
|
+
[*unusable_versions(fetched), steps_note, *review_notes, cut_note(fetched)].compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def unusable_versions(fetched)
|
|
34
|
+
fetched.snapshots.reject(&:ok?).map do |snapshot|
|
|
35
|
+
"The #{snapshot.language} version could not be used (#{snapshot.problem}), " \
|
|
36
|
+
"so it was left out of the review."
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def steps_note
|
|
41
|
+
return if missing_steps.empty?
|
|
42
|
+
|
|
43
|
+
"The audit left out step #{missing_steps.join(', ')}, so it does not count as complete."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def review_notes
|
|
47
|
+
if review.error
|
|
48
|
+
["The second review failed (#{review.error}). New findings are marked 'audit (unreviewed)'."]
|
|
49
|
+
elsif review_read.nil?
|
|
50
|
+
["The second review answered in a way that could not be used. " \
|
|
51
|
+
"New findings are marked 'audit (unreviewed)'."]
|
|
52
|
+
elsif review.asked.call.model == first.call.model
|
|
53
|
+
["The second review used the same model as the audit, because no other model was available. " \
|
|
54
|
+
"It is not independent."]
|
|
55
|
+
else
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cut_note(fetched)
|
|
61
|
+
cut = fetched.snapshots.select(&:truncated).map(&:language)
|
|
62
|
+
return if cut.empty?
|
|
63
|
+
|
|
64
|
+
"The #{cut.join(', ')} version was cut at the size limit, so its end was not reviewed."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|