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,352 @@
1
+ """`recala company` (also `recala org`): create or choose the company, and read its website.
2
+
3
+ A brand-new account already holds one empty company (the sign-up made it); its
4
+ first setup fills that one in rather than creating a second. The market (the
5
+ country the company sells in) travels with the website scan, where the portal's
6
+ wizard sends it too.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from recala_cli import config
16
+ from recala_cli.client import RecalaClient
17
+ from recala_cli.endpoints import CREDITS_SUMMARY, ME, ONBOARDING_SAVE, ONBOARDING_SCRAPE, ORG_CREATE, ORG_SHOW, ORG_UPDATE
18
+ from recala_cli.errors import CliError, ExitCode
19
+ from recala_cli.display import human_data
20
+ from recala_cli.parallel import run_parallel
21
+ from recala_cli.polling import indicator
22
+ from recala_cli.organizations import organization_description, organization_label
23
+ from recala_cli.prompts import ask
24
+ from recala_cli.runtime import command_runner, common_options, confirm, json_option, open_client, accepted_yes_option, dry_run_option
25
+
26
+ #: The scan's own entity types, as the wizard sends them back (products and competitors also as lists).
27
+ _ENTITY_TYPES = ("industry", "product", "service", "audience", "competitor", "keyword", "location")
28
+ _SCAN_TIMEOUT_SECONDS = 180.0
29
+
30
+
31
+ def _describe_org(result: dict[str, Any]) -> str:
32
+ org = result.get("org") or {}
33
+ lines = [org.get("name") or "(unnamed company)", f" id: {str(org.get('id', ''))[:8]}"]
34
+ fields = (("website", org.get("website_url")), ("blog", org.get("blog_base_url")),
35
+ ("language", org.get("language")), ("timezone", org.get("timezone") or result.get("timezone")),
36
+ ("plan", (result.get("plan") or {}).get("name")), ("niche", org.get("niche")))
37
+ lines.extend(f" {label + ':':<9} {value}" for label, value in fields if value)
38
+ return "\n".join(lines)
39
+
40
+
41
+ @click.group()
42
+ def org() -> None:
43
+ """Create, choose or scan the company you act on."""
44
+
45
+
46
+ @org.command("show")
47
+ @common_options
48
+ @command_runner(human=_describe_org)
49
+ def show(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
50
+ """Show the active company: website, language, timezone and plan."""
51
+ client = open_client(ctx, org_flag)
52
+ assert client.org_id is not None # open_client refuses to build one without it
53
+ org_id = client.org_id
54
+ # --json is the API's own document (agents read the plan with `credits --json`); a person also sees the plan.
55
+ wants_plan = not ctx.obj.get("as_json")
56
+ found = run_parallel({
57
+ "identity": lambda: client.call(ORG_SHOW, path={"org_id": org_id}),
58
+ "plan": lambda: _plan_or_none(client) if wants_plan else None,
59
+ })
60
+ identity: dict[str, Any] = found["identity"]
61
+ return {**identity, "plan": found["plan"]} if found["plan"] else identity
62
+
63
+
64
+ def _plan_or_none(client: RecalaClient) -> dict[str, str] | None:
65
+ """The plan is a nicety on this view: any failing read leaves the company shown without it."""
66
+ try:
67
+ summary = client.call(CREDITS_SUMMARY) or {}
68
+ except CliError:
69
+ return None
70
+ name = summary.get("plan_name") or summary.get("plan")
71
+ return {"name": str(name), "key": str(summary.get("plan") or "")} if name else None
72
+
73
+
74
+ def _describe_memberships(result: dict[str, Any]) -> str:
75
+ active = result.get("active_org")
76
+ lines = [
77
+ f"{'*' if m['org_id'] == active else ' '} {m['name']} {m['role']} [{m['org_id'][:8]}]"
78
+ for m in result["memberships"]
79
+ ]
80
+ return "\n".join(lines) if lines else "No companies yet."
81
+
82
+
83
+ @org.command("list")
84
+ @json_option
85
+ @command_runner(human=_describe_memberships)
86
+ def list_orgs(ctx: click.Context) -> dict[str, Any]:
87
+ """Every company this credential may act on; * marks the active one."""
88
+ client = open_client(ctx, None, require_org=False)
89
+ me: dict[str, Any] = client.call(ME)
90
+ return {"memberships": me["memberships"], "active_org": ctx.meta.get("resolved_org")}
91
+
92
+
93
+ def _describe_selection(ctx: click.Context, result: dict[str, Any]) -> str:
94
+ org_id = result["org_id"]
95
+ org = ctx.meta.get("organization_names", {}).get(org_id) or {"org_id": org_id}
96
+ if not org.get("name"):
97
+ return f"Active company: {org_id}"
98
+ return f"Using {organization_description(org)} [{org_id[:8]}]"
99
+
100
+
101
+ @org.command("use")
102
+ @json_option
103
+ @command_runner(contextual_human=_describe_selection)
104
+ @click.argument("org_id", metavar="ORG")
105
+ def use(ctx: click.Context, org_id: str) -> dict[str, Any]:
106
+ """Choose the company later commands act on.
107
+
108
+ ORG is its name, slug or id."""
109
+ if config.is_org_id(org_id.strip()):
110
+ org_id = org_id.strip()
111
+ if not ctx.obj.get("as_json"):
112
+ try:
113
+ client = open_client(ctx, org_id, require_org=False)
114
+ client.org_id = org_id
115
+ organization_label(ctx, client)
116
+ except CliError:
117
+ pass # choosing a known id still works offline, as it did before
118
+ else:
119
+ open_client(ctx, org_id, require_org=False)
120
+ org_id = ctx.meta["resolved_org"]
121
+ config.set_active_org(org_id)
122
+ return {"org_id": org_id}
123
+
124
+
125
+ # ── create or fill in ───────────────────────────────────────────────────────────
126
+
127
+
128
+ def blank_company(memberships: list[dict[str, Any]]) -> dict[str, Any] | None:
129
+ """The empty company a brand-new account was given at sign-up: the only one, owned, with no website yet."""
130
+ owned = [m for m in memberships if m.get("role") == "owner"]
131
+ if len(memberships) == 1 and len(owned) == 1 and not owned[0].get("website_url"):
132
+ return owned[0]
133
+ return None
134
+
135
+
136
+ def remember_market(org_id: str, market: str | None) -> None:
137
+ if market:
138
+ progress = config.onboarding_progress(org_id)
139
+ config.save_onboarding_progress(org_id, {**progress, "market": market.upper()})
140
+
141
+
142
+ def create_or_fill(ctx: click.Context, client: RecalaClient, *, name: str, website: str, language: str,
143
+ market: str | None, use: bool = True) -> dict[str, Any]:
144
+ """Fill in the account's blank company, else create a new one; it becomes the active company unless use=False."""
145
+ me = client.call(ME) or {}
146
+ memberships = me.get("memberships") or []
147
+ blank = blank_company(memberships)
148
+ previous_id = config.active_org()
149
+ # Only a company the credential still lists can be switched back to; a deleted one is not offered.
150
+ previous = next((m for m in memberships if m.get("org_id") == previous_id), None)
151
+ fields = {"name": name, "website_url": website, "language": language}
152
+ if blank:
153
+ confirm(ctx, client, f"Set up your company as {name} ({website}, {language})?")
154
+ client.org_id = blank["org_id"]
155
+ updated = client.call(ORG_UPDATE, path={"org_id": blank["org_id"]}, json=fields) or {}
156
+ created = {"created": False, "filled_in": True, "org": updated.get("org") or {"id": blank["org_id"], **fields}}
157
+ else:
158
+ confirm(ctx, client, f"Create the company {name} ({website}, {language})?")
159
+ created = client.call(ORG_CREATE, json=fields) or {}
160
+ org_id = str((created.get("org") or {}).get("id") or "")
161
+ if not org_id:
162
+ raise CliError(ExitCode.ERROR, "org_not_created", "Recala did not return the new company's id.")
163
+ if use:
164
+ config.set_active_org(org_id)
165
+ client.org_id = org_id
166
+ remember_market(org_id, market)
167
+ return {**created, "market": (market or "").upper() or None, "active": use,
168
+ "previous_active_org": previous if previous and previous["org_id"] != org_id else None}
169
+
170
+
171
+ def _describe_created(result: dict[str, Any]) -> str:
172
+ """Which company later commands act on, said out loud: creating one switches to it unless --no-use."""
173
+ org, previous = result.get("org") or {}, result.get("previous_active_org")
174
+ name, short = org.get("name"), str(org.get("id") or "")[:8]
175
+ lines = [f"{'Set up' if result.get('filled_in') else 'Created'} {name} [{short}]."]
176
+ if result.get("active", True):
177
+ if previous:
178
+ lines.append(f"{name} is now the active company: later commands act on it, "
179
+ f"not on {previous.get('name') or previous['org_id']}.")
180
+ lines.append(f"Switch back: recala company use {previous['org_id'][:8]}")
181
+ else:
182
+ lines.append(f"{name} is now the active company.")
183
+ else:
184
+ kept = f"{previous.get('name') or previous['org_id']} is still the active company." if previous \
185
+ else "No company is active yet."
186
+ lines.append(f"{kept} To act on {name}: recala company use {short}")
187
+ return "\n".join(lines)
188
+
189
+
190
+ @org.command("create")
191
+ @common_options
192
+ @command_runner(human=_describe_created)
193
+ @click.option("--name", default=None, help="The company's name, as readers know it.")
194
+ @click.option("--website", default=None, help="Its website, e.g. https://cometa.cards")
195
+ @click.option("--language", default=None, help="The language it publishes in (ISO code, e.g. es).")
196
+ @click.option("--market", default=None, help="The country it sells in (ISO code, e.g. ES).")
197
+ @click.option("--use/--no-use", default=True, show_default=True,
198
+ help="Make the new company the active one that later commands act on.")
199
+ @accepted_yes_option
200
+ @dry_run_option
201
+ def create(ctx: click.Context, org_flag: str | None, name: str | None, website: str | None, language: str | None,
202
+ market: str | None, use: bool) -> dict[str, Any]:
203
+ """Create a company and make it active.
204
+
205
+ Fills in the empty company a new account starts with, else creates another; --no-use keeps the
206
+ current active company. For the guided setup (company, website, author, first topics), run
207
+ recala onboard instead.
208
+ """
209
+ client = open_client(ctx, org_flag, require_org=False)
210
+ return create_or_fill(
211
+ ctx, client,
212
+ name=ask(ctx, name, label="Company name", flag="--name"),
213
+ website=ask(ctx, website, label="Website", flag="--website"),
214
+ language=ask(ctx, language, label="Language it publishes in (es, en, ...)", flag="--language").lower(),
215
+ market=ask(ctx, market, label="Country it sells in (ES, US, ...)", flag="--market", default="").upper() or None,
216
+ use=use,
217
+ )
218
+
219
+
220
+ # ── website scan ────────────────────────────────────────────────────────────────
221
+
222
+
223
+ def _names(entities: list[dict[str, Any]], kind: str) -> list[str]:
224
+ """Names of one kind; a service business's services are its products (the server saves both as products)."""
225
+ kinds = {"product", "service"} if kind == "product" else {kind}
226
+ return [str(e.get("name")) for e in entities if e.get("entity_type") in kinds and e.get("name")]
227
+
228
+
229
+ def describe_scan(scan: dict[str, Any]) -> str:
230
+ scan = human_data(scan)
231
+ entities = scan.get("entities") or []
232
+ brand = scan.get("brand") or {}
233
+ website = scan.get("website") or {}
234
+ lines = [f" {website.get('title') or website.get('url')}"]
235
+ if brand.get("core_description") or website.get("description"):
236
+ lines.append(f" {brand.get('core_description') or website.get('description')}")
237
+ if brand.get("brand_tone"):
238
+ lines.append(f" Tone: {brand['brand_tone']}" + (f", {brand['brand_style']}" if brand.get("brand_style") else ""))
239
+ found = False
240
+ for label, kind in (("Industry", "industry"), ("Products", "product"), ("Audiences", "audience"),
241
+ ("Competitors", "competitor"), ("Keywords", "keyword")):
242
+ names = _names(entities, kind)
243
+ if names:
244
+ found = True
245
+ lines.append(f" {label + ':':<12} {', '.join(names[:8])}")
246
+ if not found:
247
+ lines.append(" No products, audiences or competitors could be taken from the site's own text.")
248
+ if website.get("blog_url"):
249
+ lines.append(f" Blog: {website['blog_url']}")
250
+ for warning in (brand.get("voice_warning"), (scan.get("knowledge_ingestion") or {}).get("warning")):
251
+ if warning:
252
+ lines.append(f" Note: {warning}")
253
+ if scan.get("scrape_thin"):
254
+ lines.append(" Warning: the site had almost no readable text. Check the address.")
255
+ return "\n".join(lines)
256
+
257
+
258
+ _PALETTE_SOURCES = {"page": "the site's own declared colours", "brandfetch": "Brandfetch",
259
+ "llm_css_scrape": "the site's styling"}
260
+
261
+
262
+ def scan_findings(scan: dict[str, Any]) -> dict[str, Any]:
263
+ """What the scan actually found, as counts an agent can branch on."""
264
+ entities = scan.get("entities") or []
265
+ brand = scan.get("brand") or {}
266
+ niche = _names(entities, "industry")
267
+ return {
268
+ "products": len(_names(entities, "product")),
269
+ "audiences": len(_names(entities, "audience")),
270
+ "competitors": len(_names(entities, "competitor")),
271
+ "niche": niche[0] if niche else None,
272
+ "voice": bool((brand.get("brand_voice") or brand.get("brand_tone"))
273
+ and brand.get("voice_status") not in {"unknown", "failed"}),
274
+ "palette_source": None if scan.get("palette_source") in {"", "none"} else scan.get("palette_source"),
275
+ }
276
+
277
+
278
+ def describe_findings(result: dict[str, Any]) -> str:
279
+ found = result.get("found") or {}
280
+ if not (found.get("products") or found.get("audiences") or found.get("competitors")
281
+ or found.get("niche") or found.get("voice")):
282
+ return "Read the website but found nothing usable: no products, audiences, competitors, niche or voice."
283
+ parts = [f"{found['products']} products", f"{found['audiences']} audiences", f"{found['competitors']} competitors"]
284
+ parts.append(f"niche {found['niche']}" if found.get("niche") else "no niche")
285
+ parts.append("voice read from the site" if found.get("voice") else "no voice")
286
+ if found.get("palette_source"):
287
+ parts.append(f"colours from {_PALETTE_SOURCES.get(found['palette_source'], found['palette_source'])}")
288
+ return "Saved what Recala found on the website: " + ", ".join(parts) + "."
289
+
290
+
291
+ def save_scan_payload(scan: dict[str, Any], *, website: str, market: str | None, brand_name: str) -> dict[str, Any]:
292
+ """What the portal's wizard sends to save-entities after the person confirms the scan."""
293
+ entities = [e for e in scan.get("entities") or [] if e.get("entity_type") in _ENTITY_TYPES and e.get("name")]
294
+ brand = scan.get("brand") or {}
295
+ payload: dict[str, Any] = {
296
+ "entities": [{"name": e["name"], "entity_type": e["entity_type"], "description": e.get("description") or "",
297
+ "confidence": e.get("confidence") or 0.9, "source": e.get("source") or "website"}
298
+ for e in entities],
299
+ "products": [{"name": name} for name in _names(entities, "product")],
300
+ "competitors": [{"name": name} for name in _names(entities, "competitor")],
301
+ "website_url": website,
302
+ "author_display_name": brand_name or None,
303
+ "blog_base_url": (scan.get("website") or {}).get("blog_url") or None,
304
+ "brand_tone": brand.get("brand_tone") or None,
305
+ "brand_style": brand.get("brand_style") or None,
306
+ "content_scope": "national" if market else "local",
307
+ "replace_existing": False,
308
+ "onboarding_method": "website",
309
+ "channel": "cli",
310
+ }
311
+ if market:
312
+ payload["country"] = market
313
+ return {key: value for key, value in payload.items() if value is not None}
314
+
315
+
316
+ def scan_website(ctx: click.Context, client: RecalaClient, *, website: str | None) -> dict[str, Any]:
317
+ org = (client.call(ORG_SHOW, path={"org_id": str(client.org_id)}) or {}).get("org") or {}
318
+ address = website or org.get("website_url")
319
+ if not address:
320
+ raise CliError(ExitCode.CONFLICT, "no_website", "This company has no website yet: recala company create")
321
+ # Reading the site is already a write: the server saves the brand it finds and spends a model call.
322
+ confirm(ctx, client, f"Read {address} and save the brand Recala finds there?")
323
+ with indicator(ctx, f"Reading {address}") as spinner:
324
+ scan = client.call(ONBOARDING_SCRAPE, json={"url": address}, timeout=_SCAN_TIMEOUT_SECONDS) or {}
325
+ if spinner is not None:
326
+ spinner.done(f"Read {address}")
327
+ if not (ctx.find_root().obj or {}).get("as_json"):
328
+ click.echo(describe_scan(scan), err=True)
329
+ if scan.get("scrape_thin"):
330
+ raise CliError(ExitCode.CONFLICT, "scrape_thin",
331
+ f"Recala could not read {address}: double-check the address (recala company create --website).")
332
+ market = config.onboarding_progress(str(client.org_id)).get("market")
333
+ confirm(ctx, client, "Save these findings?",
334
+ machine_summary=f"Save these findings for {org.get('name') or 'this company'}?")
335
+ saved = client.call(ONBOARDING_SAVE, json=save_scan_payload(scan, website=address, market=market,
336
+ brand_name=org.get("name") or "")) or {}
337
+ config.save_onboarding_progress(str(client.org_id), {**config.onboarding_progress(str(client.org_id)),
338
+ "scanned": True})
339
+ return {"scan": scan, "saved": saved, "found": scan_findings(scan)}
340
+
341
+
342
+ @org.command("scan")
343
+ @common_options
344
+ @command_runner(human=describe_findings)
345
+ @click.option("--website", default=None, help="Read this address instead of the company's website.")
346
+ @accepted_yes_option
347
+ @dry_run_option
348
+ def scan(ctx: click.Context, org_flag: str | None, website: str | None) -> dict[str, Any]:
349
+ """Read the website; save what you confirm.
350
+
351
+ Brand, products, audiences and competitors."""
352
+ return scan_website(ctx, open_client(ctx, org_flag), website=website)
@@ -0,0 +1,70 @@
1
+ """`recala company profile show`: the brand profile the articles are written in (read-only).
2
+
3
+ It gathers what the portal's brand and profile pages show: the author and voice, the positioning
4
+ and the default article intent. Changing it stays in the portal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ from recala_cli.display import display_name
14
+ from recala_cli.endpoints import PROFILE_AUTHOR, PROFILE_INTENT, PROFILE_POSITIONING
15
+ from recala_cli.runtime import command_runner, common_options, open_client, optional_read
16
+
17
+ CHANGE_IN_PORTAL = "To change any of this, use Brand and Settings in the portal."
18
+
19
+
20
+ def _line(label: str, value: Any) -> str | None:
21
+ text = display_name(value) if isinstance(value, str) else ", ".join(display_name(item) for item in value or [])
22
+ return f" {label}: {text}" if text else None
23
+
24
+
25
+ def _section(title: str, rows: list[str | None]) -> list[str]:
26
+ present = [row for row in rows if row]
27
+ return [f"{title}:", *present] if present else []
28
+
29
+
30
+ def describe_profile(profile: dict[str, Any]) -> str:
31
+ author, positioning = profile.get("author") or {}, profile.get("positioning") or {}
32
+ where = ", ".join(part for part in (author.get("city"), author.get("region"), author.get("country")) if part)
33
+ blocks = [
34
+ _section("Author", [_line("Name", author.get("author_display_name")), _line("Title", author.get("author_title")),
35
+ _line("Bio", author.get("author_bio"))]),
36
+ _section("Voice", [_line("Tone", author.get("brand_tone")), _line("Voice", author.get("brand_voice")),
37
+ _line("Personality", author.get("brand_personality")), _line("Style", author.get("brand_style")),
38
+ _line("Writing guidelines", author.get("writing_guidelines")),
39
+ _line("Never say", author.get("forbidden_phrases"))]),
40
+ _section("Company", [_line("Industry", author.get("niche")), _line("Language", author.get("language")),
41
+ _line("Where", where), _line("Reaches", author.get("content_scope"))]),
42
+ _section("Positioning", [_line("One line", positioning.get("one_line_pov")), _line("Who we are", positioning.get("who_we_are")),
43
+ _line("Market", positioning.get("market_category")),
44
+ _line("Persona", positioning.get("author_persona")),
45
+ *[_line(f"Against {wedge['name']}", wedge.get("our_wedge_vs_them"))
46
+ for wedge in positioning.get("wedges") or [] if wedge.get("name")]]),
47
+ _section("Articles", [_line("Default intent", (profile.get("intent") or {}).get("default_intent"))]),
48
+ ]
49
+ lines = [line for block in blocks for line in block]
50
+ return "\n".join([*lines, CHANGE_IN_PORTAL] if lines else ["The brand profile is empty.", CHANGE_IN_PORTAL])
51
+
52
+
53
+ @click.group()
54
+ def profile() -> None:
55
+ """The company's brand profile: author, voice and positioning."""
56
+
57
+
58
+ @profile.command("show")
59
+ @common_options
60
+ @command_runner(human=describe_profile)
61
+ def show(ctx: click.Context, org_flag: str | None) -> dict[str, Any]:
62
+ """Show the author, voice and positioning.
63
+
64
+ Also the default intent the articles are written with."""
65
+ client = open_client(ctx, org_flag)
66
+ return {
67
+ "author": optional_read(client, PROFILE_AUTHOR),
68
+ "positioning": optional_read(client, PROFILE_POSITIONING),
69
+ "intent": optional_read(client, PROFILE_INTENT),
70
+ }
@@ -0,0 +1,246 @@
1
+ """Read the company's tracked questions and confirm its first baseline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter, defaultdict
7
+ from typing import Any
8
+
9
+ import click
10
+ from click.core import ParameterSource
11
+
12
+ from recala_cli.endpoints import QUESTION_PANEL, QUESTION_PANEL_BASELINE
13
+ from recala_cli.errors import CliError, ExitCode
14
+ from recala_cli.runtime import command_runner, common_options, confirm, open_client, accepted_yes_option, dry_run_option
15
+ from recala_cli.views import company_timezone, engine_name, local_time, question_counts, timezone_label
16
+
17
+ NOT_ENABLED = "Question tracking is not switched on for this company yet."
18
+ BASELINE_REFUSALS = {
19
+ (403, "human_only"): (
20
+ "Starting a baseline needs a person signed in, not an API key. Run: recala login"
21
+ ),
22
+ (403, "ai_checks_not_in_plan"): "Your plan does not include AI checks.",
23
+ (409, "baseline_exists"): (
24
+ "A baseline already exists; your questions are measured on the plan's schedule. "
25
+ "See: recala questions"
26
+ ),
27
+ (409, "panel_run_in_progress"): "A baseline is already running. See: recala questions",
28
+ (422, "second_language_not_in_plan"): "That language is not included in your plan.",
29
+ }
30
+ language_option = click.option(
31
+ "--language", default=None, metavar="xx",
32
+ help="Panel language: two lowercase letters (default: the company's language).",
33
+ )
34
+ all_option = click.option("--all", "all_questions", is_flag=True,
35
+ help="Show every tracked question in human output (JSON always includes all).")
36
+
37
+ # question_panel/engines.py, engine.py, cron.py and DataForSEO's failure kinds.
38
+ _ERROR_PHRASES = { # each reads after a count: "3 had no AI overview"
39
+ "timeout": "timed out", "blocked": "were blocked", "lost": "were lost", "error": "failed",
40
+ "empty_serp": "found no search results", "no_ai_overview": "had no AI overview",
41
+ "ai_overview_without_sources": "had an AI overview with no readable sources",
42
+ "empty_answer": "came back empty", "no_sources": "named no sources",
43
+ "unresolved_sources": "had sources that could not be resolved",
44
+ "payment_required": "were refused by the data provider (payment needed)",
45
+ "auth": "were refused by the data provider (sign-in)", "rate_limited": "hit the data provider's rate limit",
46
+ "invalid_request": "were invalid requests", "provider_unavailable": "met an unavailable data provider",
47
+ }
48
+ _OTHER_PHRASE = "had another problem"
49
+
50
+
51
+ def _language_body(language: str | None) -> dict[str, str]:
52
+ if language is None:
53
+ return {}
54
+ if not re.fullmatch(r"[a-z]{2}", language):
55
+ raise CliError(
56
+ ExitCode.USAGE, "invalid_language",
57
+ "--language must be two lowercase letters, for example: es.",
58
+ )
59
+ return {"language": language}
60
+
61
+
62
+ def _describe_measurements(view: dict[str, Any], zone: str | None) -> list[str]:
63
+ """Keep rolling ownership and latest per-question citations separate, per engine."""
64
+ labels = {}
65
+ latest_by_engine = defaultdict(list)
66
+ for area in view["panel"].get("areas") or []:
67
+ for question in area["questions"]:
68
+ for engine, tier in (question.get("tiers") or {}).items():
69
+ label = tier.get("label")
70
+ labels[engine] = label if label and label != engine else engine_name(engine)
71
+ if tier.get("latest"):
72
+ latest_by_engine[engine].append(tier["latest"])
73
+
74
+ lines = []
75
+ for engine, counts in view.get("summary", {}).items():
76
+ if counts.get("owned") is not None:
77
+ lines.append(
78
+ f"{labels.get(engine, engine_name(engine))}: {counts['owned']} owned questions "
79
+ f"in the last {view['window_days']} days."
80
+ )
81
+ for engine, latest in latest_by_engine.items():
82
+ measured = [
83
+ check for check in latest
84
+ if check["status"] == "ok" and isinstance(check.get("own_domain_cited"), bool)
85
+ ]
86
+ cited = sum(check["own_domain_cited"] for check in measured)
87
+ result = (
88
+ f"{cited} of {len(measured)} measured questions cited"
89
+ if measured else "no usable measurements"
90
+ )
91
+ when = local_time(max((check.get("run_at") or "" for check in latest), default=""), zone)
92
+ lines.append(
93
+ f"Latest {labels[engine]} checks: {result}; "
94
+ f"{len(latest) - len(measured)} without a usable answer (most recent: {when})."
95
+ )
96
+ return lines
97
+
98
+
99
+ def _citation_state(latest: dict[str, Any]) -> str:
100
+ cited = latest.get("own_domain_cited")
101
+ if latest.get("status") != "ok" or not isinstance(cited, bool):
102
+ return "unknown"
103
+ return "cited" if cited else "not cited"
104
+
105
+
106
+ def _question_lines(view: dict[str, Any], *, all_questions: bool) -> list[str]:
107
+ panel = view["panel"]
108
+ available, total = question_counts(panel)
109
+ limit = available if all_questions else min(10, available)
110
+ engines = dict.fromkeys(view.get("summary") or {})
111
+ for area in panel.get("areas") or []:
112
+ for question in area.get("questions") or []:
113
+ engines.update(dict.fromkeys(question.get("tiers") or {}))
114
+ lines, shown = [], 0
115
+ for area in panel.get("areas") or []:
116
+ questions = (area.get("questions") or [])[:max(0, limit - shown)]
117
+ if not questions:
118
+ continue
119
+ lines.append(f"\n{area.get('area') or 'Other questions'}")
120
+ for question in questions:
121
+ tiers = question.get("tiers") or {}
122
+ results = (f"{engine_name(engine)}: {_citation_state((tiers.get(engine) or {}).get('latest') or {})}"
123
+ for engine in engines)
124
+ lines += [f" {question['question']}", " " + " · ".join(results)]
125
+ shown += 1
126
+ lines.append(f"Showing {shown} of {total} questions."
127
+ + (" All: recala questions --all" if not all_questions and shown < total else ""))
128
+ return lines
129
+
130
+
131
+ def _failure_lines(view: dict[str, Any]) -> list[str]:
132
+ """Failed checks over the whole window, in words. The latest-check summary above counts only each question's
133
+ newest check, so this line names its own scope instead of repeating "unknown"."""
134
+ failures = defaultdict(Counter)
135
+ for failure in view.get("engine_failures") or []:
136
+ failures[failure["engine"]][_ERROR_PHRASES.get(failure.get("error_kind"), _OTHER_PHRASE)] += failure["count"]
137
+ days = view.get("window_days")
138
+ window = f"in the last {days} days" if days else "recently"
139
+ lines = []
140
+ for engine, counts in failures.items():
141
+ total = sum(counts.values())
142
+ reasons = ", ".join(f"{count} {phrase}" for phrase, count in counts.items())
143
+ lines.append(f"{engine_name(engine)}: {total} check{'' if total == 1 else 's'} {window} "
144
+ f"gave no usable answer ({reasons})")
145
+ return lines
146
+
147
+
148
+ def _describe_questions(ctx: click.Context, view: dict[str, Any]) -> str:
149
+ zone = company_timezone(ctx, view)
150
+ baseline = view.get("baseline")
151
+ run = view.get("latest_run")
152
+ if baseline is None:
153
+ running = bool(run) and run.get("status") == "running"
154
+ lines = ["No baseline yet." + ("" if running else " Start it: recala questions baseline")]
155
+ elif baseline.get("captured_at"):
156
+ lines = [f"Baseline captured at {local_time(baseline['captured_at'], zone)}."]
157
+ else:
158
+ lines = ["Baseline exists; its capture date is not recorded."]
159
+ if run:
160
+ lines.append(f"Latest {run['kind'].replace('_', ' ')} run is {run['status'].replace('_', ' ')}.")
161
+ panel = view["panel"]
162
+ groups = ", ".join(
163
+ f"{count} {group.replace('_', ' ')}"
164
+ for group, count in (panel.get("by_group") or {}).items()
165
+ )
166
+ lines.insert(0, timezone_label(zone))
167
+ lines.append(f"Tracking {question_counts(panel)[1]} questions" + (f" ({groups})" if groups else "") + ".")
168
+ lines.extend(_describe_measurements(view, zone))
169
+ lines.extend(_failure_lines(view))
170
+ lines.extend(_question_lines(view, all_questions=ctx.meta.get("all_questions", False)))
171
+ return "\n".join(lines)
172
+
173
+
174
+ @click.group(invoke_without_command=True)
175
+ @common_options
176
+ @language_option
177
+ @all_option
178
+ @click.pass_context
179
+ def questions(
180
+ ctx: click.Context, org_flag: str | None, as_json: bool, language: str | None,
181
+ all_questions: bool,
182
+ ) -> None:
183
+ """Show tracked questions.
184
+
185
+ Start their baseline with: recala questions baseline"""
186
+ if ctx.invoked_subcommand is None:
187
+ ctx.invoke(show, org_flag=org_flag, as_json=as_json, language=language, all_questions=all_questions)
188
+ return
189
+ typed = [name for name in ("org_flag", "as_json", "language", "all_questions")
190
+ if ctx.get_parameter_source(name) is ParameterSource.COMMANDLINE]
191
+ if typed:
192
+ # The subcommand never sees them: a baseline would start for another company or language.
193
+ raise click.UsageError(
194
+ f"Put --org, --json, --language and --all after the subcommand: "
195
+ f"recala questions {ctx.invoked_subcommand} --org <company>", ctx)
196
+
197
+
198
+ @questions.command("show")
199
+ @common_options
200
+ @language_option
201
+ @all_option
202
+ @command_runner(contextual_human=_describe_questions)
203
+ def show(ctx: click.Context, org_flag: str | None, language: str | None, all_questions: bool) -> dict[str, Any]:
204
+ """Show the baseline, tracked questions and latest measurements."""
205
+ params = _language_body(language)
206
+ ctx.meta["all_questions"] = all_questions
207
+ client = open_client(ctx, org_flag)
208
+ try:
209
+ return client.call(QUESTION_PANEL, params=params)
210
+ except CliError as error:
211
+ if error.status == 404:
212
+ error.message = NOT_ENABLED
213
+ raise
214
+
215
+
216
+ def _describe_started(result: dict[str, Any]) -> str:
217
+ return (
218
+ f"Baseline started ({result['size']} questions, plan {result['plan']}). "
219
+ "It runs in the background; check it with: recala questions"
220
+ )
221
+
222
+
223
+ @questions.command("baseline")
224
+ @common_options
225
+ @language_option
226
+ @command_runner(human=_describe_started)
227
+ @accepted_yes_option
228
+ @dry_run_option
229
+ def baseline(ctx: click.Context, org_flag: str | None, language: str | None) -> dict[str, Any]:
230
+ """Start the question baseline (asks first).
231
+
232
+ Uses your plan's AI-check allowance."""
233
+ body = _language_body(language)
234
+ client = open_client(ctx, org_flag)
235
+ confirm(ctx, client, "Start the question baseline? This uses your plan's AI-check allowance.")
236
+ try:
237
+ return client.call(QUESTION_PANEL_BASELINE, json=body)
238
+ except CliError as error:
239
+ message = BASELINE_REFUSALS.get((error.status, error.code))
240
+ if error.status == 404:
241
+ message = NOT_ENABLED
242
+ elif error.status == 403 and message is None:
243
+ message = "Only the company owner or assigned staff can start it."
244
+ if message:
245
+ error.message = message
246
+ raise