@topy-ai/maggie 0.7.19 → 0.7.21
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 +27 -3
- package/README.zh-TW.md +9 -2
- package/bin/maggie.js +2 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +37 -0
- package/bundled-contracts/maggiedash/README.md +6 -0
- package/bundled-references/content-localization-contract.md +9 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +6 -3
- package/bundled-skills/maggie-dash/SKILL.md +9 -0
- package/bundled-skills/maggie-deployment/SKILL.md +25 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +158 -0
- package/bundled-tools/clis/maggie_localization.py +6 -2
- package/bundled-tools/runtime/content_localization.py +137 -0
- package/package.json +1 -1
- package/references/content-localization-contract.md +9 -0
package/README.md
CHANGED
|
@@ -192,6 +192,7 @@ maggie seo social-cards ... # per-page og:image format/dimension audit
|
|
|
192
192
|
maggie seo head-tags ... # rendered-shell head metadata drift audit
|
|
193
193
|
maggie ops favicon-check ... # served favicon behaviour check
|
|
194
194
|
maggie deployment | migration | release | analytics | schedule
|
|
195
|
+
maggie deployment readiness --project PATH
|
|
195
196
|
maggie migration identity --identity-file FILE [--expected-file FILE]
|
|
196
197
|
maggie deployment canary --asset URL=SHA256 --render-report report.json
|
|
197
198
|
maggie design icon-inventory --source-dir src --runtime assets/icons.css
|
|
@@ -280,6 +281,25 @@ The canary report requires a screenshot and zero console errors, missing
|
|
|
280
281
|
assets, and visual placeholders for every route. It records safe cache headers
|
|
281
282
|
only and never stores response bodies, cookies, or credentials.
|
|
282
283
|
|
|
284
|
+
Unit regression does not establish runtime release readiness. Produce unit
|
|
285
|
+
evidence and then validate all five release evidence slots:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
python3 tools/tests/run_regression.py \
|
|
289
|
+
--report .maggie/verification/unit-regression.json
|
|
290
|
+
maggie deployment readiness --project . \
|
|
291
|
+
--package-report .maggie/verification/package-smoke.json \
|
|
292
|
+
--browser-report .maggie/verification/browser-evidence.json \
|
|
293
|
+
--rendered-canary .maggie/deployment-canary.json \
|
|
294
|
+
--deployment-preflight .maggie/release-preflight.json \
|
|
295
|
+
--output .maggie/deployment-readiness.json
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The readiness report follows `maggie-deployment-readiness.v1`. It reports unit
|
|
299
|
+
regression, package smoke, host browser evidence, rendered canary, and
|
|
300
|
+
deployment preflight separately. Missing host adapter evidence is
|
|
301
|
+
`inconclusive`; only five passing slots produce `passed`.
|
|
302
|
+
|
|
283
303
|
### Localization and analytics release gates
|
|
284
304
|
|
|
285
305
|
Extract real page strings before creating a localization job, then require a
|
|
@@ -305,8 +325,8 @@ artifact schemas.
|
|
|
305
325
|
Recommended upgrade sequence for the current release:
|
|
306
326
|
|
|
307
327
|
```bash
|
|
308
|
-
npx @topy-ai/maggie@0.7.
|
|
309
|
-
npx @topy-ai/maggie@0.7.
|
|
328
|
+
npx @topy-ai/maggie@0.7.21 update --project . --force
|
|
329
|
+
npx @topy-ai/maggie@0.7.21 cleanup --project .
|
|
310
330
|
```
|
|
311
331
|
|
|
312
332
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -316,7 +336,11 @@ as a command-line argument:
|
|
|
316
336
|
node scripts/publish-npm.mjs --maggie-env-file ../.env
|
|
317
337
|
```
|
|
318
338
|
|
|
319
|
-
The 0.7.
|
|
339
|
+
The 0.7.21 workflow adds operation-specific localization quality checks,
|
|
340
|
+
explicit deployment readiness evidence states, and serialized package assembly.
|
|
341
|
+
The 0.7.20 workflow completes the MaggieDash Activity and Navigation
|
|
342
|
+
workspaces and dashboard UI v3 contract. The 0.7.19 workflow adds
|
|
343
|
+
documentation hygiene audits, live URL API contract
|
|
320
344
|
validation, host-supplied schema reader audits, dashboard runtime evidence,
|
|
321
345
|
and provider-neutral activity-log and managed-navigation contracts. The 0.7.18
|
|
322
346
|
workflow adds staged/untracked changed-surface detection, scenario
|
package/README.zh-TW.md
CHANGED
|
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
|
|
|
8
8
|
## 安裝
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
npx @topy-ai/maggie@0.7.
|
|
11
|
+
npx @topy-ai/maggie@0.7.21 init --agent all
|
|
12
12
|
npx @topy-ai/maggie doctor --project .
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,7 +25,10 @@ maggie doctor --project . --require-bootstrap --strict
|
|
|
25
25
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
26
26
|
publish 與 production deployment 需要明確確認。
|
|
27
27
|
|
|
28
|
-
0.7.
|
|
28
|
+
0.7.21 增加 localization polish/rewrite 的 operation-specific quality checks、
|
|
29
|
+
deployment readiness evidence 狀態,以及並發 package assembly 保護。
|
|
30
|
+
0.7.20 完成 MaggieDash Activity 與 Navigation workspace,以及 dashboard UI v3
|
|
31
|
+
contract。0.7.19 加入文件 hygiene audit、live URL API contract、schema reader audit、
|
|
29
32
|
dashboard runtime evidence,以及 activity-log 和 managed-navigation contracts。
|
|
30
33
|
0.7.18 加入 staged/untracked changed-surface release gate、QA run 與 release
|
|
31
34
|
整合、market/locale-aware service variant route、provider capability matrix
|
|
@@ -37,6 +40,10 @@ MaggieDash 也提供穩定 section identity、可重用 section arrangement、
|
|
|
37
40
|
section registry、短期 agent content bridge,以及 translation out-of-band write
|
|
38
41
|
後的 restart gate。`maggie doctor` 會比較 install manifest 與磁碟上的實際 skills。
|
|
39
42
|
|
|
43
|
+
`maggie deployment readiness` 會分開檢查 unit regression、package smoke、host
|
|
44
|
+
browser evidence、rendered canary 與 deployment preflight。缺少必要 adapter 時
|
|
45
|
+
回傳 `inconclusive`,只有五組 evidence 全部通過才回傳 `passed`。
|
|
46
|
+
|
|
40
47
|
新版 release gates:
|
|
41
48
|
|
|
42
49
|
```bash
|
package/bin/maggie.js
CHANGED
|
@@ -108,6 +108,7 @@ Usage:
|
|
|
108
108
|
maggie service convert-page <page-path> --project PATH
|
|
109
109
|
maggie service match-pages --project PATH --pages-dir src/pages
|
|
110
110
|
maggie deployment --project PATH --target vps-with-cloudflare-dns
|
|
111
|
+
maggie deployment readiness --project PATH
|
|
111
112
|
maggie deployment canary --project PATH --asset URL=SHA256 --render-report report.json --output docs/deployment-canary.json
|
|
112
113
|
maggie migration --project PATH --environment staging
|
|
113
114
|
maggie migration identity --identity-file FILE [--expected-file FILE]
|
|
@@ -408,6 +409,7 @@ try {
|
|
|
408
409
|
else if (command === "seo") seo(args);
|
|
409
410
|
else if (command === "ops") workflowCli("maggie_ops.py", args);
|
|
410
411
|
else if (command === "deployment" && args[0] === "canary") workflowCli("maggie_deployment_canary.py", args.slice(1));
|
|
412
|
+
else if (command === "deployment" && args[0] === "readiness") workflowCli("maggie_deployment_readiness.py", args.slice(1));
|
|
411
413
|
else if (command === "deployment") workflowCli("maggie_deployment.py", args);
|
|
412
414
|
else if (command === "migration") workflowCli("maggie_migration.py", args);
|
|
413
415
|
else if (command === "schedule") workflowCli("maggie_schedule.py", args);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/deployment-readiness-v1.json",
|
|
4
|
+
"title": "Maggie deployment readiness evidence summary",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "status", "checks"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-deployment-readiness.v1"},
|
|
9
|
+
"status": {"enum": ["passed", "failed", "inconclusive"]},
|
|
10
|
+
"checks": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["unitRegression", "packageSmoke", "browserEvidence", "renderedCanary", "deploymentPreflight"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"unitRegression": {"$ref": "#/$defs/check"},
|
|
16
|
+
"packageSmoke": {"$ref": "#/$defs/check"},
|
|
17
|
+
"browserEvidence": {"$ref": "#/$defs/check"},
|
|
18
|
+
"renderedCanary": {"$ref": "#/$defs/check"},
|
|
19
|
+
"deploymentPreflight": {"$ref": "#/$defs/check"}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"$defs": {
|
|
24
|
+
"check": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["state", "configured"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"state": {"enum": ["passed", "failed", "inconclusive", "not-configured"]},
|
|
29
|
+
"configured": {"type": "boolean"},
|
|
30
|
+
"path": {"type": "string"},
|
|
31
|
+
"reason": {"type": "string"}
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"additionalProperties": false
|
|
37
|
+
}
|
|
@@ -61,6 +61,12 @@ Dashboard browser checks must emit runtime evidence; a static component scan
|
|
|
61
61
|
or JSON declaration is not proof that a screen mounted or that its data calls
|
|
62
62
|
ran.
|
|
63
63
|
|
|
64
|
+
The first-party MaggieDash 0.2.4 distribution exposes these adapters through
|
|
65
|
+
its dashboard UI v3 contract: a read-only Activity workspace and an atomic,
|
|
66
|
+
locale-aware Navigation workspace. The reusable UI accepts the standard
|
|
67
|
+
`events`/`occurredAt` and `items`/`destination` shapes, while also reading the
|
|
68
|
+
legacy host response aliases used by existing adapters during migration.
|
|
69
|
+
|
|
64
70
|
## Status model
|
|
65
71
|
|
|
66
72
|
Content transitions are explicit:
|
|
@@ -61,6 +61,15 @@ Supported operations are distinct: `translate`, `polish`, `rewrite`,
|
|
|
61
61
|
source. A source revision change marks dependent translations stale. Fallback
|
|
62
62
|
content is never indexable.
|
|
63
63
|
|
|
64
|
+
`polish` and `rewrite` validation includes deterministic output checks. Polish
|
|
65
|
+
must keep the source language, change the copy, preserve protected fact tokens,
|
|
66
|
+
and show a clarity signal (shorter maximum sentence, clearer sentence
|
|
67
|
+
segmentation, placeholder reduction, or explicit quality evidence). Rewrite
|
|
68
|
+
must change the copy and its observable structure, while preserving protected
|
|
69
|
+
fact tokens. These are conservative heuristics, not semantic equivalence
|
|
70
|
+
proof: every result contains `quality.humanReview.required: true` and marks
|
|
71
|
+
semantic equivalence as `not-certified` until a named reviewer approves it.
|
|
72
|
+
|
|
64
73
|
The following are protected by default: price, currency, rating, provider
|
|
65
74
|
facts, booking URL, legal/health claims, content ID, slug, canonical owner,
|
|
66
75
|
and translation group. Changes require structured approval and evidence.
|
|
@@ -14,9 +14,12 @@ metadata:
|
|
|
14
14
|
changes; translate and polish preserve meaning; localise enables market
|
|
15
15
|
adaptation. A host generation adapter or the authoring agent must apply these
|
|
16
16
|
instructions when producing copy. Planning does not invoke a model or generate
|
|
17
|
-
translations. Validation
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
translations. Validation now applies deterministic operation-specific checks to
|
|
18
|
+
polish and rewrite outputs: source/output text must be present, protected fact
|
|
19
|
+
tokens must remain unchanged, polish must show a clarity signal, and rewrite
|
|
20
|
+
must change observable structure. These checks are conservative heuristics,
|
|
21
|
+
not semantic equivalence proof. Every result requires named human review and
|
|
22
|
+
reports `semanticEquivalence: not-certified` until approval.
|
|
20
23
|
|
|
21
24
|
### Resumable draft generation
|
|
22
25
|
|
|
@@ -46,6 +46,15 @@ owns the framework route, email/password session middleware, database, media
|
|
|
46
46
|
storage, provider credentials, and `/api/maggie/*` adapter endpoints. Read the
|
|
47
47
|
MaggieDash host adapter contract before adding a new framework adapter.
|
|
48
48
|
|
|
49
|
+
MaggieDash 0.2.4 adds two reusable operator workspaces. Activity is a read-only
|
|
50
|
+
view backed by `/api/maggie/activity.json`; it supports actor/action filters,
|
|
51
|
+
refresh, and a safe unavailable state. Navigation is an ordered,
|
|
52
|
+
locale/location-aware editor backed by `/api/maggie/navigation.json`; it saves
|
|
53
|
+
the full menu atomically and requires the host to keep published markup as its
|
|
54
|
+
fallback. The host owns authentication, event writes, menu persistence,
|
|
55
|
+
translation behavior, and validation. Use the dashboard UI v3 contract for the
|
|
56
|
+
required route and capability declarations.
|
|
57
|
+
|
|
49
58
|
For a built/deployed host, an agent must use the host's validated content
|
|
50
59
|
bridge rather than editing release files or receiving database credentials:
|
|
51
60
|
|
|
@@ -37,6 +37,31 @@ visitor-facing files. The canary must include screenshots, zero console or
|
|
|
37
37
|
network errors, and zero placeholder matches. Query-driven routes belong in
|
|
38
38
|
behavior/API checks, not static byte baselines.
|
|
39
39
|
|
|
40
|
+
## Release readiness evidence
|
|
41
|
+
|
|
42
|
+
Unit regression is necessary but does not prove runtime release readiness. The
|
|
43
|
+
readiness command keeps five evidence slots separate: dependency-free unit
|
|
44
|
+
regression, package smoke, host browser evidence, rendered canary, and
|
|
45
|
+
deployment preflight:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python3 tools/tests/run_regression.py \
|
|
49
|
+
--report .maggie/verification/unit-regression.json
|
|
50
|
+
maggie deployment readiness --project . \
|
|
51
|
+
--package-report .maggie/verification/package-smoke.json \
|
|
52
|
+
--browser-report .maggie/verification/browser-evidence.json \
|
|
53
|
+
--rendered-canary .maggie/deployment-canary.json \
|
|
54
|
+
--deployment-preflight .maggie/release-preflight.json \
|
|
55
|
+
--output .maggie/deployment-readiness.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The report follows `maggie-deployment-readiness.v1`. A failed evidence file
|
|
59
|
+
returns `failed`; a missing or unavailable host/browser adapter returns
|
|
60
|
+
`inconclusive`; only five passing evidence slots return `passed`. Maggie does
|
|
61
|
+
not fabricate browser, rendered, or deployment evidence and does not deploy
|
|
62
|
+
from this command. See
|
|
63
|
+
[`readiness-v1.schema.json`](../../bundled-contracts/maggie-deployment/readiness-v1.schema.json).
|
|
64
|
+
|
|
40
65
|
## Automatic memory hook
|
|
41
66
|
|
|
42
67
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Summarize release evidence without confusing tests with runtime readiness."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CHECKS = (
|
|
15
|
+
("unitRegression", "unit_report"),
|
|
16
|
+
("packageSmoke", "package_report"),
|
|
17
|
+
("browserEvidence", "browser_report"),
|
|
18
|
+
("renderedCanary", "rendered_canary"),
|
|
19
|
+
("deploymentPreflight", "deployment_preflight"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def display_path(path: Path, project: Path) -> str:
|
|
24
|
+
try:
|
|
25
|
+
return str(path.resolve().relative_to(project.resolve()))
|
|
26
|
+
except ValueError:
|
|
27
|
+
return path.name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_unit(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
31
|
+
if payload.get("schemaVersion") != "maggie-regression.v1":
|
|
32
|
+
return False, "unit evidence schemaVersion is unsupported"
|
|
33
|
+
if payload.get("status") != "passed" or not isinstance(payload.get("suites"), int) or payload["suites"] < 1:
|
|
34
|
+
return False, "unit evidence must report a passing suite count"
|
|
35
|
+
if payload.get("skipped", 0) != 0:
|
|
36
|
+
return False, "unit evidence contains skipped suites"
|
|
37
|
+
return True, "dependency-free regression suites passed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_package(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
41
|
+
if payload.get("schemaVersion") != "maggie-package-smoke.v1":
|
|
42
|
+
return False, "package evidence schemaVersion is unsupported"
|
|
43
|
+
checks = payload.get("checks")
|
|
44
|
+
if payload.get("passed") is not True or not isinstance(checks, list) or not checks:
|
|
45
|
+
return False, "package evidence must contain passing smoke checks"
|
|
46
|
+
return True, "package smoke checks passed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_browser(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
50
|
+
if payload.get("schemaVersion") != "maggie-browser-evidence.v1":
|
|
51
|
+
return False, "browser evidence schemaVersion is unsupported"
|
|
52
|
+
routes = payload.get("routes")
|
|
53
|
+
if payload.get("passed") is not True or not isinstance(routes, list) or not routes:
|
|
54
|
+
return False, "browser evidence must contain a passing non-empty route set"
|
|
55
|
+
for route in routes:
|
|
56
|
+
if not isinstance(route, dict) or not route.get("route") or not route.get("screenshot"):
|
|
57
|
+
return False, "browser evidence routes need a route and screenshot"
|
|
58
|
+
if route.get("consoleErrors") or route.get("networkErrors") or route.get("missingAssets"):
|
|
59
|
+
return False, "browser evidence contains runtime errors"
|
|
60
|
+
return True, "host browser evidence passed"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_canary(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
64
|
+
if payload.get("schemaVersion") != "maggie-deployment-canary.v1":
|
|
65
|
+
return False, "rendered canary schemaVersion is unsupported"
|
|
66
|
+
if payload.get("status") != "pass":
|
|
67
|
+
return False, "rendered canary did not pass"
|
|
68
|
+
rendered = payload.get("rendered")
|
|
69
|
+
if not isinstance(rendered, dict) or not isinstance(rendered.get("report"), dict):
|
|
70
|
+
return False, "rendered canary is missing browser-rendered report evidence"
|
|
71
|
+
return True, "edge and rendered canary passed"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_preflight(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
75
|
+
if payload.get("schemaVersion") not in {"1.0", "maggie-release-preflight.v1"}:
|
|
76
|
+
return False, "deployment preflight schemaVersion is unsupported"
|
|
77
|
+
if payload.get("passed") is not True:
|
|
78
|
+
return False, "deployment preflight contains a failed gate"
|
|
79
|
+
gates = payload.get("gates")
|
|
80
|
+
if not isinstance(gates, list) or not gates:
|
|
81
|
+
return False, "deployment preflight has no gate results"
|
|
82
|
+
return True, "deployment preflight gates passed"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
86
|
+
"unitRegression": validate_unit,
|
|
87
|
+
"packageSmoke": validate_package,
|
|
88
|
+
"browserEvidence": validate_browser,
|
|
89
|
+
"renderedCanary": validate_canary,
|
|
90
|
+
"deploymentPreflight": validate_preflight,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
|
|
95
|
+
result: dict[str, Any] = {"state": "inconclusive", "configured": path.is_file(), "path": display_path(path, project)}
|
|
96
|
+
if not path.is_file():
|
|
97
|
+
result["reason"] = "required adapter evidence is unavailable"
|
|
98
|
+
return result
|
|
99
|
+
try:
|
|
100
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
102
|
+
result.update({"state": "failed", "reason": f"evidence is not valid JSON: {error}"})
|
|
103
|
+
return result
|
|
104
|
+
if not isinstance(payload, dict):
|
|
105
|
+
result.update({"state": "failed", "reason": "evidence root must be an object"})
|
|
106
|
+
return result
|
|
107
|
+
valid, reason = VALIDATORS[name](payload)
|
|
108
|
+
result.update({"state": "passed" if valid else "failed", "reason": reason})
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def readiness(project: Path, paths: dict[str, Path], output: Path | None = None) -> int:
|
|
113
|
+
checks = {name: inspect_check(name, path, project) for name, path in paths.items()}
|
|
114
|
+
states = {item["state"] for item in checks.values()}
|
|
115
|
+
status = "failed" if "failed" in states else "inconclusive" if "inconclusive" in states else "passed"
|
|
116
|
+
report = {
|
|
117
|
+
"schemaVersion": "maggie-deployment-readiness.v1",
|
|
118
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
119
|
+
"project": display_path(project, project),
|
|
120
|
+
"status": status,
|
|
121
|
+
"checks": checks,
|
|
122
|
+
"nextAction": "resolve failed evidence before release" if status == "failed" else "collect the missing host adapter evidence" if status == "inconclusive" else "all declared readiness evidence passed",
|
|
123
|
+
}
|
|
124
|
+
if output:
|
|
125
|
+
destination = output if output.is_absolute() else project / output
|
|
126
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
destination.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
128
|
+
report["output"] = display_path(destination, project)
|
|
129
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
130
|
+
return 0 if status == "passed" else 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
135
|
+
parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
136
|
+
parser.add_argument("--unit-report", type=Path, default=Path(".maggie/verification/unit-regression.json"))
|
|
137
|
+
parser.add_argument("--package-report", type=Path, default=Path(".maggie/verification/package-smoke.json"))
|
|
138
|
+
parser.add_argument("--browser-report", type=Path, default=Path(".maggie/verification/browser-evidence.json"))
|
|
139
|
+
parser.add_argument("--rendered-canary", type=Path, default=Path(".maggie/deployment-canary.json"))
|
|
140
|
+
parser.add_argument("--deployment-preflight", type=Path, default=Path(".maggie/release-preflight.json"))
|
|
141
|
+
parser.add_argument("--output", type=Path)
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
project = args.project.resolve()
|
|
144
|
+
if not project.is_dir():
|
|
145
|
+
parser.error(f"project directory does not exist: {project}")
|
|
146
|
+
paths = {
|
|
147
|
+
name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
|
|
148
|
+
for name, option in CHECKS
|
|
149
|
+
}
|
|
150
|
+
try:
|
|
151
|
+
return readiness(project, paths, args.output)
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
print(f"BLOCKED: maggie deployment readiness: {error}", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
|
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
15
|
-
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_translation
|
|
15
|
+
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
|
|
16
16
|
from localization_runner import generate, process_adapter
|
|
17
17
|
|
|
18
18
|
|
|
@@ -250,6 +250,8 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
250
250
|
errors.append("generationContract mode must match operation")
|
|
251
251
|
if job.get("operation") == "rewrite" and not generation.get("allowStructuralRewrite"):
|
|
252
252
|
errors.append("rewrite requires structural rewrite permission")
|
|
253
|
+
if job.get("operation") == "polish" and job.get("sourceLanguage") != job.get("targetLanguage"):
|
|
254
|
+
errors.append("polish must keep sourceLanguage and targetLanguage identical")
|
|
253
255
|
errors.extend(validate_translation({
|
|
254
256
|
"contentId": job.get("contentId"), "lang": job.get("targetLanguage"),
|
|
255
257
|
"locale": job.get("targetLocale"), "translationGroupId": job.get("translationGroupId", f"tg-{job.get('contentId')}"),
|
|
@@ -260,6 +262,8 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
260
262
|
})
|
|
261
263
|
}, expected_lang=job.get("targetLanguage")))
|
|
262
264
|
errors.extend(f"protected field changed without approval: {field}" for field in protected_field_changes(job.get("content", {}), translation))
|
|
265
|
+
quality_errors, quality = validate_operation_quality(job.get("content", {}), translation, job.get("operation", ""), job.get("review"))
|
|
266
|
+
errors.extend(quality_errors)
|
|
263
267
|
if source_path:
|
|
264
268
|
errors.extend(validate_source(source_path, job.get("sourceRevision")))
|
|
265
269
|
source = load(source_path)
|
|
@@ -269,7 +273,7 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
269
273
|
errors.append("translation sourceStringIds must link every extracted source string")
|
|
270
274
|
if render_path:
|
|
271
275
|
errors.extend(validate_render_report(render_path))
|
|
272
|
-
return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status")}
|
|
276
|
+
return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status"), "quality": quality}
|
|
273
277
|
|
|
274
278
|
|
|
275
279
|
def preview(path: Path) -> int:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections import Counter
|
|
5
6
|
import re
|
|
6
7
|
from typing import Any, Iterable
|
|
7
8
|
|
|
@@ -17,6 +18,10 @@ MARKETS = {"global", "uk", "us"}
|
|
|
17
18
|
STATUSES = {"missing", "draft", "machine_translated", "needs_review", "approved", "published", "archived"}
|
|
18
19
|
OPERATIONS = {"translate", "polish", "rewrite", "localise", "rebrand", "manual_edit"}
|
|
19
20
|
PROTECTED_FIELDS = {"price", "currency", "rating", "provider", "providerId", "bookingUrl", "paymentUrl", "legalClaims", "healthClaims", "contentId", "slug", "canonicalUrl", "translationGroupId"}
|
|
21
|
+
QUALITY_OPERATIONS = {"polish", "rewrite"}
|
|
22
|
+
QUALITY_META_FIELDS = {"identity", "contentId", "contentType", "sourceRevision", "translationStatus", "isIndexable", "provenance", "sourceStringIds", "qualityEvidence", "humanReviewStatus"}
|
|
23
|
+
FACT_TOKEN_PATTERN = re.compile(r"https?://[^\s<>]+|[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|(?:£|\$|€|¥|₹)\s*\d[\d,.]*|\b\d[\d,.]*%|\b\d[\d,.]*\b", re.UNICODE)
|
|
24
|
+
PLACEHOLDER_PATTERN = re.compile(r"\{\{[^}]+\}\}|\$\{[^}]+\}|\b(?:TODO|TBD|lorem ipsum)\b", re.IGNORECASE)
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
class TranslationIndex:
|
|
@@ -162,3 +167,135 @@ def protected_field_changes(source: Any, translation: Any) -> list[str]:
|
|
|
162
167
|
if not isinstance(source, dict) or not isinstance(translation, dict):
|
|
163
168
|
return []
|
|
164
169
|
return [field for field in sorted(PROTECTED_FIELDS) if field in source and field in translation and source[field] != translation[field]]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _copy_payload(value: Any) -> Any:
|
|
173
|
+
"""Remove identity/approval metadata before comparing editorial structure."""
|
|
174
|
+
if isinstance(value, dict):
|
|
175
|
+
return {
|
|
176
|
+
key: _copy_payload(item)
|
|
177
|
+
for key, item in value.items()
|
|
178
|
+
if key not in QUALITY_META_FIELDS and key not in PROTECTED_FIELDS
|
|
179
|
+
}
|
|
180
|
+
if isinstance(value, list):
|
|
181
|
+
return [_copy_payload(item) for item in value]
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _copy_text(value: Any) -> str:
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
return "\n".join(part for part in (_copy_text(item) for item in value.values()) if part)
|
|
188
|
+
if isinstance(value, list):
|
|
189
|
+
return "\n".join(part for part in (_copy_text(item) for item in value) if part)
|
|
190
|
+
return str(value).strip() if isinstance(value, str) else ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _copy_metrics(text: str) -> dict[str, int | bool]:
|
|
194
|
+
normalized = text.strip()
|
|
195
|
+
sentences = [item for item in re.split(r"[.!?。!?]+", normalized) if item.strip()]
|
|
196
|
+
words = re.findall(r"\w+(?:['’\-]\w+)*", normalized, re.UNICODE)
|
|
197
|
+
sentence_lengths = [len(re.findall(r"\w+", item, re.UNICODE)) for item in sentences]
|
|
198
|
+
return {
|
|
199
|
+
"characters": len(normalized),
|
|
200
|
+
"words": len(words),
|
|
201
|
+
"sentences": len(sentences),
|
|
202
|
+
"paragraphs": len([item for item in re.split(r"\n\s*\n", normalized) if item.strip()]),
|
|
203
|
+
"headings": len(re.findall(r"(?im)^\s{0,3}(?:#{1,6}\s+|<h[1-6]\b)", normalized)),
|
|
204
|
+
"listItems": len(re.findall(r"(?im)^\s*(?:[-*+]\s+|\d+[.)]\s+|<li\b)", normalized)),
|
|
205
|
+
"maxSentenceWords": max(sentence_lengths, default=0),
|
|
206
|
+
"placeholders": len(PLACEHOLDER_PATTERN.findall(normalized)),
|
|
207
|
+
"repeatedWhitespace": len(re.findall(r"[ \t]{2,}", normalized)),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fact_tokens(text: str) -> Counter[str]:
|
|
212
|
+
return Counter(token.casefold().rstrip(".,;:)") for token in FACT_TOKEN_PATTERN.findall(text))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _structure_signature(value: Any) -> tuple[Any, ...]:
|
|
216
|
+
if isinstance(value, dict):
|
|
217
|
+
return ("object", tuple((key, _structure_signature(item)) for key, item in sorted(value.items())))
|
|
218
|
+
if isinstance(value, list):
|
|
219
|
+
return ("list", len(value), tuple(_structure_signature(item) for item in value))
|
|
220
|
+
if isinstance(value, str):
|
|
221
|
+
metrics = _copy_metrics(value)
|
|
222
|
+
return ("text", metrics["paragraphs"], metrics["headings"], metrics["listItems"], metrics["sentences"])
|
|
223
|
+
if value is None:
|
|
224
|
+
return ("null",)
|
|
225
|
+
return (type(value).__name__,)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_operation_quality(source: Any, translation: Any, operation: str, review: Any = None) -> tuple[list[str], dict[str, Any]]:
|
|
229
|
+
"""Run conservative, dependency-free checks for polish and rewrite jobs.
|
|
230
|
+
|
|
231
|
+
These checks cover observable copy structure and protected fact tokens. They
|
|
232
|
+
intentionally do not claim semantic equivalence; every applicable result
|
|
233
|
+
carries a mandatory human-review signal.
|
|
234
|
+
"""
|
|
235
|
+
report: dict[str, Any] = {
|
|
236
|
+
"operation": operation,
|
|
237
|
+
"applicable": operation in QUALITY_OPERATIONS,
|
|
238
|
+
"method": "deterministic-copy-heuristics",
|
|
239
|
+
}
|
|
240
|
+
if operation not in QUALITY_OPERATIONS:
|
|
241
|
+
return [], report
|
|
242
|
+
source_copy = _copy_payload(source)
|
|
243
|
+
translation_copy = _copy_payload(translation)
|
|
244
|
+
source_text = _copy_text(source_copy)
|
|
245
|
+
translation_text = _copy_text(translation_copy)
|
|
246
|
+
source_metrics = _copy_metrics(source_text)
|
|
247
|
+
translation_metrics = _copy_metrics(translation_text)
|
|
248
|
+
source_facts = _fact_tokens(source_text)
|
|
249
|
+
translation_facts = _fact_tokens(translation_text)
|
|
250
|
+
changed_fact_count = sum((source_facts - translation_facts).values()) + sum((translation_facts - source_facts).values())
|
|
251
|
+
normalized_source = re.sub(r"\s+", " ", source_text).strip().casefold()
|
|
252
|
+
normalized_translation = re.sub(r"\s+", " ", translation_text).strip().casefold()
|
|
253
|
+
structure_changed = _structure_signature(source_copy) != _structure_signature(translation_copy)
|
|
254
|
+
clarity_signal = (
|
|
255
|
+
translation_metrics["maxSentenceWords"] < source_metrics["maxSentenceWords"]
|
|
256
|
+
or translation_metrics["sentences"] > source_metrics["sentences"]
|
|
257
|
+
or translation_metrics["repeatedWhitespace"] < source_metrics["repeatedWhitespace"]
|
|
258
|
+
or translation_metrics["placeholders"] < source_metrics["placeholders"]
|
|
259
|
+
)
|
|
260
|
+
declared_clarity = isinstance(translation, dict) and isinstance(translation.get("qualityEvidence"), dict) and translation["qualityEvidence"].get("clarityImproved") is True
|
|
261
|
+
human_review = review if isinstance(review, dict) else {}
|
|
262
|
+
review_approved = human_review.get("decision") == "approve" and bool(human_review.get("reviewer"))
|
|
263
|
+
errors: list[str] = []
|
|
264
|
+
if not source_text:
|
|
265
|
+
errors.append(f"{operation} quality requires source copy text")
|
|
266
|
+
if not translation_text:
|
|
267
|
+
errors.append(f"{operation} quality requires generated copy text")
|
|
268
|
+
if changed_fact_count:
|
|
269
|
+
errors.append(f"{operation} output changed {changed_fact_count} protected fact token(s)")
|
|
270
|
+
if operation == "polish":
|
|
271
|
+
if normalized_source and normalized_source == normalized_translation:
|
|
272
|
+
errors.append("polish output is unchanged")
|
|
273
|
+
if translation_metrics["placeholders"] > source_metrics["placeholders"]:
|
|
274
|
+
errors.append("polish output introduced unresolved placeholders")
|
|
275
|
+
if source_text and translation_text and not (clarity_signal or declared_clarity):
|
|
276
|
+
errors.append("polish output has no deterministic clarity-improvement signal")
|
|
277
|
+
if operation == "rewrite":
|
|
278
|
+
if normalized_source and normalized_source == normalized_translation:
|
|
279
|
+
errors.append("rewrite output is unchanged")
|
|
280
|
+
if source_text and translation_text and not structure_changed:
|
|
281
|
+
errors.append("rewrite output did not change copy structure")
|
|
282
|
+
report.update({
|
|
283
|
+
"source": source_metrics,
|
|
284
|
+
"output": translation_metrics,
|
|
285
|
+
"checks": {
|
|
286
|
+
"nonEmpty": bool(source_text and translation_text),
|
|
287
|
+
"changed": bool(normalized_source != normalized_translation),
|
|
288
|
+
"protectedFactsPreserved": changed_fact_count == 0,
|
|
289
|
+
"structureChanged": structure_changed,
|
|
290
|
+
"clarityImproved": bool(clarity_signal or declared_clarity) if operation == "polish" else None,
|
|
291
|
+
"clarityEvidence": "deterministic" if clarity_signal else "declared" if declared_clarity else "missing",
|
|
292
|
+
},
|
|
293
|
+
"humanReview": {
|
|
294
|
+
"required": True,
|
|
295
|
+
"status": "approved" if review_approved else "required",
|
|
296
|
+
"semanticEquivalence": "not-certified",
|
|
297
|
+
"reason": "heuristics do not replace human meaning review",
|
|
298
|
+
},
|
|
299
|
+
"status": "passed" if not errors else "failed",
|
|
300
|
+
})
|
|
301
|
+
return errors, report
|
package/package.json
CHANGED
|
@@ -61,6 +61,15 @@ Supported operations are distinct: `translate`, `polish`, `rewrite`,
|
|
|
61
61
|
source. A source revision change marks dependent translations stale. Fallback
|
|
62
62
|
content is never indexable.
|
|
63
63
|
|
|
64
|
+
`polish` and `rewrite` validation includes deterministic output checks. Polish
|
|
65
|
+
must keep the source language, change the copy, preserve protected fact tokens,
|
|
66
|
+
and show a clarity signal (shorter maximum sentence, clearer sentence
|
|
67
|
+
segmentation, placeholder reduction, or explicit quality evidence). Rewrite
|
|
68
|
+
must change the copy and its observable structure, while preserving protected
|
|
69
|
+
fact tokens. These are conservative heuristics, not semantic equivalence
|
|
70
|
+
proof: every result contains `quality.humanReview.required: true` and marks
|
|
71
|
+
semantic equivalence as `not-certified` until a named reviewer approves it.
|
|
72
|
+
|
|
64
73
|
The following are protected by default: price, currency, rating, provider
|
|
65
74
|
facts, booking URL, legal/health claims, content ID, slug, canonical owner,
|
|
66
75
|
and translation group. Changes require structured approval and evidence.
|