recala-cli 0.6.0__py3-none-any.whl

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 (67) hide show
  1. recala_cli/__init__.py +3 -0
  2. recala_cli/__main__.py +3 -0
  3. recala_cli/article_runs.py +372 -0
  4. recala_cli/auth.py +196 -0
  5. recala_cli/client.py +103 -0
  6. recala_cli/commands/__init__.py +1 -0
  7. recala_cli/commands/article.py +835 -0
  8. recala_cli/commands/auth.py +207 -0
  9. recala_cli/commands/billing.py +76 -0
  10. recala_cli/commands/bing.py +164 -0
  11. recala_cli/commands/cadence.py +217 -0
  12. recala_cli/commands/connect.py +395 -0
  13. recala_cli/commands/credits.py +46 -0
  14. recala_cli/commands/faithful_translations.py +70 -0
  15. recala_cli/commands/gsc.py +79 -0
  16. recala_cli/commands/index.py +293 -0
  17. recala_cli/commands/keys.py +98 -0
  18. recala_cli/commands/knowledge.py +245 -0
  19. recala_cli/commands/languages.py +119 -0
  20. recala_cli/commands/monthly_report.py +152 -0
  21. recala_cli/commands/notifications.py +106 -0
  22. recala_cli/commands/onboard.py +237 -0
  23. recala_cli/commands/org.py +352 -0
  24. recala_cli/commands/profile.py +70 -0
  25. recala_cli/commands/questions.py +246 -0
  26. recala_cli/commands/reads.py +320 -0
  27. recala_cli/commands/refresh.py +138 -0
  28. recala_cli/commands/refusals.py +20 -0
  29. recala_cli/commands/schema.py +47 -0
  30. recala_cli/commands/shell.py +81 -0
  31. recala_cli/commands/skills.py +92 -0
  32. recala_cli/commands/slot_topic.py +94 -0
  33. recala_cli/commands/status.py +153 -0
  34. recala_cli/commands/topics.py +328 -0
  35. recala_cli/config.py +176 -0
  36. recala_cli/confirm.py +137 -0
  37. recala_cli/display.py +40 -0
  38. recala_cli/endpoints.py +258 -0
  39. recala_cli/errors.py +220 -0
  40. recala_cli/human.py +324 -0
  41. recala_cli/ids.py +139 -0
  42. recala_cli/labels.py +74 -0
  43. recala_cli/main.py +129 -0
  44. recala_cli/onboarding.py +522 -0
  45. recala_cli/organizations.py +197 -0
  46. recala_cli/output.py +34 -0
  47. recala_cli/parallel.py +18 -0
  48. recala_cli/polling.py +102 -0
  49. recala_cli/prompts.py +88 -0
  50. recala_cli/runtime.py +216 -0
  51. recala_cli/search_numbers.py +22 -0
  52. recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
  53. recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
  54. recala_cli/skills/recala-cli-contract.md +78 -0
  55. recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
  56. recala_cli/skills/recala-onboard/SKILL.md +25 -0
  57. recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
  58. recala_cli/skills/recala-plan-month/SKILL.md +33 -0
  59. recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
  60. recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
  61. recala_cli/ui.py +260 -0
  62. recala_cli/updates.py +43 -0
  63. recala_cli/views.py +372 -0
  64. recala_cli-0.6.0.dist-info/METADATA +63 -0
  65. recala_cli-0.6.0.dist-info/RECORD +67 -0
  66. recala_cli-0.6.0.dist-info/WHEEL +4 -0
  67. recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,835 @@
