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
recala_cli/errors.py ADDED
@@ -0,0 +1,220 @@
1
+ """How a failure is named: one stable exit code and one machine-readable code.
2
+
3
+ Agents branch on the exit code, so a number never changes meaning once shipped.
4
+ Add new codes at the end; never reuse or renumber one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import IntEnum
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+
15
+ class ExitCode(IntEnum):
16
+ OK = 0
17
+ ERROR = 1 # unexpected: a bug, or an answer the CLI cannot read
18
+ USAGE = 2 # bad flags or arguments (click uses 2 for these too)
19
+ NOT_AUTHENTICATED = 3 # no credentials, or the API refused them
20
+ FORBIDDEN = 4 # the role or the key's scopes do not allow it
21
+ NOT_FOUND = 5
22
+ CONFLICT = 6 # the current state does not allow it yet (no org selected, no person linked)
23
+ INVALID = 7 # the API refused the input (400, 422)
24
+ RATE_LIMITED = 8 # retry after a pause
25
+ UNAVAILABLE = 9 # server or network trouble; safe to retry
26
+ CONFIRMATION_REQUIRED = 10 # a guarded action (publish, pay) ran without its confirmation flag
27
+ PAYMENT_REQUIRED = 11 # not enough credits for a paid action
28
+
29
+
30
+ _BY_STATUS: dict[int, tuple[ExitCode, str]] = {
31
+ 400: (ExitCode.INVALID, "invalid"),
32
+ 401: (ExitCode.NOT_AUTHENTICATED, "not_authenticated"),
33
+ 402: (ExitCode.PAYMENT_REQUIRED, "payment_required"),
34
+ 403: (ExitCode.FORBIDDEN, "forbidden"),
35
+ 404: (ExitCode.NOT_FOUND, "not_found"),
36
+ 409: (ExitCode.CONFLICT, "conflict"),
37
+ 422: (ExitCode.INVALID, "invalid"),
38
+ 429: (ExitCode.RATE_LIMITED, "rate_limited"),
39
+ }
40
+ _RETRYABLE = {ExitCode.RATE_LIMITED, ExitCode.UNAVAILABLE}
41
+ # A gateway (Railway, Cloudflare) answering for an API that did not: always a passing outage.
42
+ _GATEWAY_STATUSES = {502, 504, 520, 521, 522, 523, 524}
43
+ TRY_AGAIN = "try again in a minute."
44
+ OUTAGE_NEXT_STEP = f"Recala is not answering right now; {TRY_AGAIN}"
45
+ SUPPORT_EMAIL = "hello@recala.co" # the address the product tells people to write to
46
+ SERVER_PROBLEM = (f"Recala hit a problem on its side. Try again in a minute; if it keeps failing, write to {SUPPORT_EMAIL}")
47
+ # A 5xx whose words are a setting ("not enabled", "not configured"), not a failure, keeps its own sentence.
48
+ _SETTING_WORDS = ("not enabled", "not configured")
49
+ CHECK_FIRST = "this change may have gone through. Check it before you try again."
50
+ # Failures before the request left this machine: trying again cannot repeat a write.
51
+ _NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
52
+
53
+
54
+ class CliError(Exception):
55
+ """A failure the CLI reports and exits on, never a traceback."""
56
+
57
+ def __init__(self, exit_code: ExitCode, code: str, message: str, status: int | None = None,
58
+ details: dict[str, Any] | None = None) -> None:
59
+ super().__init__(message)
60
+ self.exit_code = exit_code
61
+ self.code = code
62
+ self.message = message
63
+ self.status = status
64
+ self.details = details
65
+
66
+ @property
67
+ def retryable(self) -> bool:
68
+ # A paid call with an unknown outcome is never "retry": the caller checks the state first.
69
+ return self.exit_code in _RETRYABLE and self.code not in {"outcome_unknown", "run_continues", "still_waiting", "method_search_quota_ledger_blocked"}
70
+
71
+ def as_dict(self) -> dict[str, Any]:
72
+ envelope = {
73
+ "code": self.code,
74
+ "message": self.message,
75
+ "status": self.status,
76
+ "exit_code": int(self.exit_code),
77
+ "retryable": self.retryable,
78
+ }
79
+ if self.details is not None:
80
+ envelope["details"] = self.details # the API's own structured refusal, for agents to read
81
+ return envelope
82
+
83
+
84
+ class DryRunStop(Exception):
85
+ """--dry-run reached the first write: stop before it, and say what it would have been (not a failure)."""
86
+
87
+ def __init__(self, would: str) -> None:
88
+ super().__init__(would)
89
+ self.would = would
90
+
91
+
92
+ def session_expired(status: int | None = None, *, code: str = "session_expired") -> CliError:
93
+ return CliError(ExitCode.NOT_AUTHENTICATED, code, "Your sign-in expired. Run `recala login`.", status)
94
+
95
+
96
+ def error_from_response(response: httpx.Response, *, writes: bool = False) -> CliError:
97
+ """Classify an API refusal.
98
+
99
+ The API's own code wins over the generic name: FastAPI's ``detail.code``, or
100
+ the OAuth-style ``error`` the device sign-in answers with (RFC 8628).
101
+ """
102
+ if writes and _not_the_api_answering(response):
103
+ return CliError(ExitCode.UNAVAILABLE, "outcome_unknown",
104
+ f"Recala did not finish answering (HTTP {response.status_code}); {CHECK_FIRST}",
105
+ status=response.status_code)
106
+ if response.status_code >= 500:
107
+ exit_code, code = ExitCode.UNAVAILABLE, "unavailable"
108
+ else:
109
+ exit_code, code = _BY_STATUS.get(response.status_code, (ExitCode.ERROR, "error"))
110
+ detail = _detail_of(response)
111
+ if response.status_code >= 500 and _is_a_failure_not_a_setting(detail):
112
+ raw = detail if isinstance(detail, str) and detail.strip() and _readable(response) else None
113
+ return CliError(exit_code, code, SERVER_PROBLEM, status=response.status_code,
114
+ details={"server_message": raw[:300]} if raw else None)
115
+ if isinstance(detail, dict):
116
+ code = str(detail.get("code") or detail.get("error") or code)
117
+ if code == "api_keys_disabled":
118
+ return CliError(exit_code, code, "API keys are not enabled for this company yet",
119
+ status=response.status_code, details=detail)
120
+ if code == "topic_not_approved":
121
+ message = f"{TOPIC_NOT_APPROVED}\nNext: {approve_topic_command(str(detail.get('queue_id') or ''))}"
122
+ return CliError(exit_code, code, message, status=response.status_code, details=detail)
123
+ message = str(detail.get("message") or detail.get("error_description") or _summary(code, detail))
124
+ if _passing_outage(response):
125
+ message = f"{message} {_when_to_try_again(response)}"
126
+ return CliError(exit_code, code, message, status=response.status_code, details=detail)
127
+ if isinstance(detail, list): # FastAPI's validation errors: the field and why, never the value sent
128
+ message = "; ".join(_field_error(item) for item in detail) or response.reason_phrase
129
+ else:
130
+ message = str(detail) if detail else response.reason_phrase
131
+ return CliError(exit_code, code, message, status=response.status_code)
132
+
133
+
134
+ #: The API's topic gate (review/queue_gate.py): this company writes only topics a person approved.
135
+ TOPIC_NOT_APPROVED = "This company approves topics before they are written, and this one is not approved yet."
136
+
137
+
138
+ def approve_topic_command(queue_id: str) -> str:
139
+ return f"recala topics approve {queue_id[:8]}"
140
+
141
+
142
+ def network_failure(failure: httpx.HTTPError, *, writes: bool) -> CliError:
143
+ """No answer: a read, or a request that never left, may be tried again; a write may already have landed."""
144
+ name = failure.__class__.__name__
145
+ if not writes or isinstance(failure, _NEVER_SENT):
146
+ return CliError(ExitCode.UNAVAILABLE, "unavailable", f"Could not reach the Recala API ({name}); {TRY_AGAIN}")
147
+ return CliError(ExitCode.UNAVAILABLE, "outcome_unknown", f"No answer from the Recala API ({name}); {CHECK_FIRST}")
148
+
149
+
150
+ def _is_a_failure_not_a_setting(detail: Any) -> bool:
151
+ """Any 5xx is Recala's problem, never the person's: only a structured refusal or a switched-off feature speaks for itself."""
152
+ if isinstance(detail, dict):
153
+ return False
154
+ return not (isinstance(detail, str) and any(word in detail.lower() for word in _SETTING_WORDS))
155
+
156
+
157
+ def _when_to_try_again(response: httpx.Response) -> str:
158
+ seconds = response.headers.get("retry-after", "")
159
+ minutes = -(-int(seconds) // 60) if seconds.isdigit() else 1
160
+ return "Try again in a minute." if minutes <= 1 else f"Try again in about {minutes} minutes."
161
+
162
+
163
+ def _field_error(item: Any) -> str:
164
+ if not isinstance(item, dict):
165
+ return "invalid input"
166
+ where = ".".join(str(part) for part in item.get("loc") or [] if part != "body")
167
+ return f"{where}: {item.get('msg')}" if where else str(item.get("msg") or "invalid input")
168
+
169
+
170
+ def _summary(code: str, detail: dict[str, Any]) -> str:
171
+ """A sentence for a refusal the API sent as data only (never a raw dict on a person's screen)."""
172
+ reasons = []
173
+ policy = detail.get("publication_policy")
174
+ if isinstance(policy, dict):
175
+ for violation in policy.get("violations") or []:
176
+ if isinstance(violation, dict):
177
+ reasons.append(f"{violation.get('url') or 'a source'} ({violation.get('reason')})")
178
+ for key in ("blockers", "reasons"):
179
+ reasons.extend(str(item) for item in detail.get(key) or [] if isinstance(item, str))
180
+ text = f"Recala refused this: {code.replace('_', ' ')}"
181
+ return text + (": " + "; ".join(reasons[:4]) if reasons else "") + "."
182
+
183
+
184
+ def _passing_outage(response: httpx.Response) -> bool:
185
+ """A gateway's answer, or a 503 that says when to retry; a 503 for a switched-off feature is not one."""
186
+ return _from_a_gateway(response) or (response.status_code == 503 and "retry-after" in response.headers)
187
+
188
+
189
+ def _not_the_api_answering(response: httpx.Response) -> bool:
190
+ """A gateway's answer, or a crash page: the API may have done the work before the answer was lost."""
191
+ return _from_a_gateway(response) or (response.status_code >= 500 and not _readable(response))
192
+
193
+
194
+ def _from_a_gateway(response: httpx.Response) -> bool:
195
+ """A gateway status whose body is not the API's: its own 502s (a failed CMS take-down) carry ``detail``."""
196
+ return response.status_code in _GATEWAY_STATUSES and not _api_detail_in(response)
197
+
198
+
199
+ def _api_detail_in(response: httpx.Response) -> bool:
200
+ try:
201
+ body = response.json()
202
+ except ValueError:
203
+ return False
204
+ return isinstance(body, dict) and "detail" in body
205
+
206
+
207
+ def _readable(response: httpx.Response) -> bool:
208
+ try:
209
+ response.json()
210
+ except ValueError:
211
+ return False
212
+ return True
213
+
214
+
215
+ def _detail_of(response: httpx.Response) -> Any:
216
+ try:
217
+ body = response.json()
218
+ except ValueError:
219
+ return response.text[:300]
220
+ return body.get("detail", body) if isinstance(body, dict) else body
recala_cli/human.py ADDED
@@ -0,0 +1,324 @@
1
+ """Readable views of nested results; Python container repr never reaches a person."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from recala_cli.errors import TOPIC_NOT_APPROVED, approve_topic_command
7
+
8
+
9
+ def publish_state_words(state: str | None) -> str:
10
+ """A publishing state in words: "draft" is only the default for an article that is not published yet."""
11
+ return "not published" if state in (None, "", "draft") else str(state).replace("_", " ")
12
+
13
+
14
+ def describe_comments(listed: dict[str, Any]) -> str:
15
+ """Review-link comments: who asked what, on which words, and whether it is still open."""
16
+ comments = listed.get("comments") or []
17
+ if not comments:
18
+ return "No comments from the review link yet."
19
+ still_open = sum(1 for comment in comments if (comment.get("status") or "open") == "open")
20
+ lines = [f"{len(comments)} comment{'' if len(comments) == 1 else 's'}, {still_open} open:"]
21
+ for comment in comments:
22
+ passage = (comment.get("quote") or {}).get("exact") # the API nests the commented words under "quote"
23
+ where = f' on "{passage}"' if passage else ""
24
+ state = str(comment.get("status") or "open").replace("_", " ").capitalize()
25
+ lines.append(f" {state} · {comment.get('author_name') or 'A reviewer'}{where}: {comment.get('body')}")
26
+ return "\n".join(lines)
27
+
28
+
29
+ def describe_fields(result: Any) -> str:
30
+ """Keep the result's information in labelled lines, including nested records."""
31
+ if isinstance(result, dict):
32
+ lines = []
33
+ for key, value in result.items():
34
+ if value is None:
35
+ continue # an empty fact is left out, not printed as "not available"
36
+ label = str(key).replace("_", " ").capitalize()
37
+ text = describe_fields(value)
38
+ if isinstance(value, (dict, list)):
39
+ lines.append(f"{label}:\n" + "\n".join(f" {line}" for line in text.splitlines()))
40
+ else:
41
+ lines.append(f"{label}: {text}")
42
+ return "\n".join(lines) or "No details available."
43
+ if isinstance(result, list):
44
+ return "\n".join(describe_fields(value) for value in result) or "None."
45
+ if result is None:
46
+ return "not available"
47
+ if isinstance(result, bool):
48
+ return "yes" if result else "no"
49
+ return str(result)
50
+
51
+
52
+ def describe_local_fields(ctx: Any, result: Any) -> str:
53
+ """describe_fields with each instant in the company's timezone, named once above when any appears."""
54
+ from recala_cli.views import company_timezone, localise_instants, timezone_label
55
+
56
+ if not localise_instants(result, "UTC")[1]:
57
+ return describe_fields(result) # no instant: no timezone lookup
58
+ zone = company_timezone(ctx, result if isinstance(result, dict) else {})
59
+ return f"{timezone_label(zone)}\n{describe_fields(localise_instants(result, zone)[0])}"
60
+
61
+
62
+ def describe_edit(result: dict[str, Any]) -> str:
63
+ redraft = result.get("redraft")
64
+ if isinstance(redraft, dict):
65
+ if redraft.get("applied"):
66
+ return "Kept the AI draft's changes."
67
+ if redraft.get("status") == "completed" and isinstance(redraft.get("summary"), dict):
68
+ return describe_finished_redraft(redraft)
69
+ return f"AI draft: {redraft.get('status') or 'ready'}." + (f" {redraft['summary']}" if redraft.get("summary") else "")
70
+ return "Updated the article's text."
71
+
72
+
73
+ def describe_finished_redraft(redraft: dict[str, Any]) -> str:
74
+ """The numbered notes --undo refers to, what each did, what the AI could not answer, and how to keep it."""
75
+ summary, notes = redraft["summary"], redraft.get("notes") or []
76
+ partly = summary.get("notes_partially_applied") or 0
77
+ lines = [f"AI draft ready: {summary.get('notes_applied', 0)} of {len(notes)} notes applied"
78
+ + (f", {partly} in part" if partly else "")
79
+ + f"; {summary.get('words_added', 0)} words added, {summary.get('words_removed', 0)} removed."]
80
+ for note in notes:
81
+ changed = [section["heading"] for section in note.get("sections") or [] if section.get("changed")]
82
+ line = f" {note.get('index')}. {note.get('text')}: {str(note.get('status') or 'pending').replace('_', ' ')}"
83
+ line += f" ({', '.join(changed)})" if changed else ""
84
+ line += f". {note['status_reason']}" if note.get("status_reason") else ""
85
+ lines.append(line)
86
+ lines += [f"Open question: {item['question']}" for question in redraft.get("questions") or []
87
+ for item in question.get("items") or [] if item.get("question")]
88
+ if notes and redraft.get("article_id"):
89
+ identifier = str(redraft["article_id"])[:8]
90
+ lines.append(f"Next: keep it with recala article edit {identifier} --apply (this also hands it over for review), "
91
+ "or keep some with --apply --undo <numbers>")
92
+ return "\n".join(lines)
93
+
94
+
95
+ def describe_schedule(result: dict[str, Any]) -> str:
96
+ if result.get("dry_run"):
97
+ readiness = "ready" if result.get("can_schedule") else "not ready"
98
+ blockers = describe_fields(result.get("blockers") or [])
99
+ return f"Scheduling preview: {readiness}.\nTimezone: {result.get('timezone') or 'UTC'}\nBlockers: {blockers}"
100
+ appointment = result.get("appointment") or {}
101
+ return "Scheduled the article." + (f" Publication: {appointment['scheduled_at']}." if appointment.get("scheduled_at") else "")
102
+
103
+
104
+ _COST_BLOCKERS = {
105
+ "topic_not_found": "This topic could not be found.",
106
+ "topic_not_writable": "This topic is not ready for a new article.",
107
+ "topic_not_approved": TOPIC_NOT_APPROVED,
108
+ "topic_dismissed": "This topic was dismissed.",
109
+ "managed_monthly_cap_reached": "Your managed plan has reached its monthly article limit.",
110
+ "no_article_credit": "No articles are left on your plan.",
111
+ "api_key_credit_cap_reached": "This API key has reached its article spending limit.",
112
+ }
113
+
114
+
115
+ def describe_cost_refusal(preview: dict[str, Any]) -> str:
116
+ blockers = preview.get("blockers") or ["request_refused"]
117
+ sentences = [_COST_BLOCKERS.get(code) or str(code).replace("_", " ").capitalize().rstrip(".") + "."
118
+ for code in blockers]
119
+ return "Not possible now: " + " ".join(sentences) + f"\nNext: {_step_past(blockers, str(preview.get('target') or ''))}"
120
+
121
+
122
+ def _step_past(blockers: list[str], target: str) -> str:
123
+ """The one command that clears the first thing in the way."""
124
+ if "topic_not_approved" in blockers:
125
+ return approve_topic_command(target)
126
+ if any(code in {"topic_not_found", "topic_not_writable", "topic_dismissed"} for code in blockers):
127
+ return "recala topics list"
128
+ return "recala credits"
129
+
130
+
131
+ def cost_sentence(preview: dict[str, Any]) -> str:
132
+ payment = preview.get("payment") or {}
133
+ usage = preview.get("managed_usage") or {}
134
+ return {
135
+ "credit": f"uses 1 of your plan's articles ({payment.get('available', 'unknown')} left)",
136
+ "internal": "included in your plan at no charge",
137
+ "managed": f"counts toward your managed plan ({usage.get('used', 'unknown')} of {usage.get('cap', 'unknown')} this month)",
138
+ "none_left": "no articles left on your plan",
139
+ }.get(payment.get("how"), "the balance could not be read")
140
+
141
+
142
+ def describe_creation(result: dict[str, Any], *, topic_label: str | None = None) -> str:
143
+ if result.get("dry_run"):
144
+ preview = result.get("preview") or {}
145
+ identifier = str(preview.get("target") or "")[:8]
146
+ label = topic_label or f"topic [{identifier}]"
147
+ lines = [f"Write {label}: {cost_sentence(preview)}."]
148
+ lines.append(describe_cost_refusal(preview) if preview.get("allowed") is False else
149
+ f"Next: recala article create --topic {identifier}")
150
+ return "\n".join(lines)
151
+ identifier = result.get("article_id") or result.get("id")
152
+ title = result.get("title") or result.get("topic") or "article"
153
+ return f"Created {title} [{str(identifier)[:8]}]." if identifier else "Created the article."
154
+
155
+
156
+ #: Finding kinds the writer's gates emit (api authority/quality/gates/*.py: fabrication, slice_grounding,
157
+ #: stale_or_unsourced_claim, inline_source, company_figures), as "kind: item, item". Each reads as what to fix.
158
+ _FINDING_KINDS = {
159
+ "figures": ("The {noun} {items} {be} not in your sources.", "Add the source or take the number out."),
160
+ "unconfirmed figures": ("Recala could not confirm {items} against your sources.",
161
+ "Add the source or take the number out."),
162
+ "entities": ("{items} {be} named but not found in your sources.", "Add a source for it or remove the name."),
163
+ "superlatives": ("Your sources do not back this claim: {items}.", "Soften the claim or add evidence."),
164
+ "numbers absent from cited evidence": ("{items} {be} not in the evidence the article cites.",
165
+ "Cite evidence that contains the number, or remove it."),
166
+ "unsourced first-party figures": ("Your own figures {items} need a source.", "Cite where they come from."),
167
+ "self-citation as evidence": ("{items} rest only on your own site.", "Add an independent source."),
168
+ "quoted sentences naming no pack-backed source": ("These quotes name no source Recala has: {items}.",
169
+ "Name the source or remove the quote."),
170
+ "external figures with no inline source": ("These outside figures have no source beside them: {items}.",
171
+ "Put the source next to each figure."),
172
+ "sources-block urls the pack cannot stand up": ("These links in the sources list could not be confirmed: {items}.",
173
+ "Replace them with links Recala can reach."),
174
+ }
175
+ _KIND_LABEL = "|".join(sorted((re.escape(kind) for kind in _FINDING_KINDS), key=len, reverse=True))
176
+ _FINDING_START = re.compile(rf"(?i)(?:^|(?<=[;|])\s*)(?=(?:{_KIND_LABEL})(?: \([^)]*\))?: )")
177
+ _FINDING = re.compile(rf"(?is)^({_KIND_LABEL})(?: \([^)]*\))?:\s*(.+)$")
178
+ _UNKNOWN_KIND = re.compile(r"^([a-z][a-z0-9]*(?:_[a-z0-9]+)+):\s*(.+)$")
179
+ _FIGURE_TRIPLE = re.compile(
180
+ r"^(?P<value>[−+-]?\d[\d.,]*) (?P<kind>[a-z][a-z0-9_]*(?::[^\s]+)?) "
181
+ r"(?P<bound>exact|gte|gt|lte|lt|unlikely_\w+)$")
182
+ _BOUNDS = {"exact": "", "gte": "at least ", "gt": "more than ", "lte": "at most ", "lt": "fewer than "}
183
+ _PLURAL_UNITS = {"day": "days", "week": "weeks", "month": "months", "year": "years", "hour": "hours",
184
+ "minute": "minutes", "second": "seconds", "lesson": "lessons", "gem": "gems", "streak": "streaks"}
185
+ _CURRENCY = {"eur": "€", "usd": "$", "gbp": "£"}
186
+
187
+
188
+ def _figure_words(raw: str) -> str:
189
+ """'1230 count:machine exact' as '1,230 machines'; anything that is not a signature is kept as written."""
190
+ match = _FIGURE_TRIPLE.match(raw.strip())
191
+ if not match:
192
+ return raw.strip()
193
+ value, kind, bound = match["value"], match["kind"], match["bound"]
194
+ try:
195
+ number = float(value.replace(",", ""))
196
+ if number == int(number) and re.fullmatch(r"\d+", value):
197
+ value = f"{int(number):,}"
198
+ except ValueError:
199
+ pass
200
+ category, _, name = kind.partition(":")
201
+ if kind == "percent":
202
+ unit = "%"
203
+ elif category == "currency":
204
+ unit = _CURRENCY.get(name, name.upper())
205
+ elif category == "count":
206
+ unit = name if name.endswith("s") else name + "s"
207
+ elif not name:
208
+ unit = _PLURAL_UNITS.get(kind, kind.replace("_", " "))
209
+ else:
210
+ unit = name
211
+ prefix = _BOUNDS.get(bound, "not " + bound.removeprefix("unlikely_") + " ")
212
+ joined = f"{value}{unit}" if unit in ("%",) else f"{value} {unit}"
213
+ return prefix + joined
214
+
215
+
216
+ def _list_in_words(items: list[str]) -> str:
217
+ return items[0] if len(items) == 1 else ", ".join(items[:-1]) + " and " + items[-1]
218
+
219
+
220
+ def finding_sentences(detail: str) -> list[str]:
221
+ """A hold's machine detail as sentences: one per finding kind; text that is already a sentence is kept."""
222
+ pieces = [part for part in re.split(_FINDING_START, detail) if part and part.strip(" ;|")]
223
+ sentences = []
224
+ for piece in pieces:
225
+ piece = piece.strip(" ;|")
226
+ found = _FINDING.match(piece)
227
+ unknown = _UNKNOWN_KIND.match(piece)
228
+ if found:
229
+ kind = found[1].lower()
230
+ items = [_figure_words(item) for item in re.split(r",\s*(?![^()]*\))", found[2].strip()) if item.strip()]
231
+ if kind in ("figures", "unconfirmed figures", "unsourced first-party figures"):
232
+ noun = "figure" if len(items) == 1 else "figures"
233
+ else:
234
+ noun = "name" if len(items) == 1 else "names"
235
+ body, advice = _FINDING_KINDS[kind]
236
+ joined = _list_in_words(items) if kind == "figures" else ", ".join(items)
237
+ sentences.append(" ".join(body.format(noun=noun, items=joined, be="is" if len(items) == 1 else "are")
238
+ .split()) + " " + advice)
239
+ elif unknown:
240
+ sentences.append(f"Recala's checks flagged {unknown[1].replace('_', ' ')}: {unknown[2].strip()}. "
241
+ "Review that part of the text.")
242
+ else:
243
+ sentences.append(piece)
244
+ return sentences
245
+
246
+
247
+ _RECHECK_METHOD_CODES = {"method_final_checks_failed", "method_edit_ledger_invalid"}
248
+ RECHECK_VERDICT_CODES = _RECHECK_METHOD_CODES | {"author_checks_failed", "recheck_blocked"}
249
+ _SAVED_TEXT_NOTICE = "Your saved text has not changed."
250
+
251
+
252
+ def _message_findings(code: str, message: str, *, split_message: bool = False) -> list[str]:
253
+ """Keep separators and context so parsing cannot silently discard diagnostic text."""
254
+ text = re.sub(r"(?:^|\s+)" + re.escape(_SAVED_TEXT_NOTICE) + r"\s*$", "", message).strip()
255
+ if code in _RECHECK_METHOD_CODES or split_message:
256
+ bullets = list(re.finditer(r"(?m)^- ", text))
257
+ if bullets:
258
+ # The header belongs to the first claim; any trailing context belongs to the last.
259
+ starts = [0, *(match.start() for match in bullets[1:]), len(text)]
260
+ return [text[start:end].strip() for start, end in zip(starts, starts[1:])]
261
+ return [part.strip() for part in re.split(r"(?<=;) ", text) if part.strip()]
262
+ return [text] if text else []
263
+
264
+
265
+ def _finding_record(item: Any, index: int) -> dict[str, Any]:
266
+ metadata = {}
267
+ if isinstance(item, dict):
268
+ metadata = {key: item[key] for key in ("claim_id", "section", "claim_text", "code") if item.get(key) is not None}
269
+ parts = [str(item[key]) for key in ("text", "meaning", "found", "detail") if item.get(key)]
270
+ text = " ".join(dict.fromkeys(parts))
271
+ claim_text = item.get("claim_text")
272
+ if claim_text and str(claim_text) not in text:
273
+ text = f"«{claim_text}» " + text
274
+ location = ", ".join(str(value) for value in (item.get("section"),
275
+ f"claim {item['claim_id']}" if item.get("claim_id") is not None else None) if value)
276
+ text = (location + ": " if location else "") + (text or str(item.get("code") or describe_fields(item)))
277
+ else:
278
+ text = str(item)
279
+ claim = re.search(r"(?:^|\n)(?:- |Check )?([^\n]+?), claim (\d+): «([^»]*)»", text)
280
+ if claim:
281
+ metadata = {"section": claim[1], "claim_id": int(claim[2]), "claim_text": claim[3]}
282
+ return {"index": index, "text": text.strip(), **metadata}
283
+
284
+
285
+ def recheck_findings(code: str, message: str, details: dict[str, Any], *,
286
+ split_message: bool = False) -> list[dict[str, Any]]:
287
+ """Prefer server records, then legacy failures, then only documented message boundaries."""
288
+ items = details.get("findings") or details.get("failures")
289
+ if not isinstance(items, list) or not items:
290
+ items = _message_findings(code, message, split_message=split_message)
291
+ return [_finding_record(item, index) for index, item in enumerate(items, 1)]
292
+
293
+
294
+ def describe_recheck(ctx, label: str, article_id: str, message: str, details: dict[str, Any]) -> str:
295
+ from recala_cli.views import company_timezone, moment_in
296
+
297
+ lines = []
298
+ if details.get("cached") is True:
299
+ checked_at = details.get("checked_at")
300
+ when = moment_in(checked_at, company_timezone(ctx, details)) if checked_at else "an unrecorded date"
301
+ lines.append(f"This text was already checked on {when}; change it to check again.")
302
+ findings = details["findings"]
303
+ noun = "finding" if len(findings) == 1 else "findings"
304
+ lines.append(f"{label}: approval is still blocked. {len(findings)} {noun}:")
305
+ for finding in findings:
306
+ # Layout is one numbered line per item; JSON keeps the original diagnostic characters.
307
+ text = re.sub(r"(?m)^\d+ further unsupported claims\.\s*", "", finding["text"])
308
+ context, bullet, claim = text.partition("\n- ")
309
+ if bullet: # the server's header line rides on the first claim: print it once, above the list
310
+ lines.append(" ".join(context.split()))
311
+ text = claim
312
+ text = text.removeprefix("- ")
313
+ lines.append(f"{finding['index']}. {' '.join(text.split())}")
314
+ remaining = re.search(r"(?m)^(\d+) further unsupported claims\.", message)
315
+ if remaining:
316
+ lines.append(f"{remaining[1]} more not shown")
317
+ elif len(message) >= 1500:
318
+ lines.append("The server cut this list; the article page shows all of them")
319
+ if "cached" not in details:
320
+ lines.append("If the text has not changed since its last check, this is that check's saved result.")
321
+ short_id = article_id[:8]
322
+ lines.append(f"Change the text, then check again: recala article edit {short_id} --body-file <file> "
323
+ f"&& recala article recheck {short_id}")
324
+ return "\n".join(lines)