@topy-ai/maggie 0.6.9 → 0.7.0

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.
@@ -0,0 +1,152 @@
1
+ """Provider-neutral service variant lifecycle and URL/layout contract."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ VARIANT_TYPES = {"location", "event", "holiday"}
11
+ STATUSES = ("draft", "review", "approved", "preview", "published", "archived")
12
+ TRANSITIONS = {"draft": {"review"}, "review": {"draft", "approved"},
13
+ "approved": {"preview", "published", "draft"},
14
+ "preview": {"approved", "published"}, "published": {"archived"}, "archived": set()}
15
+ SLUG_WORD = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
16
+
17
+
18
+ def now() -> str:
19
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
20
+
21
+
22
+ def slug_part(value: str) -> str:
23
+ value = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
24
+ if not value or not SLUG_WORD.fullmatch(value):
25
+ raise ValueError("slug parts must contain lowercase ASCII words")
26
+ return value
27
+
28
+
29
+ def variant_slug(kind: str, *, location: str = "", event: str = "", holiday: str = "") -> str:
30
+ if kind not in VARIANT_TYPES:
31
+ raise ValueError("variant type must be location, event, or holiday")
32
+ value = {"location": location, "event": event, "holiday": holiday}[kind]
33
+ if not value:
34
+ raise ValueError(f"{kind} variant requires its slug part")
35
+ return f"{kind}/{slug_part(value)}"
36
+
37
+
38
+ def similarity(source: str, variant: str) -> float:
39
+ left, right = set(re.findall(r"[a-z0-9]+", source.lower())), set(re.findall(r"[a-z0-9]+", variant.lower()))
40
+ return 1.0 if not left and not right else len(left & right) / max(1, len(left | right))
41
+
42
+
43
+ class ServiceVariantStore:
44
+ def __init__(self, path: Path):
45
+ self.path = path.resolve()
46
+ self.path.parent.mkdir(parents=True, exist_ok=True)
47
+ self.data = {"schemaVersion": "maggie-service-variants.v1", "variants": [], "redirects": [], "events": []}
48
+ if self.path.exists():
49
+ self.data = json.loads(self.path.read_text(encoding="utf-8"))
50
+ if self.data.get("schemaVersion") != "maggie-service-variants.v1":
51
+ raise ValueError("unsupported service variant store")
52
+
53
+ def _save(self) -> None:
54
+ self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
55
+
56
+ def _event(self, action: str, variant_id: str, actor: str, reason: str) -> None:
57
+ self.data["events"].append({"action": action, "variantId": variant_id, "actor": actor, "reason": reason, "at": now()})
58
+
59
+ def _find(self, variant_id: str) -> dict:
60
+ found = next((item for item in self.data["variants"] if item["id"] == variant_id), None)
61
+ if not found:
62
+ raise ValueError("service variant not found")
63
+ return found
64
+
65
+ def create(self, *, service_id: str, variant_id: str, variant_type: str, locale: str, market: str,
66
+ slug: str, title: str, facts: list[dict], source_revision: str, canonical_variant_id: str | None = None,
67
+ cluster_links: list[str] | None = None, layout_family: str = "service-default", actor: str = "cli") -> dict:
68
+ if not service_id or not variant_id or not locale or not market or not title or not source_revision:
69
+ raise ValueError("service identity, locale, market, title and source revision are required")
70
+ if variant_type not in VARIANT_TYPES or not SLUG_WORD.fullmatch(slug.split("/", 1)[-1]) or not slug.startswith(variant_type + "/"):
71
+ raise ValueError("slug must match the canonical variant type/slug grammar")
72
+ if not isinstance(facts, list) or not facts or any(not isinstance(item, dict) or not item.get("key") or not item.get("value") for item in facts):
73
+ raise ValueError("variant requires meaningful keyed facts")
74
+ if any(item["id"] == variant_id for item in self.data["variants"]):
75
+ raise ValueError("variant ID already exists")
76
+ if any(item["slug"] == slug and item["locale"] == locale for item in self.data["variants"]):
77
+ raise ValueError("variant slug and locale collision")
78
+ if canonical_variant_id and not any(item["id"] == canonical_variant_id for item in self.data["variants"]):
79
+ raise ValueError("canonical variant does not exist")
80
+ item = {"id": variant_id, "serviceId": service_id, "variantType": variant_type, "locale": locale,
81
+ "market": market, "slug": slug, "title": title, "facts": facts,
82
+ "clusterLinks": sorted(set(cluster_links or [])), "layoutFamily": layout_family,
83
+ "canonicalVariantId": canonical_variant_id or variant_id, "canonicalUrl": "/services/" + slug + "/",
84
+ "hreflang": {locale: "/services/" + slug + "/"}, "sourceRevision": source_revision,
85
+ "provenance": {"operation": "create", "actor": actor, "sourceRevision": source_revision},
86
+ "status": "draft", "createdAt": now(), "updatedAt": now()}
87
+ self.data["variants"].append(item); self._event("variant.created", variant_id, actor, "draft created"); self._save()
88
+ return item
89
+
90
+ def edit(self, variant_id: str, *, title: str, facts: list[dict], source_revision: str, actor: str, reason: str) -> dict:
91
+ item = self._find(variant_id)
92
+ if item["status"] not in {"draft", "review"}:
93
+ raise ValueError("only draft or review variants may be edited")
94
+ if not title or not facts:
95
+ raise ValueError("edited variant requires title and facts")
96
+ item.update(title=title, facts=facts, sourceRevision=source_revision, updatedAt=now())
97
+ item["provenance"].update({"operation": "edit", "actor": actor, "reason": reason, "sourceRevision": source_revision})
98
+ self._event("variant.edited", variant_id, actor, reason); self._save(); return item
99
+
100
+ def translate(self, source_id: str, *, variant_id: str, locale: str, market: str, title: str,
101
+ facts: list[dict], source_revision: str, actor: str) -> dict:
102
+ source = self._find(source_id)
103
+ return self.create(service_id=source["serviceId"], variant_id=variant_id, variant_type=source["variantType"],
104
+ locale=locale, market=market, slug=source["slug"], title=title, facts=facts,
105
+ source_revision=source_revision, canonical_variant_id=source["canonicalVariantId"],
106
+ cluster_links=source["clusterLinks"], layout_family=source["layoutFamily"], actor=actor)
107
+
108
+ def transition(self, variant_id: str, target: str, actor: str, reason: str, *, preview_url: str | None = None) -> dict:
109
+ item = self._find(variant_id)
110
+ if target not in STATUSES or target not in TRANSITIONS[item["status"]]:
111
+ raise ValueError(f"invalid variant transition: {item['status']} -> {target}")
112
+ if target == "preview":
113
+ if not preview_url or not preview_url.startswith(("http://", "https://", "file://")):
114
+ raise ValueError("preview requires a rendered preview URL")
115
+ item["previewUrl"] = preview_url
116
+ if target == "published":
117
+ if not item.get("canonicalUrl") or not item.get("hreflang") or not item.get("facts"):
118
+ raise ValueError("published variant requires canonical, hreflang and facts")
119
+ if item.get("canonicalVariantId") != item["id"] and not any(v["id"] == item["canonicalVariantId"] and v["status"] == "published" for v in self.data["variants"]):
120
+ raise ValueError("canonical source must be published before a translated variant")
121
+ old = item["status"]; item["status"] = target; item["updatedAt"] = now()
122
+ item.setdefault("approval", []).append({"from": old, "to": target, "actor": actor, "reason": reason, "at": now()})
123
+ self._event("variant." + target, variant_id, actor, reason); self._save(); return item
124
+
125
+ def plan_slug_change(self, variant_id: str, new_slug: str, actor: str) -> dict:
126
+ item = self._find(variant_id)
127
+ if item["status"] == "published" and not actor:
128
+ raise ValueError("published URL changes require owner approval")
129
+ if not new_slug.startswith(item["variantType"] + "/") or not SLUG_WORD.fullmatch(new_slug.split("/", 1)[-1]):
130
+ raise ValueError("new slug violates variant grammar")
131
+ if any(v["slug"] == new_slug and v["locale"] == item["locale"] for v in self.data["variants"] if v["id"] != variant_id):
132
+ raise ValueError("new slug collides with another variant")
133
+ redirect = {"from": item["canonicalUrl"], "to": "/services/" + new_slug + "/", "ownerApproval": actor, "status": "planned"}
134
+ self.data["redirects"].append(redirect); self._save(); return redirect
135
+
136
+ def validate(self, variant_id: str, source_text: str | None = None, render_report: dict | None = None) -> dict:
137
+ item = self._find(variant_id); errors = []
138
+ if not item["clusterLinks"] and item["variantType"] != "location": errors.append("cluster links required")
139
+ if source_text is not None and similarity(source_text, item["title"] + " " + " ".join(str(f["value"]) for f in item["facts"])) < 0.1:
140
+ errors.append("variant content has no meaningful similarity to source")
141
+ if item["canonicalVariantId"] != item["id"] and item["canonicalVariantId"] not in {v["id"] for v in self.data["variants"]}: errors.append("canonical relation missing")
142
+ if render_report is not None:
143
+ if render_report.get("schemaVersion") != "maggie-service-variant-render.v1" or render_report.get("passed") is not True:
144
+ errors.append("render report is not passed")
145
+ if render_report.get("layoutFamily") != item["layoutFamily"]:
146
+ errors.append("rendered layout family does not match variant contract")
147
+ if render_report.get("canonicalUrl") != item["canonicalUrl"]:
148
+ errors.append("rendered canonical URL does not match variant contract")
149
+ if render_report.get("locale") != item["locale"]:
150
+ errors.append("rendered locale does not match variant contract")
151
+ return {"passed": not errors, "errors": errors, "similarity": similarity(source_text, item["title"]) if source_text is not None else None,
152
+ "layoutFamily": item["layoutFamily"], "status": item["status"]}
@@ -0,0 +1,60 @@
1
+ """Reviewed static-site contracts. Never silently accept a changed baseline."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+
9
+ def snapshot(report: dict, reviewer: str) -> dict:
10
+ if not reviewer.strip():
11
+ raise ValueError("baseline requires a reviewer")
12
+ crawl = report.get("crawl", {})
13
+ if not report.get("passed") or not crawl.get("enabled") or not crawl.get("complete"):
14
+ raise ValueError("baseline requires a passing, complete sitemap crawl")
15
+ pages = crawl.get("pages", [])
16
+ if not pages or len(pages) != crawl.get("discovered_url_count"):
17
+ raise ValueError("baseline page coverage is incomplete")
18
+ contracts = {}
19
+ for page in pages:
20
+ if not page.get("passed") or not isinstance(page.get("contract"), dict):
21
+ raise ValueError("baseline requires successful page contracts")
22
+ if page["url"] in contracts:
23
+ raise ValueError("duplicate page URL")
24
+ contracts[page["url"]] = page["contract"]
25
+ return {"schemaVersion": "maggie-site-baseline.v1", "url": report["url"],
26
+ "reviewer": reviewer.strip(), "createdAt": datetime.now(timezone.utc).isoformat(),
27
+ "pages": contracts}
28
+
29
+
30
+ def compare(baseline: dict, report: dict) -> dict:
31
+ if baseline.get("schemaVersion") != "maggie-site-baseline.v1" or not isinstance(baseline.get("pages"), dict) or not baseline["pages"]:
32
+ raise ValueError("invalid site baseline")
33
+ crawl = report.get("crawl", {})
34
+ errors = []
35
+ if baseline.get("url") != report.get("url"):
36
+ errors.append("site origin/base URL differs from baseline")
37
+ if not crawl.get("enabled") or not crawl.get("complete"):
38
+ errors.append("complete sitemap crawl required")
39
+ current = {page["url"]: page.get("contract") for page in crawl.get("pages", [])}
40
+ expected = baseline["pages"]
41
+ added, removed = sorted(current.keys() - expected.keys()), sorted(expected.keys() - current.keys())
42
+ changes = []
43
+ for url in sorted(expected.keys() & current.keys()):
44
+ actual = current[url]
45
+ if not isinstance(actual, dict):
46
+ errors.append("missing page contract: " + url)
47
+ continue
48
+ fields = sorted(key for key in expected[url].keys() | actual.keys() if expected[url].get(key) != actual.get(key))
49
+ if fields:
50
+ changes.append({"url": url, "fields": fields})
51
+ return {"passed": not errors and not added and not removed and not changes,
52
+ "errors": errors, "added": added, "removed": removed, "changed": changes}
53
+
54
+
55
+ def save(path: Path, baseline: dict) -> None:
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ # Exclusive creation protects the reviewed baseline from accidental replacement.
58
+ with path.open("x", encoding="utf-8") as stream:
59
+ json.dump(baseline, stream, ensure_ascii=False, indent=2)
60
+ stream.write("\n")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.6.9",
3
+ "version": "0.7.0",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,52 @@
1
+ # Blog translation ingestion contract
2
+
3
+ ## Entry points and ownership
4
+
5
+ List every ingest entry point before enabling automatic translation: manual
6
+ CLI, admin HTTP action, scheduled tick, webhook and backfill. Each must use
7
+ the same post-persistence scheduling function. Record the caller and source
8
+ revision in the run evidence. A hook in a standalone script is insufficient
9
+ evidence for a scheduled HTTP path.
10
+
11
+ The packaged Python BlogStore reconciles durable translation tasks after source
12
+ persistence when `autoTranslateEnabled` is true and `translationLocales` contains
13
+ supported locale tags. The same reconciliation runs before translation retries,
14
+ so an interruption between source persistence and scheduling is recoverable.
15
+ Tasks are under `.maggie/blog/translations/`; content changes create new task
16
+ identities, and only current identities are processed. Old drafts are retained
17
+ for inspection, not eligible for automatic publication.
18
+
19
+ Run `maggie blog translate-pending --project . --adapter-command
20
+ '["python3", "scripts/translation-provider.py"]' --confirm` to generate drafts
21
+ from saved sources without re-pulling. The project supplies a trusted adapter
22
+ using the localization provider protocol. It receives title, excerpt, body,
23
+ topic labels and supplied image alt text. Completed tasks are skipped; failures
24
+ return partial/nonzero and store only an error category. Use one worker per
25
+ project. Host database/API schedulers must explicitly integrate this boundary;
26
+ installing the skill does not patch an existing host ingestion implementation.
27
+
28
+ ## Durable work
29
+
30
+ After source persistence, enqueue translation work using an idempotency key
31
+ of project, content ID, source revision, target locale and operation. The
32
+ source write and scheduling intent must commit together or use a reconciliation
33
+ pass that detects missing intents. A repeat pull must retry pending/failed
34
+ translation without purchasing another upstream pull or duplicating completed
35
+ work. A changed source revision invalidates earlier translation work.
36
+
37
+ Track pending, running, succeeded and failed states with attempt count and
38
+ safe error category. A run with failed required translations is partial, not
39
+ completed. Missing credentials or provider availability must be visible;
40
+ never print credentials or provider payloads in the report. Keep translated
41
+ content draft and non-indexable until its validation and review pass.
42
+
43
+ ## Acceptance evidence
44
+
45
+ Exercise the actual CLI and scheduled HTTP entry points against fixture
46
+ providers. With translation enabled, both must schedule identical work for
47
+ the same source revision. With translation disabled, neither schedules work.
48
+ Test provider failure, process interruption, retry without re-pull, duplicate
49
+ delivery, changed source revision, multiple target locales, media alt text and
50
+ topic labels. Assert persisted target output and queue state, not merely the
51
+ presence of a translate function name. Browser preview must show the target
52
+ locale before publication is claimed complete.