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