1
+ """`recala article`: read, edit, AI-edit, hand over for review, share. (Approving and publishing: slice 4.)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from hashlib import sha256
7
+ from datetime import datetime
8
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+ from recala_cli.client import RecalaClient
15
+ from recala_cli.ids import id_arguments, resolve_article_id, resolve_person_id, resolve_topic_id
16
+ from recala_cli.endpoints import (
17
+ APPOINTMENT_BOOK,
18
+ APPOINTMENT_CANCEL,
19
+ ARTICLE_APPROVE,
20
+ ARTICLE_REGENERATE,
21
+ ARTICLE_RECHECK,
22
+ ARTICLE_BODY,
23
+ ARTICLE_COMMENTS,
24
+ ARTICLE_PERSON,
25
+ ARTICLE_READINESS,
26
+ ARTICLE_REDRAFT_APPLY,
27
+ ARTICLE_REDRAFT_LATEST,
28
+ ARTICLE_REDRAFT_START,
29
+ ARTICLE_REPUBLISH,
30
+ ARTICLE_REQUEST_CHANGES,
31
+ ARTICLE_SHOW,
32
+ ARTICLE_SUBMIT,
33
+ ARTICLE_UNPUBLISH,
34
+ ARTICLE_VERSION,
35
+ ARTICLE_VERSIONS,
36
+ ARTICLES_LIST,
37
+ BILLING_CREDITS,
38
+ COST_PREVIEW,
39
+ GENERATE_FROM_TOPIC,
40
+ REVIEW_LINK_CREATE,
41
+ REVIEW_LINK_REVOKE,
42
+ REVIEW_LINK_SHOW,
43
+ SHARE_TOKEN_ROTATE,
44
+ )
45
+ from recala_cli.display import display_name, terminal_text
46
+ from recala_cli import config
47
+ from recala_cli.errors import CliError, ExitCode
48
+ from recala_cli.human import (
49
+ RECHECK_VERDICT_CODES, cost_sentence, describe_local_fields, describe_comments, describe_creation, describe_cost_refusal, describe_edit, describe_fields, describe_schedule,
50
+ describe_recheck, publish_state_words, recheck_findings,
51
+ )
52
+ from recala_cli.labels import action_sentence, named_local_result, named_result, resource_label
53
+ from recala_cli.views import company_timezone, local_time
54
+ from recala_cli.polling import indicator, poll_until
55
+ from recala_cli.article_runs import (describe_run, ensure_outcome, failed_rerun, hold_text, resume_command,
56
+ wait_for_article)
57
+ from recala_cli.runtime import (
58
+ accepted_yes_option, dry_run_option,
59
+ command_runner, common_options, confirm, ignored_yes_option, open_client, optional_read, yes_option,
60
+ )
61
+
62
+
63
+ def project_article(detail: dict[str, Any], *, markdown: bool = False) -> dict[str, Any]:
64
+ """The CLI's promise about one article; the API's full payload stays behind it."""
65
+ article = detail.get("article") or {}
66
+ shown = {
67
+ "id": article.get("id"),
68
+ "title": article.get("article_title") or article.get("topic"),
69
+ "topic": article.get("topic"),
70
+ "publish_status": article.get("cms_publish_status"),
71
+ "review_status": article.get("review_status"),
72
+ "person_id": article.get("person_id"),
73
+ "word_count": article.get("word_count"),
74
+ "grade": article.get("evaluation_grade") or None, # the API stores "" for an article it never graded
75
+ "score": article.get("evaluation_score"),
76
+ "created_at": article.get("created_at"),
77
+ "hold": detail.get("hold"),
78
+ "brand_asks": detail.get("brand_asks") or [],
79
+ "change_requests": detail.get("revision_requests") or [],
80
+ }
81
+ if article.get("language"):
82
+ shown["language"] = article["language"]
83
+ if markdown:
84
+ shown["markdown"] = article.get("markdown_content") or ""
85
+ return shown
86
+
87
+
88
+ _optional = optional_read
89
+
90
+
91
+ @click.group()
92
+ def article() -> None:
93
+ """Articles: read, edit, AI-edit, hand over for review, share."""
94
+
95
+
96
+ def article_date(row):
97
+ return str(row.get("published_at") or row.get("scheduled_at") or row.get("created_at") or "")
98
+
99
+
100
+ def _status_of(row):
101
+ return str(row.get("cms_publish_status") or row.get("review_status") or "draft").replace("_", " ")
102
+
103
+
104
+ def describe_articles(ctx, result):
105
+ zone = company_timezone(ctx, result)
106
+ rows = result["articles"]
107
+ lines = [f"{local_time(article_date(a), zone)} {_status_of(a)} "
108
+ f"{a.get('article_title') or a.get('topic')} "
109
+ f"{('[' + a['language'] + '] ') if a.get('language') else ''}[{str(a['id'])[:8]}]" for a in rows]
110
+ total = result.get("total")
111
+ if isinstance(total, int):
112
+ lines.append(f"{len(rows)} of {total} articles" if len(rows) != total else f"{total} articles")
113
+ return "\n".join(lines)
114
+
115
+
116
+ @article.command("list")
117
+ @common_options
118
+ @command_runner(contextual_human=describe_articles)
119
+ @click.option("--limit", type=click.IntRange(1, 100), default=50, show_default=True)
120
+ @click.option("--offset", type=click.IntRange(0), default=0, show_default=True)
121
+ @click.option("--status", default=None, help="Only articles in this state, e.g. published or draft.")
122
+ @click.option("--language", default=None, help="Only articles in this language, e.g. de.")
123
+ def list_articles(ctx: click.Context, org_flag: str | None, limit: int, offset: int, status: str | None,
124
+ language: str | None) -> dict[str, Any]:
125
+ """The company's articles, newest first."""
126
+ listed: dict[str, Any] = open_client(ctx, org_flag).call(ARTICLES_LIST, params={"limit": limit, "offset": offset})
127
+ rows = [a for a in listed.get("articles") or []
128
+ if (not status or _status_of(a) == status.replace("_", " ").lower())
129
+ and (not language or str(a.get("language") or "").lower() == language.lower())]
130
+ listed["articles"] = sorted(rows, key=article_date, reverse=True)
131
+ return listed
132
+
133
+
134
+ @article.command("show")
135
+ @common_options
136
+ @command_runner(contextual_human=lambda ctx, a: f"{a['title']}\n{article_state_line(a)}"
137
+ + change_request_lines(a["change_requests"])
138
+ + (f"\n\n{a['markdown']}" if "markdown" in a else "")
139
+ + ("\n" + hold_text(ctx.meta["shown_article"]["hold"], a["id"], ctx=ctx)
140
+ if a.get("hold") else ""))
141
+ @click.option("--markdown", is_flag=True, help="Include the article's text.")
142
+ @click.argument("article_id")
143
+ @id_arguments(article_id="article")
144
+ def show(ctx: click.Context, org_flag: str | None, markdown: bool, article_id: str) -> dict[str, Any]:
145
+ """One article: status, author, hold, questions.
146
+
147
+ The open questions too, and the text with --markdown."""
148
+ client = open_client(ctx, org_flag)
149
+ article_id = resolve_article_id(client, article_id)
150
+ detail = client.call(ARTICLE_SHOW, path={"article_id": article_id})
151
+ shown = project_article(detail, markdown=markdown)
152
+ ctx.meta["shown_article"] = shown
153
+ return shown
154
+
155
+
156
+ def article_state_line(shown: dict[str, Any]) -> str:
157
+ """Grade, review, publishing and length in words: never a snake_case state or "None"."""
158
+ grade = f"Grade {shown['grade']}" if shown.get("grade") else "Not graded"
159
+ review = str(shown.get("review_status") or "draft").replace("_", " ")
160
+ published = publish_state_words(shown.get("publish_status"))
161
+ words = f" · {shown['word_count']} words" if shown.get("word_count") is not None else ""
162
+ return f" {grade} · {review} · {published}{words}"
163
+
164
+
165
+ def change_request_lines(requests: list[dict[str, Any]]) -> str:
166
+ """What the reviewer asked to change, open ones first; nothing when there were none."""
167
+ if not requests:
168
+ return ""
169
+ ordered = sorted(requests, key=lambda request: request.get("resolved_at") is not None)
170
+ open_count = sum(request.get("resolved_at") is None for request in requests)
171
+ lines = [f"Change requests ({open_count} open):"]
172
+ for request in ordered:
173
+ state = "Open" if request.get("resolved_at") is None else "Resolved"
174
+ section = f" ({request['section_key']})" if request.get("section_key") else ""
175
+ lines.append(f" {state}{section}: {' '.join(str(request.get('feedback') or '').split())}")
176
+ return "\n" + "\n".join(lines)
177
+
178
+
179
+ def describe_article_status(ctx, result):
180
+ shown = ctx.meta["shown_article"]
181
+ last_rerun = result.get("last_rerun")
182
+ return (f"{shown['title']}\nAuthor: {ctx.meta.get('author_label') or 'not configured'}\n"
183
+ + describe_fields({k: v for k, v in result.items() if k not in {"id", "person_id", "last_rerun"}})
184
+ + (f"\nLast re-run: {display_name(last_rerun.get('message'))}" if last_rerun else "")
185
+ + ("\n" + hold_text(shown["hold"], shown["id"], ctx=ctx) if shown.get("hold") else ""))
186
+
187
+
188
+ @article.command("status")
189
+ @common_options
190
+ @command_runner(contextual_human=describe_article_status)
191
+ @click.argument("article_id")
192
+ @id_arguments(article_id="article")
193
+ def status(ctx: click.Context, org_flag: str | None, article_id: str) -> dict[str, Any]:
194
+ """Where the article stands now.
195
+
196
+ Review, publishing, author, hold, versions and review link."""
197
+ client = open_client(ctx, org_flag)
198
+ article_id = resolve_article_id(client, article_id)
199
+ detail = client.call(ARTICLE_SHOW, path={"article_id": article_id})
200
+ shown = project_article(detail)
201
+ ctx.meta["shown_article"] = shown
202
+ if shown.get("person_id"):
203
+ ctx.meta["author_label"] = resource_label(ctx, client, "person", shown["person_id"], result_subject=False)
204
+ versions = _optional(client, ARTICLE_VERSIONS, article_id=article_id)
205
+ link = _optional(client, REVIEW_LINK_SHOW, article_id=article_id)
206
+ return {
207
+ "id": shown["id"],
208
+ "review_status": shown["review_status"],
209
+ "publish_status": shown["publish_status"],
210
+ "person_id": shown["person_id"],
211
+ "grade": shown["grade"],
212
+ "score": shown["score"],
213
+ "held": shown["hold"] is not None,
214
+ "versions": None if versions is None else len(versions.get("versions") or []),
215
+ "review_token": None if link is None else link.get("review_token"),
216
+ **({"last_rerun": last_rerun} if (last_rerun := failed_rerun(detail.get("recovery"))) else {}),
217
+ }
218
+
219
+
220
+ @article.command("versions")
221
+ @common_options
222
+ @command_runner(contextual_human=named_local_result(describe_local_fields))
223
+ @click.option("--show", "number", type=click.IntRange(1), default=None, help="One version's text and changes.")
224
+ @click.argument("article_id")
225
+ @id_arguments(article_id="article")
226
+ def versions(ctx: click.Context, org_flag: str | None, number: int | None, article_id: str) -> dict[str, Any]:
227
+ """Every saved version of the article.
228
+
229
+ --show N prints one of them."""
230
+ client = open_client(ctx, org_flag)
231
+ article_id = resolve_article_id(client, article_id)
232
+ resource_label(ctx, client, "article", article_id)
233
+ if number is None:
234
+ listed: dict[str, Any] = client.call(ARTICLE_VERSIONS, path={"article_id": article_id})
235
+ return listed
236
+ one: dict[str, Any] = client.call(ARTICLE_VERSION, path={"article_id": article_id, "number": str(number)})
237
+ return one
238
+
239
+
240
+ def describe_link_warnings(result: dict[str, Any]) -> str:
241
+ lines = [f"Link warning (line {warning['line']}): {warning['url']} — {warning['reason']}"
242
+ for warning in result.get('link_warnings') or []]
243
+ if result.get('link_policy_error'):
244
+ lines.append('Link policy could not be checked; the edit was saved.')
245
+ return ''.join('\n' + line for line in lines)
246
+
247
+
248
+ @article.command("edit")
249
+ @common_options
250
+ @command_runner(contextual_human=named_result(lambda result: describe_edit(result) + describe_link_warnings(result)))
251
+ @click.option("--body-file", type=click.Path(exists=True, dir_okay=False, path_type=Path),
252
+ help="Replace the text with this Markdown file.")
253
+ @click.option("--ai", "notes", multiple=True, help="An AI edit: one note per --ai (a paid model call; 3 per article).")
254
+ @click.option("--wait", is_flag=True, help="With --ai: wait until the AI draft is ready.")
255
+ @click.option("--timeout", type=click.IntRange(10, 3600), default=600, show_default=True)
256
+ @click.option("--apply", is_flag=True,
257
+ help="Keep the finished AI draft's changes (all but --undo); this also hands the article over for review.")
258
+ @click.option("--undo", default="", help="With --apply: comma-separated change numbers to undo, e.g. 2,5.")
259
+ @click.argument("article_id")
260
+ @id_arguments(article_id="article")
261
+ @accepted_yes_option
262
+ @dry_run_option
263
+ def edit(ctx: click.Context, org_flag: str | None, body_file: Path | None, notes: tuple[str, ...], wait: bool, timeout: int,
264
+ apply: bool, undo: str, article_id: str) -> dict[str, Any]:
265
+ """Edit the text, or ask for and keep an AI edit.
266
+
267
+ --body-file replaces the text, --ai "notes" asks for an AI edit, --apply keeps the AI draft."""
268
+ chosen = [bool(body_file), bool(notes), apply]
269
+ if sum(chosen) != 1:
270
+ raise click.UsageError("Choose one: --body-file, --ai or --apply.")
271
+ client = open_client(ctx, org_flag)
272
+ article_id = resolve_article_id(client, article_id)
273
+ path = {"article_id": article_id}
274
+ label = resource_label(ctx, client, "article", article_id)
275
+ if body_file:
276
+ confirm(ctx, client, f"Replace the text of {label} with {body_file.name}?")
277
+ edited: dict[str, Any] = client.call(ARTICLE_BODY, path=path, json={"markdown_content": body_file.read_text()})
278
+ return edited
279
+ if apply:
280
+ latest = client.call(ARTICLE_REDRAFT_LATEST, path=path).get("redraft") or {}
281
+ if not latest.get("id"):
282
+ raise CliError(ExitCode.CONFLICT, "no_redraft", "This article has no AI draft to keep.")
283
+ choices = {number.strip(): "undo" for number in undo.split(",") if number.strip()}
284
+ confirm(ctx, client, f"Keep the AI draft's changes on {label}? This also hands it over for review.")
285
+ applied: dict[str, Any] = client.call(
286
+ ARTICLE_REDRAFT_APPLY, path={**path, "redraft_id": latest["id"]}, json={"choices": choices}
287
+ )
288
+ return applied
289
+ confirm(ctx, client, f"Ask for an AI edit of {label} (a paid model call)?")
290
+ started: dict[str, Any] = client.call(ARTICLE_REDRAFT_START, path=path, json={"notes": list(notes)})
291
+ if not wait:
292
+ return started
293
+ return wait_for_redraft(ctx, client, path, timeout, label=f"Writing the AI draft of {label}")
294
+
295
+
296
+ def wait_for_redraft(ctx: click.Context, client: RecalaClient, path: dict[str, str], timeout: int, *,
297
+ label: str = "Writing the AI draft") -> dict[str, Any]:
298
+ """Watch the AI edit with the shared pacing: a read a minute, a pause after a slow read, none in tick minutes."""
299
+
300
+ def read() -> dict[str, Any]:
301
+ current: dict[str, Any] = client.call(ARTICLE_REDRAFT_LATEST, path=path)
302
+ redraft = current.get("redraft") or {}
303
+ if redraft.get("status") == "failed": # raised inside, so the wait ends as stopped, never as done
304
+ error = redraft.get("error") or {}
305
+ reason = error.get("message") or "Recala gave no reason."
306
+ if error.get("reason"):
307
+ reason = f"{reason} ({error['reason']})"
308
+ raise CliError(ExitCode.CONFLICT, "redraft_failed", f"The AI edit failed: {reason}", details=current)
309
+ return current
310
+
311
+ return poll_until(ctx, read, lambda current: (current.get("redraft") or {}).get("status") != "running",
312
+ label=label, timeout_seconds=timeout)
313
+
314
+
315
+ @article.command("person")
316
+ @common_options
317
+ @command_runner(contextual_human=action_sentence("Author assigned to"))
318
+ @click.option("--person", "person_id", required=True, help="The author (recala authors list) who signs this article.")
319
+ @click.argument("article_id")
320
+ @id_arguments(article_id="article")
321
+ @accepted_yes_option
322
+ @dry_run_option
323
+ def person(ctx: click.Context, org_flag: str | None, person_id: str, article_id: str) -> dict[str, Any]:
324
+ """Link the article to the named person who will sign it."""
325
+ client = open_client(ctx, org_flag)
326
+ article_id = resolve_article_id(client, article_id)
327
+ person_id = resolve_person_id(client, person_id)
328
+ label = resource_label(ctx, client, "article", article_id)
329
+ author_label = resource_label(ctx, client, "person", person_id, result_subject=False)
330
+ confirm(ctx, client, f"Make {author_label} the author who signs {label}?",
331
+ machine_summary=f"Make {person_id} the author who signs article {article_id}?")
332
+ linked: dict[str, Any] = client.call(ARTICLE_PERSON, path={"article_id": article_id}, json={"person_id": person_id})
333
+ return linked
334
+
335
+
336
+ @article.command("submit")
337
+ @common_options
338
+ @command_runner(contextual_human=action_sentence("In review"))
339
+ @click.argument("article_id")
340
+ @id_arguments(article_id="article")
341
+ @accepted_yes_option
342
+ @dry_run_option
343
+ def submit(ctx: click.Context, org_flag: str | None, article_id: str) -> dict[str, Any]:
344
+ """Hand the article over for the person's review."""
345
+ client = open_client(ctx, org_flag)
346
+ article_id = resolve_article_id(client, article_id)
347
+ label = resource_label(ctx, client, "article", article_id)
348
+ confirm(ctx, client, f"Hand {label} over for review?")
349
+ submitted: dict[str, Any] = client.call(ARTICLE_SUBMIT, path={"article_id": article_id})
350
+ return submitted
351
+
352
+
353
+ @article.command("request-changes")
354
+ @common_options
355
+ @command_runner(contextual_human=action_sentence("Changes requested for"))
356
+ @click.option("--note", required=True, help="What should change.")
357
+ @click.argument("article_id")
358
+ @id_arguments(article_id="article")
359
+ @accepted_yes_option
360
+ @dry_run_option
361
+ def request_changes(ctx: click.Context, org_flag: str | None, note: str, article_id: str) -> dict[str, Any]:
362
+ """Send the article back with a change request."""
363
+ client = open_client(ctx, org_flag)
364
+ article_id = resolve_article_id(client, article_id)
365
+ label = resource_label(ctx, client, "article", article_id)
366
+ confirm(ctx, client, f"Send {label} back with your change request?")
367
+ requested: dict[str, Any] = client.call(ARTICLE_REQUEST_CHANGES, path={"article_id": article_id},
368
+ json={"feedback": note})
369
+ return requested
370
+
371
+
372
+ @article.command("comments")
373
+ @common_options
374
+ @command_runner(contextual_human=named_result(describe_comments))
375
+ @click.argument("article_id")
376
+ @id_arguments(article_id="article")
377
+ def comments(ctx: click.Context, org_flag: str | None, article_id: str) -> dict[str, Any]:
378
+ """Comments left through the review link."""
379
+ client = open_client(ctx, org_flag)
380
+ article_id = resolve_article_id(client, article_id)
381
+ resource_label(ctx, client, "article", article_id)
382
+ listed: dict[str, Any] = client.call(ARTICLE_COMMENTS, path={"article_id": article_id})
383
+ return listed
384
+
385
+
386
+ @article.command("share")
387
+ @common_options
388
+ @command_runner(contextual_human=named_local_result(describe_local_fields))
389
+ @ignored_yes_option
390
+ @click.option("--review-link", "create", is_flag=True, help="Turn the review link on (or replace it).")
391
+ @click.option("--rotate", is_flag=True, help="Replace the article's share token; the old link stops working.")
392
+ @click.option("--revoke", is_flag=True, help="Turn the review link off.")
393
+ @click.argument("article_id")
394
+ @id_arguments(article_id="article")
395
+ @dry_run_option
396
+ def share(ctx: click.Context, org_flag: str | None, yes: bool, create: bool, rotate: bool, revoke: bool,
397
+ article_id: str) -> dict[str, Any]:
398
+ """Show or manage the article's review link.
399
+
400
+ --review-link makes one; --rotate / --revoke kill the old one (after you confirm)."""
401
+ if sum([create, rotate, revoke]) > 1:
402
+ raise click.UsageError("Choose one of --review-link, --rotate, --revoke.")
403
+ client = open_client(ctx, org_flag)
404
+ article_id = resolve_article_id(client, article_id)
405
+ label = resource_label(ctx, client, "article", article_id)
406
+ if rotate or revoke:
407
+ confirm(ctx, client, f"Anyone holding the current link for {label} will lose access. Go ahead?", yes=yes,
408
+ machine_summary="Anyone holding the current link will lose access. Go ahead?")
409
+ elif create:
410
+ confirm(ctx, client, f"Turn on the review link for {label}?")
411
+ endpoint = (REVIEW_LINK_CREATE if create else SHARE_TOKEN_ROTATE if rotate
412
+ else REVIEW_LINK_REVOKE if revoke else REVIEW_LINK_SHOW)
413
+ answer: dict[str, Any] = client.call(endpoint, path={"article_id": article_id})
414
+ if answer.get("review_token"): # the page the web app serves for a share token: {web}/share/{token}
415
+ answer = {**answer, "review_url": f"{config.web_url()}/share/{answer['review_token']}"}
416
+ return answer
417
+
418
+
419
+ # ── slice 4: approving, publishing, spending ─────────────────────────────────
420
+
421
+ #: Starting a run and each status read use short, independent HTTP requests.
422
+ _GENERATION_TIMEOUT_SECONDS = 60.0
423
+
424
+
425
+ @article.command("approve")
426
+ @common_options
427
+ @command_runner(contextual_human=action_sentence("Approved"))
428
+ @yes_option
429
+ @click.option("--read", is_flag=True, help="Show the current text and acknowledge reading it before approval.")
430
+ @click.option(
431
+ "--reconfirm-current", is_flag=True,
432
+ help=("Refresh approval of an already-approved original; "
433
+ "requires a human session and current checks."),
434
+ )
435
+ @click.argument("article_id")
436
+ @id_arguments(article_id="article")
437
+ @dry_run_option
438
+ def approve(
439
+ ctx: click.Context, org_flag: str | None, yes: bool, article_id: str,
440
+ read: bool = False, reconfirm_current: bool = False,
441
+ ) -> dict[str, Any]:
442
+ """Approve the article; its named author signs it.
443
+
444
+ A key needs the publish scope; its owner is on record."""
445
+ client = open_client(ctx, org_flag)
446
+ article_id = resolve_article_id(client, article_id)
447
+ return approve_article(
448
+ ctx, client, article_id, yes=yes, read=read, reconfirm_current=reconfirm_current
449
+ )
450
+
451
+
452
+ def approve_article(
453
+ ctx: click.Context, client: RecalaClient, article_id: str, *,
454
+ yes: bool = False, read: bool = False, reconfirm_current: bool = False,
455
+ ) -> dict[str, Any]:
456
+ label = resource_label(ctx, client, "article", article_id)
457
+ payload = {}
458
+ if read:
459
+ detail = client.call(ARTICLE_SHOW, path={"article_id": article_id})
460
+ current_text = (detail.get("article") or {}).get("markdown_content")
461
+ if not isinstance(current_text, str) or not current_text.strip():
462
+ raise CliError(ExitCode.CONFLICT, "article_body_unavailable", "The current text is unavailable. Read the saved article before approving it.")
463
+ # What the person reads is sanitized; the receipt below hashes the exact stored body.
464
+ click.echo(terminal_text(current_text, keep_layout=True), err=True)
465
+ payload["json"] = {"read_receipt": {"body_sha256": sha256(current_text.encode("utf-8")).hexdigest()}}
466
+ if reconfirm_current:
467
+ payload.setdefault("json", {})["reconfirm_current"] = True
468
+ confirm(ctx, client, f"Approve {label}? Approving is the author's signature on it.", publishes=True, yes=yes)
469
+ try:
470
+ approved: dict[str, Any] = client.call(ARTICLE_APPROVE, path={"article_id": article_id}, **payload)
471
+ except CliError as error:
472
+ if error.code == "invalid_transition" and (error.details or {}).get("from") == "draft":
473
+ selector = article_id if ctx.obj.get("as_json") else article_id[:8]
474
+ error.message = f"Submit it for review first: recala article submit {selector}"
475
+ elif error.code == "below_minimum_grade_unread":
476
+ selector = article_id if ctx.obj.get("as_json") else article_id[:8]
477
+ error.message = (f"Read the current text with recala article show {selector} --markdown, "
478
+ f"then acknowledge it with recala article approve {selector} --read.")
479
+ raise
480
+ return approved
481
+
482
+
483
+ @article.command("unpublish")
484
+ @common_options
485
+ @command_runner(contextual_human=action_sentence("Unpublished"))
486
+ @yes_option
487
+ @click.argument("article_id")
488
+ @id_arguments(article_id="article")
489
+ @dry_run_option
490
+ def unpublish(ctx: click.Context, org_flag: str | None, yes: bool, article_id: str) -> dict[str, Any]:
491
+ """Take a published article off the site (the CMS first)."""
492
+ client = open_client(ctx, org_flag)
493
+ article_id = resolve_article_id(client, article_id)
494
+ label = resource_label(ctx, client, "article", article_id)
495
+ confirm(ctx, client, f"Unpublish {label}? This takes the page off the site.", publishes=True, yes=yes)
496
+ done: dict[str, Any] = client.call(ARTICLE_UNPUBLISH, path={"article_id": article_id})
497
+ return done
498
+
499
+
500
+ def republish_preview(detail: dict[str, Any]) -> dict[str, Any]:
501
+ """Preview the stored article; the POST still enforces the server's publish gates."""
502
+ shown = project_article(detail)
503
+ cms_response = (detail.get("article") or {}).get("cms_response") or {}
504
+ if detail.get("hold"):
505
+ would = "refused"
506
+ reason = "This article is held for review, so it cannot go to your site yet."
507
+ elif shown["review_status"] != "approved":
508
+ would = "refused"
509
+ reason = "This article is not approved. Have the named author approve it before republishing."
510
+ elif not shown["person_id"]:
511
+ would = "refused"
512
+ reason = "This article has no named author. Link the person who signs it before republishing."
513
+ elif cms_response.get("post_id"):
514
+ would = "update_existing"
515
+ reason = "Would update the existing page on your site (Recala re-checks approval and proof first)."
516
+ else:
517
+ would = "publish_new"
518
+ reason = "Would publish a new page on your site (Recala re-checks approval and proof first)."
519
+ return {"dry_run": True, "title": shown["title"] or "Untitled", "review_status": shown["review_status"],
520
+ "publish_status": shown["publish_status"], "live_url": cms_response.get("post_url"),
521
+ "would": would, "reason": reason}
522
+
523
+
524
+ def describe_republish(ctx: click.Context, result: dict[str, Any]) -> str:
525
+ if result.get("dry_run"):
526
+ next_command = "status" if result["would"] == "refused" else "republish"
527
+ return (f"{result['title']}: {result['reason']}\n"
528
+ f"Next: recala article {next_command} {ctx.params['article_id'][:8]}")
529
+ action = {"updated": "Updated", "created": "Published"}.get(result.get("action"), "Republished")
530
+ sentence = f"{action} {ctx.meta['result_subject']} on your site"
531
+ return sentence + (f": {result['post_url']}" if result.get("post_url") else ".")
532
+
533
+
534
+ @article.command("republish")
535
+ @common_options
536
+ @command_runner(contextual_human=describe_republish)
537
+ @yes_option
538
+ @click.option("--dry-run", is_flag=True, help="Preview updating or publishing the article; change nothing.")
539
+ @click.argument("article_id")
540
+ @id_arguments(article_id="article")
541
+ def republish(ctx: click.Context, org_flag: str | None, yes: bool, dry_run: bool, article_id: str) -> dict[str, Any]:
542
+ """Publish corrections to your site.
543
+
544
+ Updates the existing page when one is recorded."""
545
+ client = open_client(ctx, org_flag)
546
+ article_id = resolve_article_id(client, article_id)
547
+ if dry_run:
548
+ return republish_preview(client.call(ARTICLE_SHOW, path={"article_id": article_id}))
549
+ label = resource_label(ctx, client, "article", article_id)
550
+ confirm(ctx, client, f"Republish {label}? This updates its page on your site, or publishes it if it has none.", publishes=True, yes=yes)
551
+ done: dict[str, Any] = client.call(ARTICLE_REPUBLISH, path={"article_id": article_id})
552
+ return done
553
+
554
+
555
+ def _readiness(client: RecalaClient, article_id: str) -> dict[str, Any]:
556
+ answer: dict[str, Any] = client.call(ARTICLE_READINESS, path={"article_id": article_id})
557
+ return answer
558
+
559
+
560
+ @article.command("schedule")
561
+ @common_options
562
+ @command_runner(contextual_human=named_result(describe_schedule))
563
+ @yes_option
564
+ @click.option("--at", "at", required=True, type=click.DateTime(["%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"]),
565
+ help="Local time in the company's timezone, e.g. 2026-10-06T09:00.")
566
+ @click.option("--tz", default=None, help="Timezone (default: the company's).")
567
+ @click.option("--dry-run", is_flag=True, help="Show whether it can be scheduled; change nothing.")
568
+ @click.argument("article_id")
569
+ @id_arguments(article_id="article")
570
+ def schedule(ctx: click.Context, org_flag: str | None, yes: bool, at: datetime, tz: str | None, dry_run: bool,
571
+ article_id: str) -> dict[str, Any]:
572
+ """Schedule an approved article to publish at a time."""
573
+ client = open_client(ctx, org_flag)
574
+ article_id = resolve_article_id(client, article_id)
575
+ if dry_run:
576
+ resource_label(ctx, client, "article", article_id)
577
+ ready = _readiness(client, article_id)
578
+ candidate = ready.get("candidate") or {}
579
+ return {"dry_run": True, "can_schedule": bool(candidate.get("can_schedule")), "blockers": candidate.get("blockers") or [],
580
+ "timezone": tz or ready.get("timezone"), "destination": ready.get("destination")}
581
+ return schedule_article(ctx, client, article_id, at=at, tz=tz, yes=yes)
582
+
583
+
584
+ #: Calendar blockers that need more than their code to be understood.
585
+ BLOCKER_HINTS = {
586
+ "sanity_on_demand_only": "sanity_on_demand_only (this Recala server publishes Sanity articles on demand only; "
587
+ "scheduling needs SANITY_SCHEDULING_ENABLED)",
588
+ }
589
+
590
+
591
+ def local_instant(at: datetime, zone: str) -> datetime:
592
+ """The wall-clock time the person typed, in the company's timezone, with its offset (the API refuses a bare time)."""
593
+ try:
594
+ return at.replace(tzinfo=ZoneInfo(zone))
595
+ except ZoneInfoNotFoundError as error:
596
+ raise CliError(ExitCode.USAGE, "invalid_timezone", f"{zone} is not a timezone (e.g. Europe/Madrid).") from error
597
+
598
+
599
+ def schedule_article(ctx: click.Context, client: RecalaClient, article_id: str, *, at: datetime, tz: str | None,
600
+ yes: bool = False) -> dict[str, Any]:
601
+ label = resource_label(ctx, client, "article", article_id)
602
+ confirm(ctx, client, f"Schedule {label} for {at:%Y-%m-%d %H:%M}? It publishes then without asking "
603
+ "again.", publishes=True, yes=yes)
604
+ ready = _readiness(client, article_id)
605
+ candidate = ready.get("candidate") or {}
606
+ if not candidate.get("can_schedule"):
607
+ blockers = [BLOCKER_HINTS.get(b, b) for b in candidate.get("blockers") or ["unknown"]]
608
+ raise CliError(ExitCode.CONFLICT, "not_ready", f"This article cannot be scheduled yet: {', '.join(blockers)}.")
609
+ zone = tz or ready.get("timezone") or "UTC"
610
+ body: dict[str, Any] = {
611
+ "scheduled_at": local_instant(at, zone).isoformat(), "timezone": zone,
612
+ "article_revision": candidate["article_revision"], "operation_id": str(uuid.uuid4()),
613
+ }
614
+ version = (candidate.get("appointment") or {}).get("version")
615
+ if version:
616
+ body["expected_version"] = version
617
+ booked: dict[str, Any] = client.call(APPOINTMENT_BOOK, path={"article_id": article_id}, json=body)
618
+ return booked
619
+
620
+
621
+ @article.command("unschedule")
622
+ @common_options
623
+ @command_runner(contextual_human=action_sentence("Unscheduled"))
624
+ @yes_option
625
+ @click.argument("article_id")
626
+ @id_arguments(article_id="article")
627
+ @dry_run_option
628
+ def unschedule(ctx: click.Context, org_flag: str | None, yes: bool, article_id: str) -> dict[str, Any]:
629
+ """Cancel the article's scheduled publication."""
630
+ client = open_client(ctx, org_flag)
631
+ article_id = resolve_article_id(client, article_id)
632
+ label = resource_label(ctx, client, "article", article_id)
633
+ confirm(ctx, client, f"Cancel the scheduled publication of {label}?", publishes=True, yes=yes)
634
+ appointment = (_readiness(client, article_id).get("candidate") or {}).get("appointment") or {}
635
+ if not appointment.get("version"):
636
+ raise CliError(ExitCode.CONFLICT, "not_scheduled", "This article has no scheduled publication.")
637
+ cancelled: dict[str, Any] = client.call(
638
+ APPOINTMENT_CANCEL, path={"article_id": article_id},
639
+ json={"expected_version": appointment["version"], "operation_id": str(uuid.uuid4())},
640
+ )
641
+ return cancelled
642
+
643
+
644
+ @article.command("create")
645
+ @common_options
646
+ @command_runner(contextual_human=lambda ctx, result: describe_creation(
647
+ result, topic_label=ctx.meta.get("creation_topic_label")) if result.get("dry_run") else describe_run(result, ctx=ctx))
648
+ @ignored_yes_option
649
+ @click.option("--topic", "queue_id", required=True, help="The topic id (recala topics list) to write.")
650
+ @click.option("--dry-run", is_flag=True, help="Show the cost preview and the balance; spend nothing.")
651
+ @click.option("--no-wait", is_flag=True, help="Start the run and return its id immediately.")
652
+ @id_arguments(queue_id="topic")
653
+ def create(ctx: click.Context, org_flag: str | None, yes: bool, queue_id: str, dry_run: bool, no_wait: bool) -> dict[str, Any]:
654
+ """Write a new article from a topic. Uses one article of the plan; always previews the cost first."""
655
+ client = open_client(ctx, org_flag)
656
+ queue_id = resolve_topic_id(client, queue_id)
657
+ if dry_run:
658
+ preview: dict[str, Any] = client.call(COST_PREVIEW, json={"action": "create_article", "queue_id": queue_id})
659
+ if not ctx.obj.get("as_json"):
660
+ label = resource_label(ctx, client, "topic", queue_id)
661
+ ctx.meta["creation_topic_label"] = f"topic [{queue_id[:8]}]" if label == f"topic {queue_id}" else label
662
+ return {"dry_run": True, "preview": preview, "credits": _optional(client, BILLING_CREDITS)}
663
+ return create_article(ctx, client, queue_id, yes=yes, no_wait=no_wait)
664
+
665
+
666
+ def create_article(ctx: click.Context, client: RecalaClient, queue_id: str, *, yes: bool = False, no_wait: bool = False) -> dict[str, Any]:
667
+ """Preview the cost, then write the article (minutes). An unknown outcome is never retried blind."""
668
+ label = resource_label(ctx, client, "topic", queue_id)
669
+ confirm(ctx, client, f"Write a new article from {label}? It uses one of the plan's articles (see the cost "
670
+ "with --dry-run).", yes=yes,
671
+ machine_summary="Write a new article from this topic? It uses one of the plan's articles (see the cost "
672
+ "with --dry-run).")
673
+ headers = {"Prefer": "respond-async", **preview_headers(client, queue_id=queue_id)}
674
+ try:
675
+ with indicator(ctx, "Writing the article (research, draft, checks)") as spinner:
676
+ created: dict[str, Any] = client.call(GENERATE_FROM_TOPIC, path={"queue_id": queue_id},
677
+ extra_headers=headers, timeout=_GENERATION_TIMEOUT_SECONDS)
678
+ if spinner is not None:
679
+ spinner.done("Article run started")
680
+ except CliError as error:
681
+ if error.exit_code != ExitCode.UNAVAILABLE:
682
+ raise
683
+ raise CliError(ExitCode.UNAVAILABLE, "outcome_unknown",
684
+ "The article may or may not have been created. Check `recala article list` before trying again; "
685
+ "never retry blind.", error.status) from error
686
+ ensure_outcome(created, client, ctx=ctx)
687
+ if created.get("run_id"):
688
+ created["check_command"] = resume_command(client, str(created["run_id"]))
689
+ return created if no_wait else wait_for_article(ctx, client, str(created["run_id"]))
690
+ if not ctx.obj.get("as_json") and created.get("article_id"):
691
+ created["title"] = resource_label(ctx, client, "article", str(created["article_id"]))
692
+ return created
693
+
694
+
695
+ #: The topic review gate's refusals (the API's review/queue_gate.py).
696
+ _TOPIC_GATE_CODES = ("topic_not_approved", "topic_dismissed")
697
+
698
+
699
+ def preview_headers(client: RecalaClient, **target: str) -> dict[str, str]:
700
+ """Bind credit-spending requests to the server's allowed, target-specific preview."""
701
+ preview = client.call(COST_PREVIEW, json={"action": "create_article", **target}) or {}
702
+ if not preview.get("allowed"):
703
+ if gate := next((code for code in preview.get("blockers") or [] if code in _TOPIC_GATE_CODES), None):
704
+ raise CliError(ExitCode.CONFLICT, gate, describe_cost_refusal(preview), # the API's own 409 for it
705
+ details={"code": gate, "queue_id": target.get("queue_id", "")})
706
+ raise CliError(ExitCode.PAYMENT_REQUIRED, "not_allowed",
707
+ f"Not created: {', '.join(preview.get('blockers') or ['refused'])}.")
708
+ return {"X-Recala-Cost-Preview": preview["preview_id"]} if preview.get("preview_id") else {}
709
+
710
+
711
+ @article.command("run")
712
+ @common_options
713
+ @command_runner(contextual_human=lambda ctx, result: describe_run(result, ctx=ctx))
714
+ @click.argument("queue_id")
715
+ def run_status(ctx, org_flag, queue_id):
716
+ """Wait for an existing creation run.
717
+
718
+ Never starts or pays for another run."""
719
+ return wait_for_article(ctx, open_client(ctx, org_flag), queue_id)
720
+
721
+
722
+ @article.command("regenerate")
723
+ @common_options
724
+ @command_runner(contextual_human=lambda ctx, result: (
725
+ f"Dry run: re-running {result['title']} would cost: {result['cost']}. Nothing was started."
726
+ if result.get("dry_run") else describe_run(result, ctx=ctx)))
727
+ @ignored_yes_option
728
+ @click.option("--dry-run", is_flag=True, help="Show the re-run cost; start nothing.")
729
+ @click.argument("article_id")
730
+ @id_arguments(article_id="article")
731
+ def regenerate(ctx, org_flag, yes, dry_run, article_id):
732
+ """Re-run a held article, after a cost preview.
733
+
734
+ Confirm the cost, then wait for review or another hold."""
735
+ client = open_client(ctx, org_flag)
736
+ article_id = resolve_article_id(client, article_id)
737
+ label = resource_label(ctx, client, "article", article_id)
738
+ headers, cost = regeneration_preview(client, article_id, as_json=ctx.obj.get("as_json"))
739
+ if dry_run:
740
+ return {"dry_run": True, "article_id": article_id, "title": label, "cost": cost}
741
+ if not ctx.obj.get("as_json"):
742
+ click.echo(f"Cost: {cost}.", err=True)
743
+ confirm(ctx, client, f"Re-run {label}? {cost}.", yes=yes)
744
+ try:
745
+ started = client.call(ARTICLE_REGENERATE, path={"article_id": article_id}, extra_headers=headers)
746
+ except CliError as error:
747
+ if error.code == "regenerate_cost_changed":
748
+ raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required", error.message, error.status) from error
749
+ if error.status == 409:
750
+ error.message = "This article cannot be re-run in its current state. " + error.message + f" Check: recala article status {article_id}"
751
+ raise
752
+ if not ctx.obj.get("as_json"):
753
+ click.echo(f"Charge mode: {started.get('charge_mode', 'reported by server')}", err=True)
754
+ try:
755
+ result = wait_for_article(ctx, client, article_id, regeneration=True,
756
+ regenerate_id=started.get("regenerate_id"))
757
+ except CliError as error:
758
+ error.details = {**(error.details or {}), "charge_mode": started.get("charge_mode"),
759
+ "regenerate_id": started.get("regenerate_id")}
760
+ raise
761
+ return {**result, "charge_mode": started.get("charge_mode")}
762
+
763
+
764
+ def regeneration_preview(client, article_id, *, as_json=False):
765
+ """Sessions and keys use the same read-only, target-specific cost receipt."""
766
+ try:
767
+ preview = client.call(COST_PREVIEW, json={"action": "create_article", "article_id": article_id}) or {}
768
+ except CliError as error:
769
+ if error.exit_code in {ExitCode.NOT_AUTHENTICATED, ExitCode.FORBIDDEN}:
770
+ raise
771
+ raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required",
772
+ "The re-run cost could not be previewed. Nothing was sent; request a fresh preview by running this command again.") from error
773
+ payment = preview.get("payment") or {}
774
+ how = payment.get("how")
775
+ if (preview.get("action") != "create_article" or preview.get("target") != article_id
776
+ or how not in {"free_rerun", "internal", "managed", "credit", "none_left"}):
777
+ message = "A matching re-run cost preview is required. Nothing was sent; run this command again."
778
+ if (not as_json and preview.get("action") == "create_article" and preview.get("target") == article_id
779
+ and preview.get("allowed") is False and preview.get("blockers")):
780
+ message = describe_cost_refusal(preview)
781
+ raise CliError(ExitCode.CONFIRMATION_REQUIRED, "confirmation_required",
782
+ message)
783
+ if not preview.get("allowed"):
784
+ raise CliError(ExitCode.PAYMENT_REQUIRED, "not_allowed",
785
+ f"Not re-run: {', '.join(preview.get('blockers') or ['refused'])}." if as_json else
786
+ describe_cost_refusal(preview))
787
+ cost = regeneration_cost(preview)
788
+ headers = {"X-Recala-Cost-Preview": preview["preview_id"]} if preview.get("preview_id") else {}
789
+ headers["X-Recala-Regenerate-Amount"] = "1900" if how == "none_left" else "0"
790
+ return headers, cost
791
+
792
+
793
+ def regeneration_cost(preview):
794
+ """The server's answer in words: only a hold of our own gate is a free re-run.
795
+
796
+ A plan article, an internal or managed run is named as such; the legacy PAYG
797
+ price is EUR 19. The API still enforces every plan gate.
798
+ """
799
+ how = (preview.get("payment") or {}).get("how")
800
+ if how == "free_rerun":
801
+ return "free re-run"
802
+ if how == "none_left":
803
+ return "EUR 19 will be charged"
804
+ return cost_sentence(preview)
805
+
806
+
807
+ @article.command("recheck")
808
+ @common_options
809
+ @command_runner(human=lambda r: f"{r['title']}: checks passed; ready for review." + describe_link_warnings(r))
810
+ @click.argument("article_id")
811
+ @id_arguments(article_id="article")
812
+ @accepted_yes_option
813
+ @dry_run_option
814
+ def recheck(ctx, org_flag, article_id):
815
+ """Check an edited article again.
816
+
817
+ Shows what still blocks approval."""
818
+ client = open_client(ctx, org_flag)
819
+ article_id = resolve_article_id(client, article_id)
820
+ label = resource_label(ctx, client, "article", article_id)
821
+ confirm(ctx, client, f"Check {label} again?")
822
+ try:
823
+ with indicator(ctx, f"Checking {label}"):
824
+ result = client.call(ARTICLE_RECHECK, path={"article_id": article_id})
825
+ if not result.get("passed"):
826
+ raise CliError(ExitCode.CONFLICT, "recheck_blocked", result.get("message") or describe_fields(result), details=result)
827
+ except CliError as error:
828
+ details = error.details or {}
829
+ if error.code in RECHECK_VERDICT_CODES or any(isinstance(details.get(key), list) for key in ("findings", "failures")):
830
+ error.exit_code = ExitCode.CONFLICT
831
+ error.details = {**details, "findings": recheck_findings(error.code, error.message, details)}
832
+ if not ctx.obj.get("as_json"):
833
+ error.message = describe_recheck(ctx, label, article_id, error.message, error.details)
834
+ raise
835
+ return {**result, "title": ctx.meta.get("result_subject", label)}