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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- 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)}
|