@topy-ai/maggie 0.7.48 → 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.
- package/README-zh-TW.md +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Validate and materialise safe Maggie scheduled-job definitions.
|
|
2
|
+
"""Validate and materialise safe Maggie scheduled-job definitions.
|
|
3
|
+
|
|
4
|
+
These jobs are the `infrastructure` schedule layer: when a worker wakes up.
|
|
5
|
+
They never carry a content-calendar publish time, a campaign flight, or a
|
|
6
|
+
measurement window; those layers are declared in a marketing run and checked
|
|
7
|
+
by `maggie_marketing_run.py` (see `maggie_marketing_run.py layers`).
|
|
8
|
+
"""
|
|
3
9
|
|
|
4
10
|
from __future__ import annotations
|
|
5
11
|
|
|
@@ -68,11 +74,15 @@ def validate(manifest: object, project: Path | None = None) -> dict:
|
|
|
68
74
|
errors.append(f"{prefix}.maxRetries must be between 0 and 5")
|
|
69
75
|
if job.get("overlapPolicy") != "skip-if-running":
|
|
70
76
|
errors.append(f"{prefix}.overlapPolicy must be skip-if-running")
|
|
77
|
+
if job.get("layer", "infrastructure") != "infrastructure":
|
|
78
|
+
errors.append(f"{prefix}.layer must be infrastructure; content calendars, campaign flights and measurement windows belong in a marketing run")
|
|
79
|
+
if any(key in job for key in ("scheduled_at", "scheduledAt", "publishAt", "publish_at")):
|
|
80
|
+
errors.append(f"{prefix} must not carry a publish time; a timer only wakes the worker")
|
|
71
81
|
if not job.get("lockKey"):
|
|
72
82
|
errors.append(f"{prefix}.lockKey is required")
|
|
73
83
|
if re.search(r"(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=|https?://[^ $]+:[^ $]+@", str(job.get("command", "")), re.I):
|
|
74
84
|
errors.append(f"{prefix}.command must not contain credentials")
|
|
75
|
-
return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "mutation": "not executed"}
|
|
85
|
+
return {"passed": not errors, "errors": errors, "jobs": len(jobs), "sourceChecks": source_checks, "layer": "infrastructure", "mutation": "not executed"}
|
|
76
86
|
|
|
77
87
|
|
|
78
88
|
def write_units(output_dir: Path, manifest: dict) -> None:
|
|
@@ -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())
|