sloplint-judge 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/LICENSE +21 -0
- data/docs/JUDGE.md +384 -0
- data/exe/sloplint-judge +11 -0
- data/lib/sloplint/judge/backend.rb +69 -0
- data/lib/sloplint/judge/backends/jev.rb +191 -0
- data/lib/sloplint/judge/cli.rb +290 -0
- data/lib/sloplint/judge/compare.rb +51 -0
- data/lib/sloplint/judge/engine.rb +243 -0
- data/lib/sloplint/judge/rules.rb +382 -0
- data/lib/sloplint/judge/secret.rb +158 -0
- data/lib/sloplint/judge/version.rb +7 -0
- data/lib/sloplint/judge.rb +15 -0
- metadata +103 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "../secret"
|
|
8
|
+
|
|
9
|
+
module Sloplint
|
|
10
|
+
module Judge
|
|
11
|
+
module Backends
|
|
12
|
+
# Jev, TypeSafe's System One model. The rule's question shape is Jev's,
|
|
13
|
+
# so questions pass through verbatim.
|
|
14
|
+
class Jev
|
|
15
|
+
DEFAULT_URL = "https://api.typesafe.ai/v1/systemone"
|
|
16
|
+
DEFAULT_MODEL = "jev-latest"
|
|
17
|
+
# The environment variable, and the keychain account, this backend's
|
|
18
|
+
# key lives under. Another backend declares its own, so two keys are
|
|
19
|
+
# two items.
|
|
20
|
+
KEY = "TYPESAFE_API_KEY"
|
|
21
|
+
|
|
22
|
+
# key: nil means look it up (environment, then keychain; see Secret).
|
|
23
|
+
def initialize(url: ENV.fetch("SYSTEMONE_URL", DEFAULT_URL),
|
|
24
|
+
model: ENV.fetch("SYSTEMONE_MODEL", DEFAULT_MODEL),
|
|
25
|
+
key: nil)
|
|
26
|
+
# The URL first: reading the key means the environment and then a
|
|
27
|
+
# keychain subprocess, which can sit for fifteen seconds on a
|
|
28
|
+
# dialog nobody can see, and a run that is about to be refused for
|
|
29
|
+
# its URL has no business pulling the secret out of the keychain.
|
|
30
|
+
@url = self.class.https!(url)
|
|
31
|
+
key ||= Secret.fetch(KEY)&.first
|
|
32
|
+
raise ArgumentError, Secret.missing(KEY) if key.nil? || key.empty?
|
|
33
|
+
|
|
34
|
+
@model = model
|
|
35
|
+
@key = key
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The name a run would report, without building the backend or
|
|
39
|
+
# reading the key, so a run that asks nothing still names the model
|
|
40
|
+
# the way a run that asks something does.
|
|
41
|
+
def self.model_name = ENV.fetch("SYSTEMONE_MODEL", DEFAULT_MODEL)
|
|
42
|
+
|
|
43
|
+
# The endpoint settings, checked without building the backend or
|
|
44
|
+
# reading the key; `status` prints what this returns.
|
|
45
|
+
def self.configured!
|
|
46
|
+
https!(ENV.fetch("SYSTEMONE_URL", DEFAULT_URL))
|
|
47
|
+
"model #{model_name}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Case-insensitive, because a DNS name is: API.TypeSafe.ai is the
|
|
51
|
+
# same host as api.typesafe.ai, and URI hands back the host as it was
|
|
52
|
+
# written. The scheme needs no such care -- URI lowercases that one.
|
|
53
|
+
HOST = /\A(?:.+\.)?typesafe\.ai\z/i
|
|
54
|
+
|
|
55
|
+
# The key and the whole document go to this URL, so it is https and
|
|
56
|
+
# a TypeSafe host or nothing. The host pin is what keeps an injected
|
|
57
|
+
# `SYSTEMONE_URL=https://attacker.example sloplint check --judge` from
|
|
58
|
+
# being a valid way to run the sanctioned command.
|
|
59
|
+
def self.https!(url)
|
|
60
|
+
# URI raises its own error class, which the commands do not catch:
|
|
61
|
+
# a misspelt SYSTEMONE_URL would end `status` with a backtrace
|
|
62
|
+
# instead of the usage error every other bad setting gets.
|
|
63
|
+
uri = begin
|
|
64
|
+
URI(url)
|
|
65
|
+
rescue URI::InvalidURIError
|
|
66
|
+
raise ArgumentError, "SYSTEMONE_URL is not a URL: #{url.inspect}"
|
|
67
|
+
end
|
|
68
|
+
raise ArgumentError, "SYSTEMONE_URL must be https, got #{uri.scheme.inspect}" unless uri.scheme == "https"
|
|
69
|
+
raise ArgumentError, "SYSTEMONE_URL must be a typesafe.ai host, got #{uri.host.inspect}" unless uri.host.to_s.match?(HOST)
|
|
70
|
+
raise ArgumentError, "SYSTEMONE_URL needs a path, for example #{DEFAULT_URL}" if uri.path.empty?
|
|
71
|
+
|
|
72
|
+
uri
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Jev's response carries token counts and no price. TypeSafe's public
|
|
76
|
+
# price is $42 per billion input tokens; output tokens are free. So
|
|
77
|
+
# the cost is the input tokens times this, exactly, not an estimate.
|
|
78
|
+
# ponytail: one constant, not a config; move to an env var when the
|
|
79
|
+
# price moves.
|
|
80
|
+
USD_PER_INPUT_TOKEN = 42.0 / 1_000_000_000
|
|
81
|
+
|
|
82
|
+
def name = @model
|
|
83
|
+
|
|
84
|
+
# Dollars for a summed usage Hash. Output tokens cost nothing. On the
|
|
85
|
+
# class as well, because the run that asks nothing has no built
|
|
86
|
+
# backend to ask and still prints a price.
|
|
87
|
+
def self.cost_usd(usage) = usage.fetch("input_tokens", 0) * USD_PER_INPUT_TOKEN
|
|
88
|
+
def cost_usd(usage) = self.class.cost_usd(usage)
|
|
89
|
+
|
|
90
|
+
def ask(state, questions)
|
|
91
|
+
http = Net::HTTP.new(@url.host, @url.port)
|
|
92
|
+
http.use_ssl = true
|
|
93
|
+
http.read_timeout = 90
|
|
94
|
+
req = Net::HTTP::Post.new(@url.request_uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{@key}")
|
|
95
|
+
req.body = JSON.generate({ "state" => state, "model" => @model, "questions" => questions })
|
|
96
|
+
res = http.request(req)
|
|
97
|
+
raise BackendError, "#{@url.host} returned #{res.code}: #{res.body.to_s[0, 200]}" unless res.code == "200"
|
|
98
|
+
|
|
99
|
+
body = JSON.parse(res.body)
|
|
100
|
+
# A body of [] or null reads as an answer until something is asked
|
|
101
|
+
# of it. Said here, it is a malformed body like any other; left to
|
|
102
|
+
# the reads below it would be a TypeError, which is a bug's class.
|
|
103
|
+
malformed!("the body is #{body.class}, not a Hash") unless body.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
usage = body["usage"].is_a?(Hash) ? body["usage"] : {}
|
|
106
|
+
answers = body.fetch("answers")
|
|
107
|
+
malformed!("answers is #{answers.class}, not a Hash keyed by question") unless answers.is_a?(Hash)
|
|
108
|
+
questions.to_h do |qname, q|
|
|
109
|
+
a = answers.fetch(qname) { raise KeyError, "no answer for #{qname}" }
|
|
110
|
+
malformed!("the answer for #{qname} is #{a.class}, not a Hash") unless a.is_a?(Hash)
|
|
111
|
+
type = q.fetch("type")
|
|
112
|
+
probs = normalise(type, type == "noul" ? a.fetch("noul") : a.fetch("probabilities"), q)
|
|
113
|
+
[qname, Answer.new(type: type, probabilities: probs, confidence: confidence_for(a, type, probs), usage: usage)]
|
|
114
|
+
end
|
|
115
|
+
# Transport errors, and a body missing a key Jev documents, are one
|
|
116
|
+
# thing to the caller: the backend did not answer. Exit 3, not a
|
|
117
|
+
# backtrace. A body of the wrong shape raises BackendError where it is
|
|
118
|
+
# read, so this list stays off our own code: a bug in normalise is a
|
|
119
|
+
# bug, and it gets to raise like one.
|
|
120
|
+
# Net::HTTPBadResponse and Net::HTTPHeaderSyntaxError are named one by
|
|
121
|
+
# one because they descend from StandardError and not from
|
|
122
|
+
# Net::ProtocolError: a proxy that answers with something that is not
|
|
123
|
+
# an HTTP status line raises one of them.
|
|
124
|
+
rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError, Net::ProtocolError,
|
|
125
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::OpenTimeout,
|
|
126
|
+
Net::ReadTimeout, Net::WriteTimeout, JSON::ParserError, KeyError => e
|
|
127
|
+
raise BackendError, "#{e.class}: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Jev returns score probabilities keyed by level index as strings, choice
|
|
133
|
+
# probabilities keyed by option, and a noul as one float under "noul".
|
|
134
|
+
# Anything else in those slots is a malformed body, said so here, which
|
|
135
|
+
# is what lets `ask` rescue only the reads and not our own arithmetic.
|
|
136
|
+
def normalise(type, probs, question)
|
|
137
|
+
case type
|
|
138
|
+
when "score" then scores(probs, question.fetch("criteria").size)
|
|
139
|
+
when "choice"
|
|
140
|
+
malformed!("choice probabilities are #{probs.class}, not a Hash keyed by option") unless probs.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
probs.transform_values { |p| number(p) }
|
|
143
|
+
else
|
|
144
|
+
malformed!("noul is #{probs.class}, not a number") unless probs.is_a?(Numeric)
|
|
145
|
+
|
|
146
|
+
probs.to_f
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# One probability per level, in level order. Jev may leave out a level
|
|
151
|
+
# whose probability is zero, so the array is as long as the question's
|
|
152
|
+
# criteria and the gaps are 0.0. Otherwise a short array would slide
|
|
153
|
+
# every level down one and Answer#top would name the wrong criterion.
|
|
154
|
+
def scores(probs, levels)
|
|
155
|
+
malformed!("score probabilities are #{probs.class}, not a Hash keyed by level") unless probs.is_a?(Hash)
|
|
156
|
+
# No level at all is not a level of zero. Filled with zeros the
|
|
157
|
+
# levels would tie, the tie would go to level 0, and level 0 is the
|
|
158
|
+
# one every score rule flags on.
|
|
159
|
+
malformed!("score probabilities for none of the #{levels} levels") if probs.empty?
|
|
160
|
+
|
|
161
|
+
out = Array.new(levels, 0.0)
|
|
162
|
+
probs.each do |k, p|
|
|
163
|
+
i = Integer(k.to_s, 10, exception: false)
|
|
164
|
+
malformed!("score probability key #{k.inspect} is not one of the #{levels} levels") unless i&.between?(0, levels - 1)
|
|
165
|
+
out[i] = number(p)
|
|
166
|
+
end
|
|
167
|
+
out
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def number(p) = Float(p, exception: false) || malformed!("#{p.class} is not a probability")
|
|
171
|
+
|
|
172
|
+
def malformed!(why) = raise(BackendError, "#{@url.host} returned a malformed body: #{why}")
|
|
173
|
+
|
|
174
|
+
# Score and choice answers carry a confidence. A noul does not, so it
|
|
175
|
+
# gets the one thing its answer can defend: distance from the fence,
|
|
176
|
+
# scaled so 0.5 is 0 and 0 or 1 is 1.
|
|
177
|
+
def confidence_for(answer, type, probs)
|
|
178
|
+
# Read like any other number Jev sends: a confidence of {} or [] or
|
|
179
|
+
# true is a malformed body, not a NoMethodError on our own side.
|
|
180
|
+
return number(answer["confidence"]) if answer["confidence"]
|
|
181
|
+
return (probs - 0.5).abs * 2 if type == "noul"
|
|
182
|
+
|
|
183
|
+
sorted = (probs.is_a?(Hash) ? probs.values : probs).sort.reverse
|
|
184
|
+
malformed!("an answer with no probabilities at all") if sorted.empty?
|
|
185
|
+
|
|
186
|
+
(sorted[0] - (sorted[1] || 0)).clamp(0.0, 1.0)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "sloplint/judge"
|
|
6
|
+
|
|
7
|
+
module Sloplint
|
|
8
|
+
module Judge
|
|
9
|
+
# The judge's own executable: judge-only check, compare, rules, explain.
|
|
10
|
+
# Exit codes are sloplint's plus 3 for a backend failure. See docs/JUDGE.md.
|
|
11
|
+
module CLI
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def run(argv, out: $stdout, err: $stderr, stdin: $stdin)
|
|
15
|
+
opts = { format: "full", register: Engine::DEFAULT_REGISTER }
|
|
16
|
+
parser = global_parser(opts, out:)
|
|
17
|
+
begin
|
|
18
|
+
parser.order!(argv)
|
|
19
|
+
rescue OptionParser::InvalidOption => e
|
|
20
|
+
argv.unshift("check", *e.args)
|
|
21
|
+
end
|
|
22
|
+
return 0 if opts[:help_shown] || opts[:version_shown]
|
|
23
|
+
|
|
24
|
+
command = argv.shift
|
|
25
|
+
command ||= (stdin.tty? ? "help" : "check")
|
|
26
|
+
case command
|
|
27
|
+
when "check" then cmd_check(argv, opts, out:, err:, stdin:)
|
|
28
|
+
when "compare" then cmd_compare(argv, opts, out:, err:)
|
|
29
|
+
when "rules" then cmd_rules(argv, out:)
|
|
30
|
+
when "explain" then cmd_explain(argv, out:, err:)
|
|
31
|
+
when "status" then cmd_status(opts, out:, err:)
|
|
32
|
+
when "key" then cmd_key(argv, opts, out:, err:, stdin:)
|
|
33
|
+
when "version" then out.puts(VERSION); 0
|
|
34
|
+
when "help" then out.puts(parser.help); 0
|
|
35
|
+
else cmd_check(argv.unshift(command), opts, out:, err:, stdin:)
|
|
36
|
+
end
|
|
37
|
+
rescue OptionParser::ParseError => e
|
|
38
|
+
err.puts("sloplint-judge: #{e.message}")
|
|
39
|
+
2
|
|
40
|
+
rescue BackendError => e
|
|
41
|
+
err.puts("sloplint-judge: backend failure: #{e.message}")
|
|
42
|
+
3
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── check ───────────────────────────────────────────────────────────────
|
|
46
|
+
def cmd_check(argv, opts, out:, err:, stdin:)
|
|
47
|
+
markdown = false
|
|
48
|
+
strict = false
|
|
49
|
+
select = nil
|
|
50
|
+
ignore = nil
|
|
51
|
+
help = false
|
|
52
|
+
OptionParser.new do |o|
|
|
53
|
+
o.banner = "usage: sloplint-judge check [options] [paths...] (\"-\" or no paths = stdin)"
|
|
54
|
+
o.on("-o", "--output-format FORMAT", %w[full json], "Output format: 'full' or 'json' (default: full).") { |v| opts[:format] = v }
|
|
55
|
+
o.on("--markdown", "Skip fenced/inline code spans, HTML comments, URLs and Markdown furniture.") { markdown = true }
|
|
56
|
+
o.on("--select IDS", "Only run these rules (comma-separated rule ids or categories).") { |v| select = v.split(",").map(&:strip) }
|
|
57
|
+
o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or categories).") { |v| ignore = v.split(",").map(&:strip) }
|
|
58
|
+
o.on("--strict", "Run the rules that are off by default, ask the sentence rules about",
|
|
59
|
+
"every sentence, and keep low-confidence notes; about three times the",
|
|
60
|
+
"requests of a default run.") { strict = true }
|
|
61
|
+
o.on("--register TEXT", "Who the reader is.") { |v| opts[:register] = v }
|
|
62
|
+
o.on("--backend NAME", "Which adapter to use.") { |v| opts[:backend] = v }
|
|
63
|
+
# OptionParser answers -h itself when nobody else does, and it
|
|
64
|
+
# answers it on the real stdout and ends the process. This command
|
|
65
|
+
# prints to the out it was given and returns, as every other one does.
|
|
66
|
+
o.on("-h", "--help", "Show this help.") { out.puts(o.help); help = true }
|
|
67
|
+
# permute!, so a flag written after the path is a flag: `sloplint-judge
|
|
68
|
+
# check draft.md --markdown` reads the way anyone would write it.
|
|
69
|
+
end.permute!(argv)
|
|
70
|
+
return 0 if help
|
|
71
|
+
|
|
72
|
+
unknown = Sloplint::CLI.unknown_rule_refs(select, RULES) + Sloplint::CLI.unknown_rule_refs(ignore, RULES)
|
|
73
|
+
unless unknown.empty?
|
|
74
|
+
err.puts("sloplint-judge: unknown rule or category: #{unknown.join(", ")}")
|
|
75
|
+
err.puts("run `sloplint-judge rules` to list them.")
|
|
76
|
+
return 2
|
|
77
|
+
end
|
|
78
|
+
rules = Sloplint::CLI.select_rules(select, ignore, strict, RULES)
|
|
79
|
+
|
|
80
|
+
# Only the read is invalid input. An ArgumentError out of the run is
|
|
81
|
+
# a bug in this code, and it raises like one instead of sending the
|
|
82
|
+
# reader to look for bad bytes in a file that has none.
|
|
83
|
+
sources = begin
|
|
84
|
+
Sloplint::CLI.read_sources(argv.empty? ? ["-"] : argv, err:, stdin:, name: "sloplint-judge")
|
|
85
|
+
rescue ArgumentError, Encoding::CompatibilityError => e
|
|
86
|
+
err.puts("sloplint-judge: invalid input: #{e.message}")
|
|
87
|
+
return 2
|
|
88
|
+
end
|
|
89
|
+
return 2 unless sources
|
|
90
|
+
|
|
91
|
+
# A selection that holds no rule is decided here, before a backend is
|
|
92
|
+
# built, because building one reads the key: `--ignore` every rule
|
|
93
|
+
# must run on a machine that has none. Nothing was asked, so nothing
|
|
94
|
+
# was found, and that is exit 0 with a 0-request usage line -- not
|
|
95
|
+
# the "nothing examined" error below, which is a document that had no
|
|
96
|
+
# prose in it.
|
|
97
|
+
if rules.empty?
|
|
98
|
+
usage = begin
|
|
99
|
+
Engine.nothing_asked(opts[:backend])
|
|
100
|
+
rescue ArgumentError => e
|
|
101
|
+
err.puts("sloplint-judge: #{e.message}")
|
|
102
|
+
return 2
|
|
103
|
+
end
|
|
104
|
+
err.puts("sloplint-judge #{Engine.usage_line(usage)}")
|
|
105
|
+
Sloplint::CLI.emit([], opts[:format], out:, by_path: false, judge: usage)
|
|
106
|
+
return 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
backend = load_backend(opts[:backend], err:) or return 2
|
|
110
|
+
result = Engine.scan_sources(sources, name: "sloplint-judge", err:, rules:, backend:, markdown:, register: opts[:register], strict:)
|
|
111
|
+
# Nothing asked is nothing examined, and exit 0 would report that the
|
|
112
|
+
# judge read the document and found nothing in it. The same reason
|
|
113
|
+
# empty input is a usage error, and the same exit code.
|
|
114
|
+
if result.usage["requests"].zero?
|
|
115
|
+
names = sources.map { |label, _| label == "-" ? "stdin" : label }
|
|
116
|
+
err.puts("sloplint-judge: nothing examined in #{names.join(", ")}: no prose reached the judge.")
|
|
117
|
+
err.puts("a document that is all Markdown furniture, or whose paragraphs are too short for the rules selected, leaves nothing to ask about.")
|
|
118
|
+
return 2
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Sloplint::CLI.emit(result.notes, opts[:format], out:, by_path: sources.count { |label, _| label != "-" } > 1, judge: result.usage)
|
|
122
|
+
result.notes.empty? ? 0 : 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ── compare A B ─────────────────────────────────────────────────────────
|
|
126
|
+
def cmd_compare(argv, opts, out:, err:)
|
|
127
|
+
drift = false
|
|
128
|
+
help = false
|
|
129
|
+
OptionParser.new do |o|
|
|
130
|
+
o.banner = "usage: sloplint-judge compare [--drift] A B (two files)"
|
|
131
|
+
o.on("--drift", "Also ask whether B changes what A says.") { drift = true }
|
|
132
|
+
o.on("--register TEXT", "Who the reader is.") { |v| opts[:register] = v }
|
|
133
|
+
o.on("--backend NAME", "Which adapter to use.") { |v| opts[:backend] = v }
|
|
134
|
+
o.on("-h", "--help", "Show this help.") { out.puts(o.help); help = true }
|
|
135
|
+
# permute!, so `compare A B --drift` sees the flag. There is no nested
|
|
136
|
+
# subcommand here whose own flags an option after the files could be.
|
|
137
|
+
end.permute!(argv)
|
|
138
|
+
return 0 if help
|
|
139
|
+
|
|
140
|
+
a, b = argv
|
|
141
|
+
unless argv.size == 2 && File.file?(a) && File.file?(b)
|
|
142
|
+
err.puts("usage: sloplint-judge compare [--drift] A B")
|
|
143
|
+
return 2
|
|
144
|
+
end
|
|
145
|
+
# The same read `check` does, so an unreadable or badly encoded file
|
|
146
|
+
# is an invalid input here too, before a request is paid for.
|
|
147
|
+
sources = Sloplint::CLI.read_sources([a, b], err:, name: "sloplint-judge") or return 2
|
|
148
|
+
|
|
149
|
+
backend = load_backend(opts[:backend], err:) or return 2
|
|
150
|
+
verdict = Compare.run(sources[0].last, sources[1].last, place: "for #{opts[:register]}", backend:, drift:)
|
|
151
|
+
out.puts(JSON.pretty_generate(verdict.to_h.except(:usage)))
|
|
152
|
+
# Engine.numbers for the same reason a scan uses it: a count that is
|
|
153
|
+
# not a number must not take down a verdict already printed.
|
|
154
|
+
usage = Engine.with_cost({ "backend" => backend.name }.merge(Engine.numbers(verdict.usage)), backend)
|
|
155
|
+
err.puts("sloplint-judge: #{Engine.usage_line(usage)}")
|
|
156
|
+
0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# An unknown backend name or a missing key is a usage error (exit 2),
|
|
160
|
+
# not a backend failure (exit 3): nothing was tried.
|
|
161
|
+
def load_backend(name, err:)
|
|
162
|
+
Backend.load(name)
|
|
163
|
+
rescue ArgumentError => e
|
|
164
|
+
err.puts("sloplint-judge: #{e.message}")
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ── status / key set ────────────────────────────────────────────────────
|
|
169
|
+
# Could a run happen here? Answered without reading the key: this is the
|
|
170
|
+
# check skill's probe and it runs before anyone has agreed to send text.
|
|
171
|
+
# Exit 0 configured, 2 not, with the message `check` would give.
|
|
172
|
+
def cmd_status(opts, out:, err:)
|
|
173
|
+
klass = Backend.klass(opts[:backend])
|
|
174
|
+
settings = klass.configured!
|
|
175
|
+
source = Secret.present?(klass::KEY) or raise ArgumentError, Secret.missing(klass::KEY)
|
|
176
|
+
out.puts("backend #{Backend.default_name(opts[:backend])} (#{settings}), key from #{source}")
|
|
177
|
+
0
|
|
178
|
+
rescue ArgumentError => e
|
|
179
|
+
err.puts("sloplint-judge: #{e.message}")
|
|
180
|
+
2
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Stores the key once. The platform tool owns the prompt and the
|
|
184
|
+
# terminal, so the value is never on a command line, in a pipe, in shell
|
|
185
|
+
# history or in this process. A person types this; the skill never does.
|
|
186
|
+
def cmd_key(argv, opts, out:, err:, stdin:)
|
|
187
|
+
key = Backend.klass(opts[:backend])::KEY
|
|
188
|
+
return cmd_key_unset(key, out:, err:) if argv == ["unset"]
|
|
189
|
+
return err.puts("usage: sloplint-judge [--backend NAME] key set|unset") || 2 unless argv == ["set"]
|
|
190
|
+
|
|
191
|
+
command = Secret.store_command(key)
|
|
192
|
+
return err.puts("sloplint-judge: no supported key store on this platform; export #{key} instead") || 2 unless command
|
|
193
|
+
return err.puts("sloplint-judge: key set needs a terminal: the keychain tool prompts for the key itself") || 2 unless stdin.tty?
|
|
194
|
+
|
|
195
|
+
out.puts("Storing #{key} in the OS keychain under #{Secret::SERVICE}; the keychain tool will prompt for it.")
|
|
196
|
+
out.puts("Replacing the item stored earlier.") if Secret.stored?(key)
|
|
197
|
+
out.puts("Any process running as you can read it back. This keeps the key out of dotfiles and out of the agent's environment, not out of your account.")
|
|
198
|
+
out.flush
|
|
199
|
+
Process.exec(*command)
|
|
200
|
+
rescue ArgumentError => e
|
|
201
|
+
err.puts("sloplint-judge: #{e.message}")
|
|
202
|
+
2
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Removes the keychain item. Says so when there was none, and says when a
|
|
206
|
+
# key in the environment is still there, because that one it cannot touch.
|
|
207
|
+
def cmd_key_unset(key, out:, err:)
|
|
208
|
+
command = Secret.delete_command(key)
|
|
209
|
+
return err.puts("sloplint-judge: no supported key store on this platform") || 2 unless command
|
|
210
|
+
return err.puts("sloplint-judge: no #{key} item in the keychain") || 2 unless Secret.stored?(key)
|
|
211
|
+
|
|
212
|
+
removed = system(*command, out: File::NULL, err: File::NULL)
|
|
213
|
+
return err.puts("sloplint-judge: the keychain tool could not remove the item") || 2 unless removed
|
|
214
|
+
|
|
215
|
+
out.puts("Removed #{key} from the OS keychain (#{Secret::SERVICE}).")
|
|
216
|
+
out.puts("It is still set in this shell's environment; unset it there too.") unless ENV[key].to_s.empty?
|
|
217
|
+
0
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── rules / explain ─────────────────────────────────────────────────────
|
|
221
|
+
def cmd_rules(argv, out:)
|
|
222
|
+
as_json = false
|
|
223
|
+
OptionParser.new { |o| o.on("--json") { as_json = true } }.order!(argv)
|
|
224
|
+
Sloplint::CLI.render_rules(RULES, json: as_json, out:)
|
|
225
|
+
0
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def cmd_explain(argv, out:, err:)
|
|
229
|
+
id = argv.shift
|
|
230
|
+
rule = RULES.find { |r| r.id == id }
|
|
231
|
+
unless rule
|
|
232
|
+
err.puts(id ? "sloplint-judge: no such rule: #{id}" : "usage: sloplint-judge explain RULE_ID")
|
|
233
|
+
return 2
|
|
234
|
+
end
|
|
235
|
+
levels = rule.question["criteria"].each_with_index.map do |c, i|
|
|
236
|
+
" #{i}#{i == rule.flag[:level] ? " (flags)" : ""}: #{c["what"]}"
|
|
237
|
+
end
|
|
238
|
+
out.puts(<<~TXT)
|
|
239
|
+
#{rule.id} (#{rule.category}, #{rule.severity}, #{rule.confidence} confidence, per #{rule.unit})
|
|
240
|
+
|
|
241
|
+
#{rule.message}
|
|
242
|
+
|
|
243
|
+
Asks: #{format(rule.question["instructions"], register: Engine::DEFAULT_REGISTER)}
|
|
244
|
+
#{levels.join("\n")}
|
|
245
|
+
|
|
246
|
+
Why: #{rule.rationale}
|
|
247
|
+
Fix: #{rule.suggestion}
|
|
248
|
+
|
|
249
|
+
Flags: #{Sloplint::CLI.fixture_list(rule.examples_bad)}
|
|
250
|
+
Does not: #{Sloplint::CLI.fixture_list(rule.examples_ok)}
|
|
251
|
+
TXT
|
|
252
|
+
0
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def global_parser(opts, out:)
|
|
256
|
+
OptionParser.new do |o|
|
|
257
|
+
o.banner = <<~BANNER
|
|
258
|
+
sloplint-judge — rules that ask a System One model what a regex cannot.
|
|
259
|
+
|
|
260
|
+
# Recommended for agents (both linters, one document). The judge sends the text to
|
|
261
|
+
# api.typesafe.ai, so ask the person before running it. Check first, ask, then run:
|
|
262
|
+
sloplint-judge status # exit 0 = a key is set up (reads no key), 2 = not
|
|
263
|
+
sloplint check --judge --markdown -o json FILE
|
|
264
|
+
# exit 0 = clean, 1 = notes found, 2 = usage error or not configured, 3 = the model could not be reached (nothing checked)
|
|
265
|
+
# output: {"notes": [...], "judge": {"backend","requests","input_tokens","output_tokens","cost_usd"}}
|
|
266
|
+
|
|
267
|
+
usage: sloplint-judge [-o full|json] [--register TEXT] [--backend NAME] [command] [args]
|
|
268
|
+
|
|
269
|
+
commands:
|
|
270
|
+
check scan paths (or stdin) with the judge's rules only [default]
|
|
271
|
+
compare A B which of two passages a plain-prose editor keeps (--drift for rewrites)
|
|
272
|
+
rules list the judge's rule catalog (add --json)
|
|
273
|
+
explain ID print one rule's question, levels, rationale and fixtures
|
|
274
|
+
status say whether a run could happen here, and where the key is, without reading it
|
|
275
|
+
key set store the backend's key in the OS keychain (the keychain tool prompts for it)
|
|
276
|
+
key unset remove it from the OS keychain
|
|
277
|
+
version print the sloplint-judge version
|
|
278
|
+
|
|
279
|
+
global options:
|
|
280
|
+
BANNER
|
|
281
|
+
o.on("-o", "--output-format FORMAT", %w[full json]) { |v| opts[:format] = v }
|
|
282
|
+
o.on("--register TEXT", "Who the reader is (default: #{Engine::DEFAULT_REGISTER}).") { |v| opts[:register] = v }
|
|
283
|
+
o.on("--backend NAME", "Which adapter to use (default: $SLOPLINT_JUDGE_BACKEND or jev).") { |v| opts[:backend] = v }
|
|
284
|
+
o.on("-h", "--help", "Show this help, including the agent recipe above.") { out.puts(o.help); opts[:help_shown] = true }
|
|
285
|
+
o.on("-v", "--version", "Print the sloplint-judge version.") { out.puts(VERSION); opts[:version_shown] = true }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "backend"
|
|
4
|
+
|
|
5
|
+
module Sloplint
|
|
6
|
+
module Judge
|
|
7
|
+
# The pairwise judge: which of two passages a plain-prose editor keeps,
|
|
8
|
+
# and optionally whether B changed what A says. See docs/JUDGE.md "compare".
|
|
9
|
+
module Compare
|
|
10
|
+
KEEP = "A careful editor who wants prose that is plain, specific and economical, and who distrusts polish " \
|
|
11
|
+
"for its own sake, must run exactly one of A and B %{place}. Which does the editor run?"
|
|
12
|
+
DRIFT = "Does B state any fact, claim or qualification that A does not, or drop any that A states? " \
|
|
13
|
+
"Answer yes if the meaning differs in any way a careful reader would notice, no if only the wording differs."
|
|
14
|
+
|
|
15
|
+
# The acceptance rule for a rewrite (keep B, drift at or under 0.5, the
|
|
16
|
+
# lower confidence at or above 0.7) belongs to the phase-two loop; this
|
|
17
|
+
# reports the inputs and applies nothing. See docs/JUDGE.md "compare".
|
|
18
|
+
Verdict = Data.define(:keep, :p_keep_b, :keep_confidence, :drift, :drift_confidence, :usage)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# a, b: the passages. place: the register as a venue. drift: also ask
|
|
23
|
+
# whether B changes what A says. The order sent is randomised because
|
|
24
|
+
# the judge has a position bias; the answer is mapped back.
|
|
25
|
+
def run(a, b, place:, backend: Backend.load, drift: false, swap: rand < 0.5)
|
|
26
|
+
first, second = swap ? [b, a] : [a, b]
|
|
27
|
+
questions = {
|
|
28
|
+
"keep" => { "type" => "choice", "instructions" => format(KEEP, place: place),
|
|
29
|
+
"criteria" => { "A" => "The editor runs passage A.", "B" => "The editor runs passage B." } }
|
|
30
|
+
}
|
|
31
|
+
questions["drift"] = { "type" => "noul", "instructions" => DRIFT } if drift
|
|
32
|
+
answers = backend.ask({ "A" => first, "B" => second }, questions)
|
|
33
|
+
keep = answers["keep"] or raise BackendError, "the backend answered no keep question"
|
|
34
|
+
p_b = probability(keep, swap ? "A" : "B")
|
|
35
|
+
d = answers["drift"]
|
|
36
|
+
Verdict.new(keep: p_b >= 0.5 ? "B" : "A", p_keep_b: p_b, keep_confidence: keep.confidence,
|
|
37
|
+
drift: d&.probabilities, drift_confidence: d&.confidence, usage: keep.usage)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The keep answer is a choice between the options named A and B. An
|
|
41
|
+
# answer keyed any other way is a malformed body, which ends `compare`
|
|
42
|
+
# with exit 3 like any other backend failure, not with a backtrace.
|
|
43
|
+
def probability(keep, option)
|
|
44
|
+
probs = keep.probabilities
|
|
45
|
+
raise BackendError, "the keep answer is #{probs.class}, not a probability for A and one for B" unless probs.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
probs.fetch(option) { raise BackendError, "the keep answer has no probability for #{option}" }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|