zer0-image-generator 0.6.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- metadata +43 -2
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
require_relative "../constants"
|
|
8
|
+
require_relative "../http"
|
|
9
|
+
require_relative "../logging"
|
|
10
|
+
|
|
11
|
+
module Zer0ImageGenerator
|
|
12
|
+
# Claude declined the request (Anthropic `stop_reason == "refusal"`). Carries
|
|
13
|
+
# the refusal category so callers can surface an actionable message rather
|
|
14
|
+
# than a generic failure.
|
|
15
|
+
class ClaudeRefusal < StandardError
|
|
16
|
+
attr_reader :category
|
|
17
|
+
|
|
18
|
+
def initialize(category)
|
|
19
|
+
@category = category
|
|
20
|
+
# `category or 'unspecified'`: both nil and "" degrade to "unspecified".
|
|
21
|
+
label = category.nil? || category == "" ? "unspecified" : category
|
|
22
|
+
super("request declined (category: #{label})")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Claude hit the max_tokens ceiling mid-answer (`stop_reason == "max_tokens"`).
|
|
27
|
+
# Distinct from a refusal so the runner can retry with a smaller ask instead
|
|
28
|
+
# of giving up. The partial text is carried as the message.
|
|
29
|
+
class ClaudeTruncated < StandardError; end
|
|
30
|
+
|
|
31
|
+
# Minimal Messages-API client honoring the repo's credential conventions.
|
|
32
|
+
#
|
|
33
|
+
# Modes (first match wins — mirrors templates/deploy/chat-proxy/worker.js):
|
|
34
|
+
# oauth CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_AUTH_TOKEN -> Bearer +
|
|
35
|
+
# `anthropic-beta: oauth-2025-04-20` + Claude Code first system block
|
|
36
|
+
# api_key ANTHROPIC_API_KEY -> x-api-key header
|
|
37
|
+
# cli `claude -p` headless (rides the local Claude Code login)
|
|
38
|
+
#
|
|
39
|
+
# `transport` is an injectable HTTP seam (defaults to the Http module); it must
|
|
40
|
+
# respond to `json(url, payload, headers, timeout:)`. `which` resolves an
|
|
41
|
+
# executable on PATH and is injectable so tests need not depend on whether the
|
|
42
|
+
# real `claude` binary is installed.
|
|
43
|
+
class AnthropicClient
|
|
44
|
+
attr_reader :mode, :token
|
|
45
|
+
|
|
46
|
+
def initialize(env = nil, transport: nil, which: nil)
|
|
47
|
+
# `dict(env if env is not None else os.environ)` — a snapshot, not a view.
|
|
48
|
+
@env = env.nil? ? ENV.to_h : env.to_h
|
|
49
|
+
@transport = transport || Http
|
|
50
|
+
@which = which || method(:default_which)
|
|
51
|
+
@mode = nil
|
|
52
|
+
@token = nil
|
|
53
|
+
|
|
54
|
+
if present?(@env["CLAUDE_CODE_OAUTH_TOKEN"])
|
|
55
|
+
@mode = "oauth"
|
|
56
|
+
@token = @env["CLAUDE_CODE_OAUTH_TOKEN"]
|
|
57
|
+
elsif present?(@env["ANTHROPIC_AUTH_TOKEN"])
|
|
58
|
+
@mode = "oauth"
|
|
59
|
+
@token = @env["ANTHROPIC_AUTH_TOKEN"]
|
|
60
|
+
elsif present?(@env["ANTHROPIC_API_KEY"])
|
|
61
|
+
@mode = "api_key"
|
|
62
|
+
@token = @env["ANTHROPIC_API_KEY"]
|
|
63
|
+
elsif @which.call("claude")
|
|
64
|
+
@mode = "cli"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def available?
|
|
69
|
+
!@mode.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def describe
|
|
73
|
+
{
|
|
74
|
+
"oauth" => "Claude Code OAuth token (Bearer)",
|
|
75
|
+
"api_key" => "Anthropic API key",
|
|
76
|
+
"cli" => "claude CLI (local Claude Code login)",
|
|
77
|
+
nil => "none"
|
|
78
|
+
}[@mode]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def headers
|
|
82
|
+
case @mode
|
|
83
|
+
when "oauth"
|
|
84
|
+
{
|
|
85
|
+
"Authorization" => "Bearer #{@token}",
|
|
86
|
+
"anthropic-version" => ANTHROPIC_VERSION,
|
|
87
|
+
"anthropic-beta" => OAUTH_BETA
|
|
88
|
+
}
|
|
89
|
+
when "api_key"
|
|
90
|
+
{
|
|
91
|
+
"x-api-key" => @token.to_s,
|
|
92
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
raise "no API headers for mode #{@mode}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# One Messages-API turn (or CLI run); returns concatenated text blocks.
|
|
100
|
+
# Raises ClaudeRefusal / ClaudeTruncated / HttpStatusError / RuntimeError.
|
|
101
|
+
def complete(system_text, user_text, model: DEFAULT_CLAUDE_MODEL,
|
|
102
|
+
max_tokens: CLAUDE_MAX_TOKENS, effort: "")
|
|
103
|
+
return complete_cli(system_text, user_text, model) if @mode == "cli"
|
|
104
|
+
raise "no Anthropic credential configured" if @mode.nil?
|
|
105
|
+
|
|
106
|
+
payload = {
|
|
107
|
+
"model" => model,
|
|
108
|
+
"max_tokens" => max_tokens,
|
|
109
|
+
"thinking" => { "type" => "adaptive" },
|
|
110
|
+
"system" => system_blocks(system_text),
|
|
111
|
+
"messages" => [{ "role" => "user", "content" => user_text }]
|
|
112
|
+
}
|
|
113
|
+
payload["output_config"] = { "effort" => effort } unless effort.to_s.empty?
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
data = api_call(payload, "Anthropic API")
|
|
117
|
+
rescue HttpStatusError => e
|
|
118
|
+
message = e.status == 400 ? e.error_message.downcase : ""
|
|
119
|
+
if e.status == 400 && (message.include?("output_config") || message.include?("effort"))
|
|
120
|
+
Logging.debug("Retrying without output_config parameter")
|
|
121
|
+
payload.delete("output_config")
|
|
122
|
+
data = api_call(payload, "Anthropic API")
|
|
123
|
+
elsif e.status == 400 && message.include?("thinking")
|
|
124
|
+
Logging.debug("Retrying without thinking parameter")
|
|
125
|
+
payload.delete("thinking")
|
|
126
|
+
data = api_call(payload, "Anthropic API")
|
|
127
|
+
elsif e.status == 401 && @mode == "oauth"
|
|
128
|
+
raise "Anthropic rejected the OAuth token (401). It may be expired " \
|
|
129
|
+
"— mint a fresh one with `claude setup-token`, or use " \
|
|
130
|
+
"ANTHROPIC_API_KEY instead."
|
|
131
|
+
else
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
stop_reason = data["stop_reason"]
|
|
137
|
+
if stop_reason == "refusal"
|
|
138
|
+
details = data["stop_details"] || {}
|
|
139
|
+
raise ClaudeRefusal.new(details.is_a?(Hash) ? details["category"] : nil)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
text = join_text(data)
|
|
143
|
+
raise ClaudeTruncated, text if stop_reason == "max_tokens"
|
|
144
|
+
|
|
145
|
+
text
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# One vision turn over a local PNG (review stage). CLI mode passes the file
|
|
149
|
+
# path and lets `claude -p` read it; API modes embed base64.
|
|
150
|
+
def complete_vision(system_text, user_text, image_path, model: DEFAULT_CLAUDE_MODEL,
|
|
151
|
+
max_tokens: 2048, effort: "")
|
|
152
|
+
if @mode == "cli"
|
|
153
|
+
prompt = "#{system_text}\n\n---\n\nFirst use the Read tool to view the " \
|
|
154
|
+
"image file at #{File.expand_path(image_path.to_s)} — then respond.\n\n#{user_text}"
|
|
155
|
+
return run_cli(prompt, model, allowed_tools: "Read")
|
|
156
|
+
end
|
|
157
|
+
raise "no Anthropic credential configured" if @mode.nil?
|
|
158
|
+
|
|
159
|
+
payload = {
|
|
160
|
+
"model" => model,
|
|
161
|
+
"max_tokens" => max_tokens,
|
|
162
|
+
"thinking" => { "type" => "adaptive" },
|
|
163
|
+
"system" => system_blocks(system_text),
|
|
164
|
+
"messages" => [{
|
|
165
|
+
"role" => "user",
|
|
166
|
+
"content" => [
|
|
167
|
+
{ "type" => "image", "source" => {
|
|
168
|
+
"type" => "base64", "media_type" => "image/png",
|
|
169
|
+
"data" => Base64.strict_encode64(File.binread(image_path.to_s))
|
|
170
|
+
} },
|
|
171
|
+
{ "type" => "text", "text" => user_text }
|
|
172
|
+
]
|
|
173
|
+
}]
|
|
174
|
+
}
|
|
175
|
+
payload["output_config"] = { "effort" => effort } unless effort.to_s.empty?
|
|
176
|
+
|
|
177
|
+
begin
|
|
178
|
+
data = api_call(payload, "Anthropic API (review)")
|
|
179
|
+
rescue HttpStatusError => e
|
|
180
|
+
message = e.status == 400 ? e.error_message.downcase : ""
|
|
181
|
+
if e.status == 400 && (message.include?("output_config") || message.include?("effort"))
|
|
182
|
+
Logging.debug("Retrying review without output_config parameter")
|
|
183
|
+
payload.delete("output_config")
|
|
184
|
+
data = api_call(payload, "Anthropic API (review)")
|
|
185
|
+
else
|
|
186
|
+
raise
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if data["stop_reason"] == "refusal"
|
|
191
|
+
details = data["stop_details"] || {}
|
|
192
|
+
raise ClaudeRefusal.new(details.is_a?(Hash) ? details["category"] : nil)
|
|
193
|
+
end
|
|
194
|
+
join_text(data)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# The Claude Code identity block is REQUIRED as the first system block for
|
|
200
|
+
# OAuth tokens (worker.js recipe) and intentionally omitted for plain API
|
|
201
|
+
# keys, matching the chat proxy's per-mode behavior.
|
|
202
|
+
def system_blocks(system_text)
|
|
203
|
+
blocks = [{ "type" => "text", "text" => system_text }]
|
|
204
|
+
blocks.unshift({ "type" => "text", "text" => CLAUDE_CODE_SYSTEM_PROMPT }) if @mode == "oauth"
|
|
205
|
+
blocks
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def api_call(payload, what)
|
|
209
|
+
Http.with_retries(what) do
|
|
210
|
+
@transport.json(ANTHROPIC_API_URL, payload, headers, timeout: 900)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def join_text(data)
|
|
215
|
+
(data["content"] || [])
|
|
216
|
+
.select { |block| block.is_a?(Hash) && block["type"] == "text" }
|
|
217
|
+
.map { |block| block["text"] || "" }
|
|
218
|
+
.join
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def complete_cli(system_text, user_text, model)
|
|
222
|
+
run_cli("#{system_text}\n\n---\n\n#{user_text}", model)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def run_cli(prompt, model, allowed_tools: nil)
|
|
226
|
+
cmd = ["claude", "-p", "--model", model, "--output-format", "text"]
|
|
227
|
+
cmd += ["--allowedTools", allowed_tools] if allowed_tools
|
|
228
|
+
begin
|
|
229
|
+
stdout, stderr, status = capture_cli(cmd, prompt, 300)
|
|
230
|
+
rescue SystemCallError, Timeout::Error => e
|
|
231
|
+
# Python catches (OSError, subprocess.TimeoutExpired) → RuntimeError.
|
|
232
|
+
raise "claude CLI failed: #{e}"
|
|
233
|
+
end
|
|
234
|
+
if !status.success? || stdout.strip.empty?
|
|
235
|
+
stripped = (stderr || "").strip
|
|
236
|
+
tail = stripped.length > 300 ? stripped[-300..] : stripped
|
|
237
|
+
raise "claude CLI exited #{status.exitstatus}: #{tail.empty? ? 'no output' : tail}"
|
|
238
|
+
end
|
|
239
|
+
stdout
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# subprocess.run(input=..., capture_output=True, text=True, timeout=300):
|
|
243
|
+
# feed stdin, drain stdout+stderr concurrently (a single sequential read can
|
|
244
|
+
# deadlock when the child fills the other pipe), and kill on timeout.
|
|
245
|
+
def capture_cli(cmd, input, seconds)
|
|
246
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
|
247
|
+
out_reader = Thread.new { stdout.read }
|
|
248
|
+
err_reader = Thread.new { stderr.read }
|
|
249
|
+
begin
|
|
250
|
+
Timeout.timeout(seconds) do
|
|
251
|
+
stdin.write(input)
|
|
252
|
+
stdin.close
|
|
253
|
+
[out_reader.value, err_reader.value, wait_thr.value]
|
|
254
|
+
end
|
|
255
|
+
rescue Timeout::Error
|
|
256
|
+
Process.kill("KILL", wait_thr.pid)
|
|
257
|
+
wait_thr.join
|
|
258
|
+
raise
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def present?(value)
|
|
264
|
+
!value.nil? && value != ""
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# shutil.which("claude"): search the real PATH for an executable, returning
|
|
268
|
+
# its path or nil. Reads the process environment (not @env) to match Python,
|
|
269
|
+
# whose shutil.which consults os.environ regardless of the client's env dict.
|
|
270
|
+
def default_which(program)
|
|
271
|
+
(ENV["PATH"] || "").split(File::PATH_SEPARATOR).each do |dir|
|
|
272
|
+
next if dir.empty?
|
|
273
|
+
|
|
274
|
+
candidate = File.join(dir, program)
|
|
275
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
276
|
+
end
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The 4-step credential chain, spelled out for the human when no Claude
|
|
282
|
+
# credential is configured. Precedence matches AnthropicClient's detection.
|
|
283
|
+
CLAUDE_CREDENTIAL_HINT =
|
|
284
|
+
"Claude orchestration (article analysis + image review) needs any ONE of:\n" \
|
|
285
|
+
" 1. CLAUDE_CODE_OAUTH_TOKEN — run `claude setup-token` (Claude Pro/Max)\n" \
|
|
286
|
+
" 2. ANTHROPIC_AUTH_TOKEN — short-lived Bearer (e.g. `ant auth print-credentials`)\n" \
|
|
287
|
+
" 3. ANTHROPIC_API_KEY — key from console.anthropic.com\n" \
|
|
288
|
+
" 4. the `claude` CLI installed and logged in (used automatically)\n" \
|
|
289
|
+
"Falling back to the template prompt and skipping review (or pass " \
|
|
290
|
+
"--prompt-engine template --review none to silence this)."
|
|
291
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../constants"
|
|
6
|
+
require_relative "../content"
|
|
7
|
+
require_relative "../logging"
|
|
8
|
+
|
|
9
|
+
module Zer0ImageGenerator
|
|
10
|
+
# The analyze + review stages: Claude ORCHESTRATES (writes the art-direction
|
|
11
|
+
# brief, vision-reviews the render), an image model RENDERS. Both stages are
|
|
12
|
+
# enhancement layers that degrade to the template prompt / an approval on any
|
|
13
|
+
# failure — neither may ever be a hard dependency of a generation run.
|
|
14
|
+
#
|
|
15
|
+
# The `client` argument is duck-typed against AnthropicClient (`complete`,
|
|
16
|
+
# `complete_vision`), so a Rails app can inject a fake without this module
|
|
17
|
+
# depending on the client class.
|
|
18
|
+
module Orchestration
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def claude_model_for(settings)
|
|
22
|
+
# `settings.claude_model or DEFAULT_CLAUDE_MODEL`: an empty configured
|
|
23
|
+
# model falls through to the engine default.
|
|
24
|
+
model = settings.claude_model
|
|
25
|
+
model.to_s.empty? ? DEFAULT_CLAUDE_MODEL : model
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Analyze stage (prompt_engine: claude): Claude reads the article and writes
|
|
29
|
+
# the art-direction brief the renderer will receive. Falls back to the
|
|
30
|
+
# template prompt on any failure.
|
|
31
|
+
def claude_article_brief(client, cf, settings, base_prompt)
|
|
32
|
+
excerpt = Content.collapse_whitespace((cf.content || "")[0, 1500])
|
|
33
|
+
article =
|
|
34
|
+
"ARTICLE\nTitle: #{Content.py_str(cf.title)}\n" \
|
|
35
|
+
"Description: #{none_if_blank(cf.description)}\n" \
|
|
36
|
+
"Categories/tags: #{none_if_blank(cf.categories)}\n" \
|
|
37
|
+
"Excerpt: #{none_if_blank(excerpt)}\n\n" \
|
|
38
|
+
"MANDATORY STYLE DIRECTIONS\nArt style: #{Content.py_str(settings.style)}\n" \
|
|
39
|
+
"Style modifiers: #{none_if_blank(settings.style_modifiers)}\n\n" \
|
|
40
|
+
"Write the image-generation prompt now."
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
text = py_strip(client.complete(
|
|
44
|
+
ART_DIRECTOR_SYSTEM, article,
|
|
45
|
+
model: claude_model_for(settings), max_tokens: 2048,
|
|
46
|
+
effort: settings.claude_effort
|
|
47
|
+
))
|
|
48
|
+
unless text.empty?
|
|
49
|
+
Logging.debug("Claude art-direction brief: #{text[0, 300]}...")
|
|
50
|
+
return text
|
|
51
|
+
end
|
|
52
|
+
Logging.warn("Claude analysis returned empty text; using template prompt")
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Logging.warn("Claude analysis failed (#{e.message}); using template prompt")
|
|
55
|
+
end
|
|
56
|
+
base_prompt
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Tolerant JSON extraction: the reviewer is told "JSON only" but models
|
|
60
|
+
# occasionally wrap prose or fences around it, so we take the outermost
|
|
61
|
+
# brace pair and parse that.
|
|
62
|
+
def extract_json_object(text)
|
|
63
|
+
start = text.index("{")
|
|
64
|
+
finish = text.rindex("}")
|
|
65
|
+
return nil if start.nil? || finish.nil? || finish <= start
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
data = JSON.parse(text[start..finish])
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
data.is_a?(Hash) ? data : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Review stage (review_engine: claude): Claude inspects the rendered banner.
|
|
76
|
+
# Returns [approved, critique, revised_prompt]. Any failure counts as
|
|
77
|
+
# approval — review must never block generation.
|
|
78
|
+
def claude_review_image(client, image_path, cf, prompt, settings)
|
|
79
|
+
mods = settings.style_modifiers
|
|
80
|
+
style_line = Content.py_str(settings.style) +
|
|
81
|
+
(Content.truthy?(mods) ? "; #{Content.py_str(mods)}" : "")
|
|
82
|
+
context =
|
|
83
|
+
"ARTICLE\nTitle: #{Content.py_str(cf.title)}\n" \
|
|
84
|
+
"Description: #{none_if_blank(cf.description)}\n" \
|
|
85
|
+
"Categories/tags: #{none_if_blank(cf.categories)}\n\n" \
|
|
86
|
+
"REQUIRED STYLE\n#{style_line}" \
|
|
87
|
+
"\n\nPROMPT THE IMAGE WAS GENERATED FROM\n#{Content.py_str(prompt)}\n\n" \
|
|
88
|
+
"Review the image above against the article and style. JSON only."
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
text = client.complete_vision(
|
|
92
|
+
REVIEWER_SYSTEM, context, image_path,
|
|
93
|
+
model: claude_model_for(settings), effort: settings.claude_effort
|
|
94
|
+
)
|
|
95
|
+
data = extract_json_object(text)
|
|
96
|
+
unless data
|
|
97
|
+
Logging.warn("Claude review returned no parseable verdict; keeping image")
|
|
98
|
+
return [true, "", ""]
|
|
99
|
+
end
|
|
100
|
+
verdict = Content.py_str(data.fetch("verdict", "approve")).downcase
|
|
101
|
+
critique = py_strip(Content.py_str(data.fetch("critique", "")))
|
|
102
|
+
revised = py_strip(Content.py_str(data.fetch("revised_prompt", "")))
|
|
103
|
+
return [false, critique, revised] if verdict == "revise" && !revised.empty?
|
|
104
|
+
|
|
105
|
+
[true, critique, ""]
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
Logging.warn("Claude review failed (#{e.message}); keeping image")
|
|
108
|
+
[true, "", ""]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `value or '(none)'` with CPython truthiness: nil, false, and "" become
|
|
113
|
+
# the "(none)" placeholder; everything else prints via str().
|
|
114
|
+
def none_if_blank(value)
|
|
115
|
+
Content.truthy?(value) ? Content.py_str(value) : "(none)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `str.strip()` with CPython's whitespace set. Reuses the content layer's
|
|
119
|
+
# single source of truth for that set rather than duplicating the codepoint
|
|
120
|
+
# table (Ruby's own String#strip is ASCII-only and also eats NUL, which
|
|
121
|
+
# Python's does not). Unlike collapse_whitespace this trims edges only.
|
|
122
|
+
PY_STRIP_RE = /\A[#{Content::PY_SPACE_CLASS}]+|[#{Content::PY_SPACE_CLASS}]+\z/.freeze
|
|
123
|
+
|
|
124
|
+
def py_strip(text)
|
|
125
|
+
text.to_s.gsub(PY_STRIP_RE, "")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|