@topy-ai/maggie 0.7.47 → 0.7.49

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 (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. package/references/provider-adapter-contract.md +33 -0
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env python3
2
+ """Provider-neutral contracts for long-running service imports.
3
+
4
+ Commands (routed by ``maggie service``):
5
+
6
+ - ``import-stages-validate``: validate a multi-stage import state snapshot
7
+ (source → enrichment/research → verification) — idempotent stage keys,
8
+ bounded progress and retries, heartbeat-based stale detection, cancellation
9
+ propagation, and out-of-order completion against superseded inputs.
10
+ - ``import-readiness``: fail before a job is queued when the host worker lacks
11
+ Python modules, a browser/driver, crawler configuration (by env name only),
12
+ a UTF-8 runtime, or normalizable country labels. A rate-limit probe runs only
13
+ when explicitly requested.
14
+ - ``fact-provenance``: merge per-field facts from the user and providers.
15
+ User-entered facts are authoritative; provider disagreement becomes a
16
+ reviewable conflict artifact instead of a silent overwrite, and one failing
17
+ provider never blocks the others.
18
+
19
+ All commands are read-only apart from their explicit ``--output`` report.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import importlib.util
26
+ import json
27
+ import locale
28
+ import os
29
+ import re
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import unicodedata
34
+ from datetime import datetime, timedelta, timezone
35
+ from pathlib import Path
36
+ from typing import Any
37
+ from urllib.error import HTTPError, URLError
38
+ from urllib.parse import urlsplit
39
+ from urllib.request import Request, urlopen
40
+
41
+ STAGES_SCHEMA = "maggie-service-import-stages.v1"
42
+ READINESS_SCHEMA = "maggie-service-import-readiness.v1"
43
+ PROVENANCE_SCHEMA = "maggie-service-fact-provenance.v1"
44
+ MERGE_SCHEMA = "maggie-service-fact-merge.v1"
45
+
46
+ STAGE_NAMES = ("source", "enrichment", "research", "verification")
47
+ DEFAULT_DEPENDENCIES = {"source": [], "enrichment": ["source"], "research": ["source"], "verification": ["enrichment", "research"]}
48
+ STAGE_STATES = {"queued", "running", "succeeded", "failed", "skipped", "cancelled"}
49
+ IMPORT_STATES = {"queued", "running", "succeeded", "partial", "failed", "cancelled", "stale"}
50
+ SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
51
+ SAFE_ERROR = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
52
+ SAFE_SOURCE = re.compile(r"^[a-z][a-z0-9_-]{0,39}$")
53
+ DEFAULT_PRECEDENCE = ["user", "fresha", "google", "website"]
54
+
55
+
56
+ def _time(value: object, label: str, errors: list[str]) -> datetime | None:
57
+ if value in (None, ""):
58
+ return None
59
+ if not isinstance(value, str):
60
+ errors.append(f"{label} must be an ISO-8601 timestamp")
61
+ return None
62
+ try:
63
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
64
+ except ValueError:
65
+ errors.append(f"{label} must be an ISO-8601 timestamp")
66
+ return None
67
+ if parsed.tzinfo is None:
68
+ errors.append(f"{label} must include a UTC offset")
69
+ return None
70
+ return parsed
71
+
72
+
73
+ def _read(path: Path) -> dict[str, Any]:
74
+ value = json.loads(path.read_text(encoding="utf-8"))
75
+ if not isinstance(value, dict):
76
+ raise ValueError(f"{path.name} root must be an object")
77
+ return value
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # D24: multi-stage import state
82
+
83
+
84
+ def derive_import_state(stages: dict[str, dict], cancellation_requested: bool) -> str:
85
+ states = {name: str(stage.get("state")) for name, stage in stages.items()}
86
+ if cancellation_requested and not any(state in {"running", "queued"} for state in states.values()):
87
+ return "cancelled"
88
+ if all(state in {"succeeded", "skipped"} for state in states.values()):
89
+ return "succeeded"
90
+ exhausted = [name for name, stage in stages.items() if stage.get("state") == "failed" and int(stage.get("attempt") or 0) >= int(stage.get("maxAttempts") or 0)]
91
+ if exhausted and not any(state in {"running", "queued"} for state in states.values()):
92
+ return "partial" if any(state == "succeeded" for state in states.values()) else "failed"
93
+ if any(state in {"running", "succeeded", "failed"} for state in states.values()):
94
+ return "running"
95
+ return "queued"
96
+
97
+
98
+ def validate_stages(payload: dict[str, Any], now: datetime | None = None) -> dict[str, Any]:
99
+ errors: list[str] = []; warnings: list[str] = []
100
+ if payload.get("schemaVersion") != STAGES_SCHEMA:
101
+ errors.append(f"schemaVersion must be {STAGES_SCHEMA}")
102
+ import_id = payload.get("importId")
103
+ if not isinstance(import_id, str) or not SAFE_ID.fullmatch(import_id):
104
+ errors.append("importId must be a safe identifier"); import_id = ""
105
+ if not isinstance(payload.get("idempotencyKey"), str) or not SAFE_ID.fullmatch(payload["idempotencyKey"]):
106
+ errors.append("idempotencyKey must be a safe identifier")
107
+ declared_state = payload.get("state")
108
+ if declared_state not in IMPORT_STATES:
109
+ errors.append("state is unsupported")
110
+ stale_after = payload.get("staleAfterSeconds")
111
+ if not isinstance(stale_after, int) or isinstance(stale_after, bool) or not 30 <= stale_after <= 86400:
112
+ errors.append("staleAfterSeconds must be an integer between 30 and 86400"); stale_after = 900
113
+ now = now or _time(payload.get("evaluatedAt"), "evaluatedAt", errors) or datetime.now(timezone.utc)
114
+ cancellation = payload.get("cancellation") if isinstance(payload.get("cancellation"), dict) else {}
115
+ cancel_requested = cancellation.get("requested") is True
116
+ cancel_at = _time(cancellation.get("requestedAt"), "cancellation.requestedAt", errors) if cancel_requested else None
117
+ if cancel_requested and cancel_at is None:
118
+ errors.append("cancellation.requestedAt is required when cancellation is requested")
119
+
120
+ raw = payload.get("stages")
121
+ if not isinstance(raw, list):
122
+ errors.append("stages must be an array"); raw = []
123
+ stages: dict[str, dict] = {}; keys: set[str] = set()
124
+ for index, stage in enumerate(raw):
125
+ label = f"stages[{index}]"
126
+ if not isinstance(stage, dict):
127
+ errors.append(f"{label} must be an object"); continue
128
+ name = stage.get("name")
129
+ if name not in STAGE_NAMES:
130
+ errors.append(f"{label}.name must be one of {', '.join(STAGE_NAMES)}"); continue
131
+ if name in stages:
132
+ errors.append(f"{label}.name {name!r} is duplicated"); continue
133
+ stages[name] = stage
134
+ key = stage.get("stageKey")
135
+ if not isinstance(key, str) or not SAFE_ID.fullmatch(key) or (import_id and not key.startswith(import_id + ":")):
136
+ errors.append(f"{name}.stageKey must be a safe identifier prefixed with '<importId>:'")
137
+ elif key in keys:
138
+ errors.append(f"{name}.stageKey is duplicated; retries must reuse, not mint, keys per stage")
139
+ else:
140
+ keys.add(key)
141
+ if stage.get("state") not in STAGE_STATES:
142
+ errors.append(f"{name}.state is unsupported")
143
+ attempt, maximum = stage.get("attempt"), stage.get("maxAttempts")
144
+ if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 0 or not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < 1 or attempt > maximum:
145
+ errors.append(f"{name}.attempt must be 0..maxAttempts and maxAttempts >= 1")
146
+ progress = stage.get("progress")
147
+ if not isinstance(progress, dict) or not all(isinstance(progress.get(k), int) and not isinstance(progress.get(k), bool) for k in ("done", "total")) or not 0 <= progress["done"] <= progress["total"]:
148
+ errors.append(f"{name}.progress must be {{done, total}} with 0 <= done <= total")
149
+ elif stage.get("state") == "succeeded" and progress["done"] != progress["total"]:
150
+ errors.append(f"{name} succeeded with incomplete progress {progress['done']}/{progress['total']}")
151
+ if stage.get("state") == "failed" and not (isinstance(stage.get("errorCode"), str) and SAFE_ERROR.fullmatch(stage["errorCode"])):
152
+ errors.append(f"{name}.errorCode is required for a failed stage")
153
+ missing = [name for name in STAGE_NAMES if name not in stages]
154
+ if missing:
155
+ errors.append("stages must include " + ", ".join(missing))
156
+
157
+ stale: list[str] = []; out_of_order: list[str] = []
158
+ times: dict[str, dict[str, datetime | None]] = {}
159
+ for name, stage in stages.items():
160
+ times[name] = {field: _time(stage.get(field), f"{name}.{field}", errors) for field in ("startedAt", "completedAt", "heartbeatAt")}
161
+ for name, stage in stages.items():
162
+ deps = stage.get("dependsOn", DEFAULT_DEPENDENCIES[name])
163
+ if not isinstance(deps, list) or any(dep not in STAGE_NAMES or dep == name for dep in deps):
164
+ errors.append(f"{name}.dependsOn must list other stage names"); deps = []
165
+ if name == "source" and deps:
166
+ errors.append("source cannot depend on another stage")
167
+ state = stage.get("state")
168
+ if state in {"running", "succeeded"}:
169
+ unmet = [dep for dep in deps if dep in stages and stages[dep].get("state") not in {"succeeded", "skipped"}]
170
+ if unmet:
171
+ errors.append(f"{name} is {state} before dependencies finished: {', '.join(unmet)}")
172
+ started = times[name]["startedAt"]
173
+ for dep in deps:
174
+ finished = times.get(dep, {}).get("completedAt")
175
+ if started and finished and started < finished:
176
+ out_of_order.append(f"{name} started before {dep} completed")
177
+ if state == "running":
178
+ beat = times[name]["heartbeatAt"] or times[name]["startedAt"]
179
+ if beat is None:
180
+ errors.append(f"{name} is running without heartbeatAt/startedAt")
181
+ elif now - beat > timedelta(seconds=stale_after):
182
+ stale.append(name)
183
+ if state in {"succeeded", "failed"} and times[name]["completedAt"] is None:
184
+ errors.append(f"{name}.completedAt is required once the stage finished")
185
+ # A downstream stage records which source revision it consumed. A late
186
+ # result built from a superseded source revision must not be applied.
187
+ if name != "source" and state == "succeeded" and "source" in stages:
188
+ consumed = stage.get("inputRevision")
189
+ current = stages["source"].get("resultRevision")
190
+ if consumed is None or current is None:
191
+ errors.append(f"{name}.inputRevision and source.resultRevision are required to reject superseded results")
192
+ elif consumed != current and stage.get("resultDiscarded") is not True:
193
+ out_of_order.append(f"{name} applied a result from source revision {consumed!r}; current is {current!r}")
194
+ if cancel_requested and cancel_at and state == "succeeded" and times[name]["completedAt"] and times[name]["completedAt"] > cancel_at and stage.get("resultDiscarded") is not True:
195
+ errors.append(f"{name} completed after cancellation was requested; mark resultDiscarded instead of applying it")
196
+ if cancel_requested and cancellation.get("propagated") is True:
197
+ still = [name for name, stage in stages.items() if stage.get("state") in {"queued", "running"}]
198
+ if still:
199
+ errors.append("cancellation is marked propagated but stages are still active: " + ", ".join(still))
200
+ if out_of_order:
201
+ errors.append("out-of-order completion: " + "; ".join(out_of_order))
202
+
203
+ derived = derive_import_state(stages, cancel_requested) if not missing else None
204
+ if stale:
205
+ derived = "stale"
206
+ if declared_state != "stale":
207
+ errors.append("stale running stage(s) without heartbeat for more than staleAfterSeconds: " + ", ".join(stale) + "; mark the import stale and requeue with the same stageKey")
208
+ if not isinstance(payload.get("nextAction"), str) or not payload["nextAction"].strip():
209
+ errors.append("nextAction is required for a stale import")
210
+ if derived and declared_state in IMPORT_STATES and declared_state != derived:
211
+ errors.append(f"state {declared_state!r} does not match the stage-derived state {derived!r}")
212
+ if cancel_requested and not cancellation.get("propagated") and not any(stage.get("state") in {"queued", "running"} for stage in stages.values()):
213
+ warnings.append("cancellation requested and no stage is active; set cancellation.propagated")
214
+ progress_total = sum(stage["progress"]["total"] for stage in stages.values() if isinstance(stage.get("progress"), dict) and isinstance(stage["progress"].get("total"), int))
215
+ progress_done = sum(stage["progress"]["done"] for stage in stages.values() if isinstance(stage.get("progress"), dict) and isinstance(stage["progress"].get("done"), int))
216
+ return {
217
+ "schemaVersion": STAGES_SCHEMA + "-report", "passed": not errors, "importId": import_id or None,
218
+ "declaredState": declared_state, "derivedState": derived, "staleStages": stale,
219
+ "progress": {"done": progress_done, "total": progress_total, "ratio": round(progress_done / progress_total, 4) if progress_total else 0.0},
220
+ "stages": [{"name": name, "state": stages[name].get("state"), "attempt": stages[name].get("attempt"), "maxAttempts": stages[name].get("maxAttempts")} for name in STAGE_NAMES if name in stages],
221
+ "errors": errors, "warnings": warnings,
222
+ }
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # D25: provider / crawler readiness
227
+
228
+ COUNTRY_ALIASES = {
229
+ "GB": ["united kingdom", "uk", "u.k.", "great britain", "britain", "england", "scotland", "wales", "northern ireland", "vereinigtes königreich", "royaume-uni", "reino unido", "regno unito", "verenigd koninkrijk", "英国", "英國", "联合王国", "聯合王國"],
230
+ "IE": ["ireland", "republic of ireland", "irland", "irlande", "irlanda", "爱尔兰", "愛爾蘭"],
231
+ "US": ["united states", "united states of america", "usa", "u.s.", "us", "vereinigte staaten", "états-unis", "etats-unis", "estados unidos", "stati uniti", "美国", "美國"],
232
+ "CA": ["canada", "kanada", "加拿大"],
233
+ "AU": ["australia", "australien", "australie", "澳大利亚", "澳洲"],
234
+ "NZ": ["new zealand", "neuseeland", "nouvelle-zélande", "新西兰", "紐西蘭"],
235
+ "DE": ["germany", "deutschland", "allemagne", "alemania", "germania", "德国", "德國"],
236
+ "FR": ["france", "frankreich", "francia", "法国", "法國"],
237
+ "ES": ["spain", "españa", "espana", "spanien", "espagne", "spagna", "西班牙"],
238
+ "IT": ["italy", "italia", "italien", "italie", "意大利", "義大利"],
239
+ "NL": ["netherlands", "the netherlands", "nederland", "niederlande", "pays-bas", "países bajos", "荷兰", "荷蘭"],
240
+ "CN": ["china", "people's republic of china", "中国", "中國", "中华人民共和国"],
241
+ "HK": ["hong kong", "hong kong sar", "香港"],
242
+ "TW": ["taiwan", "台灣", "臺灣", "台湾"],
243
+ "SG": ["singapore", "新加坡"],
244
+ "JP": ["japan", "日本"],
245
+ "AE": ["united arab emirates", "uae", "阿联酋", "阿聯酋"],
246
+ }
247
+ _ALIAS_INDEX = {alias: code for code, aliases in COUNTRY_ALIASES.items() for alias in aliases}
248
+
249
+
250
+ def repair_mojibake(value: str) -> str:
251
+ """Undo UTF-8 text that was decoded as Latin-1/CP1252 (e.g. ``Königreich``)."""
252
+ if not re.search(r"[ÃÂâ€]", value):
253
+ return value
254
+ for codec in ("cp1252", "latin-1"):
255
+ try:
256
+ repaired = value.encode(codec).decode("utf-8")
257
+ except (UnicodeEncodeError, UnicodeDecodeError):
258
+ continue
259
+ if repaired != value:
260
+ return repaired
261
+ return value
262
+
263
+
264
+ def normalize_country(label: str) -> dict[str, Any]:
265
+ original = label
266
+ repaired = repair_mojibake(label)
267
+ text = unicodedata.normalize("NFC", repaired).strip()
268
+ simplified = re.sub(r"\s+", " ", text.casefold().replace("’", "'"))
269
+ code = None
270
+ if re.fullmatch(r"[a-z]{2}", simplified) and simplified.upper() in COUNTRY_ALIASES:
271
+ code = simplified.upper()
272
+ code = code or _ALIAS_INDEX.get(simplified)
273
+ if code is None:
274
+ try:
275
+ import pycountry # type: ignore[import-not-found]
276
+
277
+ match = pycountry.countries.lookup(text)
278
+ code = match.alpha_2
279
+ except Exception: # noqa: BLE001 - optional dependency, any lookup failure is "unknown"
280
+ code = None
281
+ return {"input": original, "repaired": repaired != original, "normalized": text, "iso2": code, "passed": code is not None}
282
+
283
+
284
+ def _which_browser() -> tuple[str | None, str | None]:
285
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "firefox"):
286
+ path = shutil.which(name)
287
+ if path:
288
+ return name, path
289
+ for env in ("CHROME_PATH", "CHROME_BIN", "PUPPETEER_EXECUTABLE_PATH"):
290
+ value = os.environ.get(env, "")
291
+ if value and Path(value).is_file() and os.access(value, os.X_OK):
292
+ return env, value
293
+ return None, None
294
+
295
+
296
+ def _version(binary: str) -> str | None:
297
+ try:
298
+ proc = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=15, check=False)
299
+ except (OSError, subprocess.SubprocessError):
300
+ return None
301
+ text = (proc.stdout or proc.stderr).strip()
302
+ return text[:120] if proc.returncode == 0 and text else None
303
+
304
+
305
+ def _major(version: str | None) -> int | None:
306
+ match = re.search(r"(\d+)\.", version or "")
307
+ return int(match.group(1)) if match else None
308
+
309
+
310
+ def readiness(modules: list[str], browser: bool, webdriver: bool, env_names: list[str], countries: list[str], probe_url: str | None, probe_timeout: float) -> dict[str, Any]:
311
+ checks: list[dict[str, Any]] = []
312
+
313
+ def add(check: str, passed: bool, detail: str, remediation: str = "") -> None:
314
+ checks.append({"check": check, "passed": passed, "detail": detail, **({"remediation": remediation} if not passed and remediation else {})})
315
+
316
+ for module in modules:
317
+ found = importlib.util.find_spec(module) is not None
318
+ add(f"python:{module}", found, "importable" if found else "not importable", f"install it in the worker environment (e.g. `python3 -m pip install {module}`) and restart the worker")
319
+ browser_version = None
320
+ if browser or webdriver:
321
+ name, path = _which_browser()
322
+ browser_version = _version(path) if path else None
323
+ add("browser", bool(path and browser_version), f"{name}: {browser_version}" if browser_version else ("found but `--version` failed" if path else "no Chrome/Chromium/Firefox executable found"), "install Chrome or Chromium on the worker host, or set CHROME_PATH to the executable")
324
+ if webdriver:
325
+ driver = shutil.which("chromedriver") or shutil.which("geckodriver")
326
+ driver_version = _version(driver) if driver else None
327
+ add("webdriver", bool(driver_version), driver_version or "no chromedriver/geckodriver on PATH", "install the driver matching the browser major version, or use Selenium Manager with network access")
328
+ if driver_version and browser_version and "chrome" in (driver or "") and _major(driver_version) != _major(browser_version):
329
+ add("webdriver-version", False, f"driver major {_major(driver_version)} != browser major {_major(browser_version)}", "install the chromedriver release that matches the installed Chrome major version")
330
+ for name in env_names:
331
+ if not re.fullmatch(r"[A-Z][A-Z0-9_]{1,63}", name):
332
+ add(f"env:{name}", False, "invalid environment variable name", "use an upper-case variable name")
333
+ continue
334
+ present = bool(os.environ.get(name, "").strip())
335
+ add(f"env:{name}", present, "set" if present else "missing or empty", f"configure {name} in the worker's secret store; values are never printed")
336
+ encoding = (locale.getpreferredencoding(False) or "").lower().replace("-", "")
337
+ fs_encoding = (sys.getfilesystemencoding() or "").lower().replace("-", "")
338
+ utf8 = encoding == "utf8" and fs_encoding == "utf8"
339
+ add("runtime-encoding", utf8, f"preferred={locale.getpreferredencoding(False)} filesystem={sys.getfilesystemencoding()}", "run the worker with LANG/LC_ALL set to a UTF-8 locale (e.g. C.UTF-8) or PYTHONUTF8=1")
340
+ normalized = [normalize_country(label) for label in countries]
341
+ for item in normalized:
342
+ add(f"country:{item['input']}", item["passed"], f"→ {item['iso2']}" + (" (repaired encoding)" if item["repaired"] else "") if item["passed"] else f"cannot map {item['normalized']!r} to an ISO 3166 code", "store ISO 3166-1 alpha-2 codes; translate locale display names before queuing")
343
+ probe = None
344
+ if probe_url:
345
+ if urlsplit(probe_url).scheme not in {"http", "https"}:
346
+ add("rate-limit-probe", False, "probe URL must be http(s)")
347
+ else:
348
+ try:
349
+ with urlopen(Request(probe_url, method="HEAD", headers={"User-Agent": "maggie-service-import-readiness/1"}), timeout=probe_timeout) as response:
350
+ status = response.status; headers = response.headers
351
+ except HTTPError as error:
352
+ status = error.code; headers = error.headers
353
+ except (URLError, TimeoutError, OSError) as error:
354
+ status = None; headers = None
355
+ add("rate-limit-probe", False, f"unreachable: {getattr(error, 'reason', error)}", "check worker egress, DNS and proxy settings")
356
+ if status is not None:
357
+ retry_after = headers.get("Retry-After") if headers else None
358
+ remaining = (headers.get("X-RateLimit-Remaining") or headers.get("RateLimit-Remaining")) if headers else None
359
+ probe = {"status": status, "retryAfter": retry_after, "remaining": remaining}
360
+ limited = status == 429 or (remaining is not None and str(remaining).strip() == "0")
361
+ add("rate-limit-probe", not limited and status < 500, f"HTTP {status}" + (f", Retry-After {retry_after}" if retry_after else ""), "wait for the provider window to reset or lower worker concurrency before queuing")
362
+ failed = [item["check"] for item in checks if not item["passed"]]
363
+ return {"schemaVersion": READINESS_SCHEMA, "passed": not failed, "queueAllowed": not failed, "failedChecks": failed, "checks": checks, "countries": normalized, "rateLimitProbe": probe if probe_url else "not requested", "mutation": "not executed"}
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # D26: field-level provenance and conflict resolution
368
+
369
+
370
+ def _norm_value(value: Any) -> str:
371
+ if isinstance(value, str):
372
+ text = unicodedata.normalize("NFC", value).strip()
373
+ if re.match(r"^https?://", text, re.I):
374
+ parts = urlsplit(text)
375
+ return f"{parts.scheme.lower()}://{parts.netloc.lower().removeprefix('www.')}{parts.path.rstrip('/')}"
376
+ return re.sub(r"\s+", " ", text).casefold()
377
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
378
+
379
+
380
+ def merge_facts(payload: dict[str, Any]) -> dict[str, Any]:
381
+ errors: list[str] = []
382
+ if payload.get("schemaVersion") != PROVENANCE_SCHEMA:
383
+ errors.append(f"schemaVersion must be {PROVENANCE_SCHEMA}")
384
+ policy = payload.get("policy") if isinstance(payload.get("policy"), dict) else {}
385
+ precedence = policy.get("precedence") or DEFAULT_PRECEDENCE
386
+ if not isinstance(precedence, list) or precedence[:1] != ["user"]:
387
+ errors.append("policy.precedence must start with 'user'; user-entered facts are authoritative")
388
+ precedence = DEFAULT_PRECEDENCE
389
+ field_precedence = policy.get("fieldPrecedence") if isinstance(policy.get("fieldPrecedence"), dict) else {}
390
+ for field, order in field_precedence.items():
391
+ if not isinstance(order, list) or order[:1] != ["user"]:
392
+ errors.append(f"policy.fieldPrecedence.{field} must start with 'user'")
393
+ min_confidence = policy.get("minConfidence", 0.6)
394
+ if not isinstance(min_confidence, (int, float)) or not 0 <= min_confidence <= 1:
395
+ errors.append("policy.minConfidence must be between 0 and 1"); min_confidence = 0.6
396
+ fields = payload.get("fields")
397
+ if not isinstance(fields, dict) or not fields:
398
+ errors.append("fields must map field paths to provenance records"); fields = {}
399
+ resolved: dict[str, Any] = {}; conflicts: list[dict[str, Any]] = []; unavailable: set[str] = set(); low_confidence: list[str] = []
400
+ for field, record in sorted(fields.items()):
401
+ if not isinstance(record, dict):
402
+ errors.append(f"fields.{field} must be an object"); continue
403
+ order = field_precedence.get(field) if isinstance(field_precedence.get(field), list) else precedence
404
+ rank = {source: index for index, source in enumerate(order)}
405
+ user = record.get("user")
406
+ candidates: list[dict[str, Any]] = []
407
+ for index, entry in enumerate(record.get("sources") or []):
408
+ label = f"fields.{field}.sources[{index}]"
409
+ if not isinstance(entry, dict) or not isinstance(entry.get("source"), str) or not SAFE_SOURCE.fullmatch(entry["source"]) or entry["source"] == "user":
410
+ errors.append(f"{label}.source must be a provider id other than 'user'"); continue
411
+ if entry.get("error"):
412
+ if not (isinstance(entry["error"], str) and SAFE_ERROR.fullmatch(entry["error"])):
413
+ errors.append(f"{label}.error must be a short error code")
414
+ unavailable.add(entry["source"]); continue
415
+ confidence = entry.get("confidence")
416
+ if not isinstance(confidence, (int, float)) or isinstance(confidence, bool) or not 0 <= confidence <= 1:
417
+ errors.append(f"{label}.confidence must be between 0 and 1"); continue
418
+ if "value" not in entry:
419
+ errors.append(f"{label}.value is required"); continue
420
+ if entry.get("sourceRef") is not None and (not isinstance(entry["sourceRef"], str) or "?" in entry["sourceRef"] and re.search(r"(?:key|token|sig|signature)=", entry["sourceRef"], re.I)):
421
+ errors.append(f"{label}.sourceRef must not carry credentials")
422
+ candidates.append({"source": entry["source"], "value": entry["value"], "confidence": float(confidence), "observedAt": entry.get("observedAt"), "sourceRef": entry.get("sourceRef")})
423
+ if isinstance(user, dict) and "value" in user:
424
+ disagree = sorted({item["source"] for item in candidates if _norm_value(item["value"]) != _norm_value(user["value"])})
425
+ resolved[field] = {"value": user["value"], "source": "user", "confidence": 1.0, "decidedBy": "user-authoritative", "providerDisagreement": disagree}
426
+ if disagree:
427
+ conflicts.append({"field": field, "status": "resolved-user-authoritative", "chosen": "user", "candidates": candidates, "requiresReview": False})
428
+ continue
429
+ eligible = [item for item in candidates if item["confidence"] >= min_confidence]
430
+ if not eligible:
431
+ if candidates:
432
+ low_confidence.append(field)
433
+ resolved[field] = {"value": None, "source": None, "confidence": 0.0, "decidedBy": "no-eligible-source"}
434
+ continue
435
+ eligible.sort(key=lambda item: (rank.get(item["source"], len(rank)), -item["confidence"]))
436
+ groups = {_norm_value(item["value"]) for item in eligible}
437
+ top = eligible[0]
438
+ if len(groups) == 1:
439
+ resolved[field] = {"value": top["value"], "source": top["source"], "confidence": max(item["confidence"] for item in eligible), "decidedBy": "agreement" if len(eligible) > 1 else "single-source", "corroboratedBy": sorted({item["source"] for item in eligible} - {top["source"]})}
440
+ else:
441
+ resolved[field] = {"value": top["value"], "source": top["source"], "confidence": top["confidence"], "decidedBy": "precedence-pending-review"}
442
+ conflicts.append({"field": field, "status": "needs-review", "proposed": top["source"], "precedence": order, "candidates": eligible, "requiresReview": True})
443
+ needs_review = [item["field"] for item in conflicts if item["requiresReview"]]
444
+ return {
445
+ "schemaVersion": MERGE_SCHEMA, "entityId": payload.get("entityId"),
446
+ "passed": not errors and not needs_review, "valid": not errors,
447
+ "resolved": resolved, "conflicts": conflicts, "needsReview": needs_review,
448
+ "unavailableSources": sorted(unavailable), "lowConfidenceFields": low_confidence,
449
+ "policy": {"precedence": precedence, "fieldPrecedence": field_precedence, "minConfidence": min_confidence},
450
+ "errors": errors, "mutation": "not executed",
451
+ }
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ def _emit(report: dict[str, Any], output: Path | None, project: Path) -> None:
458
+ if output:
459
+ target = output if output.is_absolute() else project / output
460
+ target.parent.mkdir(parents=True, exist_ok=True)
461
+ target.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
462
+ report = {**report, "output": str(target)}
463
+ print(json.dumps(report, indent=2, ensure_ascii=False))
464
+
465
+
466
+ def main(argv: list[str] | None = None) -> int:
467
+ parser = argparse.ArgumentParser(prog="maggie service", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
468
+ sub = parser.add_subparsers(dest="command", required=True)
469
+ stages = sub.add_parser("import-stages-validate", help="validate a multi-stage import state snapshot")
470
+ stages.add_argument("--manifest", type=Path, required=True)
471
+ stages.add_argument("--now", help="evaluation time (ISO-8601); defaults to evaluatedAt or the current time")
472
+ ready = sub.add_parser("import-readiness", help="fail before queuing when the import worker is not ready")
473
+ ready.add_argument("--requirements", type=Path, help="JSON with pythonModules, browser, webdriver, env, countries")
474
+ ready.add_argument("--python-module", action="append", default=[])
475
+ ready.add_argument("--browser", action="store_true", help="require a headless-capable browser")
476
+ ready.add_argument("--webdriver", action="store_true", help="require a matching WebDriver (Selenium crawlers)")
477
+ ready.add_argument("--env", action="append", default=[], help="required environment variable name (value never printed)")
478
+ ready.add_argument("--country", action="append", default=[], help="country label to normalize to ISO 3166")
479
+ ready.add_argument("--rate-limit-probe", metavar="URL", help="opt-in single HEAD request to read rate-limit headers")
480
+ ready.add_argument("--probe-timeout", type=float, default=10.0)
481
+ facts = sub.add_parser("fact-provenance", help="merge user and provider facts with per-field provenance")
482
+ facts.add_argument("--facts", type=Path, required=True)
483
+ for command in (stages, ready, facts):
484
+ command.add_argument("--project", type=Path, default=Path.cwd())
485
+ command.add_argument("--output", type=Path)
486
+ args = parser.parse_args(argv)
487
+ project = args.project.resolve()
488
+ try:
489
+ if args.command == "import-stages-validate":
490
+ errors: list[str] = []
491
+ now = _time(args.now, "--now", errors) if args.now else None
492
+ if errors:
493
+ raise ValueError("; ".join(errors))
494
+ manifest = args.manifest if args.manifest.is_absolute() else project / args.manifest
495
+ report = validate_stages(_read(manifest), now)
496
+ elif args.command == "import-readiness":
497
+ spec: dict[str, Any] = {}
498
+ if args.requirements:
499
+ spec = _read(args.requirements if args.requirements.is_absolute() else project / args.requirements)
500
+ report = readiness(
501
+ list(spec.get("pythonModules", [])) + args.python_module,
502
+ bool(spec.get("browser")) or args.browser,
503
+ bool(spec.get("webdriver")) or args.webdriver,
504
+ list(spec.get("env", [])) + args.env,
505
+ list(spec.get("countries", [])) + args.country,
506
+ args.rate_limit_probe or spec.get("rateLimitProbe"),
507
+ args.probe_timeout,
508
+ )
509
+ else:
510
+ facts_path = args.facts if args.facts.is_absolute() else project / args.facts
511
+ report = merge_facts(_read(facts_path))
512
+ except (OSError, ValueError, json.JSONDecodeError) as error:
513
+ print(f"BLOCKED: maggie service {args.command}: {error}", file=sys.stderr)
514
+ return 2
515
+ _emit(report, args.output, project)
516
+ return 0 if report["passed"] else 1
517
+
518
+
519
+ if __name__ == "__main__":
520
+ raise SystemExit(main())