@topy-ai/maggie 0.6.8 → 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.
- package/README.md +39 -2
- package/README.zh-TW.md +1 -1
- package/bin/maggie.js +6 -2
- package/bundled-references/blog-translation-ingestion.md +52 -0
- package/bundled-skills/maggie-blog/SKILL.md +16 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +41 -1
- package/bundled-skills/maggie-dash/SKILL.md +31 -0
- package/bundled-skills/maggie-deployment/SKILL.md +1 -1
- package/bundled-skills/maggie-design/SKILL.md +12 -1
- package/bundled-skills/maggie-memory/SKILL.md +7 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +75 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +11 -1
- package/bundled-tools/clis/maggie_blog.py +13 -1
- package/bundled-tools/clis/maggie_browser_audit.py +78 -0
- package/bundled-tools/clis/maggie_dash.py +47 -0
- package/bundled-tools/clis/maggie_design.py +12 -0
- package/bundled-tools/clis/maggie_localization.py +42 -0
- package/bundled-tools/clis/maggie_memory.py +4 -1
- package/bundled-tools/clis/maggie_service_booking.py +3 -3
- package/bundled-tools/clis/site_audit.py +103 -8
- package/bundled-tools/runtime/browser_behavior.py +35 -0
- package/bundled-tools/runtime/browser_geometry.js +31 -0
- package/bundled-tools/runtime/localization_runner.py +113 -0
- package/bundled-tools/runtime/maggie_blog.py +66 -1
- package/bundled-tools/runtime/maggie_memory.py +7 -2
- package/bundled-tools/runtime/maggie_sitemap.py +16 -3
- package/bundled-tools/runtime/service_variants.py +152 -0
- package/bundled-tools/runtime/site_baseline.py +60 -0
- package/package.json +1 -1
- package/references/blog-translation-ingestion.md +52 -0
|
@@ -171,12 +171,17 @@ def _active(item: dict[str, Any], project_id: str = "") -> bool:
|
|
|
171
171
|
return not expires or expires > now()
|
|
172
172
|
|
|
173
173
|
|
|
174
|
-
def relevant(project: str | Path, *, skill: str = "", query: str = "", project_id: str = "") -> dict[str, list[dict[str, Any]]]:
|
|
174
|
+
def relevant(project: str | Path, *, skill: str = "", query: str = "", project_id: str = "", status: str | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
175
175
|
terms = set(re.findall(r"[a-z0-9_-]+", f"{skill} {query}".lower()))
|
|
176
176
|
result: dict[str, list[dict[str, Any]]] = {kind: [] for kind in KINDS}
|
|
177
177
|
for kind in KINDS:
|
|
178
178
|
for item in read(project, kind)["items"]:
|
|
179
|
-
if
|
|
179
|
+
if status is not None:
|
|
180
|
+
if item.get("status") != status:
|
|
181
|
+
continue
|
|
182
|
+
if item.get("scope") == "project" and item.get("projectId") not in {project_id or root(project).name, ""}:
|
|
183
|
+
continue
|
|
184
|
+
elif kind != "errors" and not _active(item, project_id or root(project).name):
|
|
180
185
|
continue
|
|
181
186
|
haystack = " ".join(str(item.get(key, "")) for key in ("type", "title", "rule", "problem", "solution", "prevention", "skill", "message", "fingerprint", "triggers")).lower()
|
|
182
187
|
if not terms or terms & set(re.findall(r"[a-z0-9_-]+", haystack)):
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import json
|
|
6
7
|
import re
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from html import escape
|
|
@@ -27,10 +28,18 @@ def parse_routes(text: str) -> list[dict[str, str]]:
|
|
|
27
28
|
continue
|
|
28
29
|
parts = line.split("\t")
|
|
29
30
|
if len(parts) < 2:
|
|
30
|
-
raise ValueError("route lines must be type<TAB>absolute-url<TAB>optional-lastmod")
|
|
31
|
+
raise ValueError("route lines must be type<TAB>absolute-url<TAB>optional-lastmod<TAB>optional-alternates-json")
|
|
31
32
|
item = {"type": parts[0].strip(), "url": parts[1].strip()}
|
|
32
33
|
if len(parts) > 2 and parts[2].strip():
|
|
33
34
|
item["lastmod"] = parts[2].strip()
|
|
35
|
+
if len(parts) > 3 and parts[3].strip():
|
|
36
|
+
try:
|
|
37
|
+
alternates = json.loads(parts[3])
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
raise ValueError("route alternates must be valid JSON") from exc
|
|
40
|
+
if not isinstance(alternates, dict):
|
|
41
|
+
raise ValueError("route alternates must be a JSON object")
|
|
42
|
+
item["alternates"] = alternates
|
|
34
43
|
routes.append(item)
|
|
35
44
|
return routes
|
|
36
45
|
|
|
@@ -63,8 +72,9 @@ def xml_file(urls: list[dict[str, str]]) -> str:
|
|
|
63
72
|
rows = []
|
|
64
73
|
for item in urls:
|
|
65
74
|
lastmod = f"<lastmod>{escape(item['lastmod'])}</lastmod>" if item.get("lastmod") else ""
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
alternate_xml = "".join(f'<xhtml:link rel="alternate" hreflang="{escape(str(lang))}" href="{escape(str(url))}" />' for lang, url in sorted((item.get("alternates") or {}).items()))
|
|
76
|
+
rows.append(f"<url><loc>{escape(item['url'])}</loc>{lastmod}{alternate_xml}</url>")
|
|
77
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">' + "".join(rows) + "</urlset>\n"
|
|
68
78
|
|
|
69
79
|
|
|
70
80
|
def build_plan(routes: list[dict[str, str]], origin: str, content_types: set[str], chunk_target: int = DEFAULT_CHUNK_TARGET, previous: dict | None = None) -> dict:
|
|
@@ -112,6 +122,9 @@ def validate_plan_data(plan: dict) -> dict:
|
|
|
112
122
|
locs = [element.text or "" for element in root.iter() if element.tag.endswith("loc")]
|
|
113
123
|
if any(not absolute_url(value, origin) for value in locs):
|
|
114
124
|
errors.append(f"chunk contains a relative or off-origin loc: {chunk.get('filename')}")
|
|
125
|
+
for link in root.iter():
|
|
126
|
+
if link.tag.endswith("link") and link.get("rel") == "alternate" and not absolute_url(link.get("href", ""), origin):
|
|
127
|
+
errors.append(f"chunk contains a relative or off-origin alternate: {chunk.get('filename')}")
|
|
115
128
|
except ElementTree.ParseError:
|
|
116
129
|
errors.append(f"chunk is not valid XML: {chunk.get('filename')}")
|
|
117
130
|
index = plan.get("index", {})
|
|
@@ -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
|
@@ -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.
|