@topy-ai/maggie 0.6.9 → 0.7.1
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 +56 -4
- package/bin/maggie.js +11 -7
- package/bundled-references/blog-translation-ingestion.md +52 -0
- package/bundled-references/maggiedash-dashboard-ui.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +28 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +49 -0
- package/bundled-skills/maggie-dash/SKILL.md +52 -0
- package/bundled-skills/maggie-deployment/SKILL.md +17 -0
- package/bundled-skills/maggie-design/SKILL.md +11 -0
- package/bundled-skills/maggie-memory/SKILL.md +7 -0
- package/bundled-skills/maggie-ops/SKILL.md +27 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +95 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +16 -0
- package/bundled-templates/maggiedash/README.md +4 -0
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +31 -0
- package/bundled-tools/clis/maggie_analytics.py +14 -1
- package/bundled-tools/clis/maggie_blog.py +19 -1
- package/bundled-tools/clis/maggie_browser_audit.py +78 -0
- package/bundled-tools/clis/maggie_dash.py +101 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -0
- package/bundled-tools/clis/maggie_feedback.py +16 -1
- package/bundled-tools/clis/maggie_localization.py +31 -0
- package/bundled-tools/clis/maggie_memory.py +4 -1
- package/bundled-tools/clis/maggie_ops.py +21 -1
- package/bundled-tools/clis/maggie_service_booking.py +6 -3
- package/bundled-tools/clis/maggie_sitemap.py +16 -2
- package/bundled-tools/clis/site_audit.py +93 -9
- package/bundled-tools/runtime/analytics_traffic.py +30 -0
- package/bundled-tools/runtime/browser_behavior.py +35 -0
- package/bundled-tools/runtime/browser_geometry.js +31 -0
- package/bundled-tools/runtime/content_localization.py +63 -1
- package/bundled-tools/runtime/dependency_lock.py +43 -0
- package/bundled-tools/runtime/integration_state.py +17 -0
- package/bundled-tools/runtime/localization_runner.py +113 -0
- package/bundled-tools/runtime/maggie_blog.py +66 -1
- package/bundled-tools/runtime/maggie_dash_store.py +150 -6
- package/bundled-tools/runtime/maggie_dash_ui.py +60 -0
- package/bundled-tools/runtime/maggie_memory.py +7 -2
- package/bundled-tools/runtime/maggie_sitemap.py +50 -4
- package/bundled-tools/runtime/route_imports.py +51 -0
- package/bundled-tools/runtime/seed_evidence.py +25 -0
- 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
- package/references/maggiedash-dashboard-ui.md +28 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "maggiedash-dashboard-ui.v1",
|
|
3
|
+
"shell": {
|
|
4
|
+
"reference": "users-workspace",
|
|
5
|
+
"navigation": "sidebar-plus-content-tabs",
|
|
6
|
+
"rules": [
|
|
7
|
+
"render one workspace bar per route",
|
|
8
|
+
"render one content tab bar per route",
|
|
9
|
+
"do not duplicate sidebar navigation in page content",
|
|
10
|
+
"do not hide duplicate navigation with CSS"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"components": [
|
|
14
|
+
{
|
|
15
|
+
"name": "WorkspaceBar",
|
|
16
|
+
"props": ["workspaceName", "activeSection", "actions", "children"],
|
|
17
|
+
"forbiddenProps": []
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "WorkspaceCard",
|
|
21
|
+
"props": ["title", "children"],
|
|
22
|
+
"forbiddenProps": []
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "ContentTabs",
|
|
26
|
+
"props": ["tabs", "active", "actions", "children"],
|
|
27
|
+
"forbiddenProps": ["title", "description"]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"accessibility": ["active tab exposes aria-selected", "actions have accessible names", "workspace navigation has a landmark"]
|
|
31
|
+
}
|
|
@@ -10,6 +10,9 @@ import re
|
|
|
10
10
|
import subprocess
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from urllib.parse import urlsplit
|
|
13
|
+
import sys
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
15
|
+
from analytics_traffic import audit_events # noqa: E402
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
|
|
@@ -107,7 +110,7 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
107
110
|
|
|
108
111
|
def main() -> int:
|
|
109
112
|
parser = argparse.ArgumentParser()
|
|
110
|
-
parser.add_argument("command", nargs="?", choices=("release-gate",))
|
|
113
|
+
parser.add_argument("command", nargs="?", choices=("release-gate", "traffic-audit"))
|
|
111
114
|
parser.add_argument("--project", default=".")
|
|
112
115
|
parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
|
|
113
116
|
parser.add_argument("--env-file", help="optional env file; values are never printed")
|
|
@@ -117,12 +120,22 @@ def main() -> int:
|
|
|
117
120
|
parser.add_argument("--network-report", help="redacted network evidence for release-gate")
|
|
118
121
|
parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
|
|
119
122
|
parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
|
|
123
|
+
parser.add_argument("--events", help="redacted JSON array of analytics events for traffic-audit")
|
|
120
124
|
args = parser.parse_args()
|
|
121
125
|
if args.command == "release-gate":
|
|
122
126
|
required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
|
|
123
127
|
if any(not getattr(args, name) for name in required):
|
|
124
128
|
parser.error("release-gate requires --contract, --render-report, --network-report, --provider-report, and --smoke-report")
|
|
125
129
|
return release_gate(args)
|
|
130
|
+
if args.command == "traffic-audit":
|
|
131
|
+
if not args.events:
|
|
132
|
+
parser.error("traffic-audit requires --events")
|
|
133
|
+
value = json.loads(Path(args.events).read_text(encoding="utf-8"))
|
|
134
|
+
if not isinstance(value, list):
|
|
135
|
+
parser.error("--events must contain a JSON array")
|
|
136
|
+
result = audit_events(value)
|
|
137
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
138
|
+
return 0
|
|
126
139
|
project = Path(args.project).resolve()
|
|
127
140
|
env = parse_env(Path(args.env_file).resolve()) if args.env_file else parse_env(project / ".env.example")
|
|
128
141
|
env.update({key: value for key, value in os.environ.items() if key in {"PUBLIC_GA4_MEASUREMENT_ID", "PUBLIC_ANALYTICS_ENABLED", "PUBLIC_ANALYTICS_CONSENT_REQUIRED", "GSC_SITE_URL", "GSC_VERIFICATION_TOKEN"}})
|
|
@@ -10,11 +10,19 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
12
12
|
from maggie_blog import BlogStore # noqa: E402
|
|
13
|
+
from localization_runner import process_adapter
|
|
14
|
+
from integration_state import integration_state # noqa: E402
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def main() -> int:
|
|
16
18
|
parser = argparse.ArgumentParser(prog="maggie blog")
|
|
17
19
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
translate = sub.add_parser("translate-pending")
|
|
21
|
+
translate.add_argument("--project", type=Path, default=Path.cwd())
|
|
22
|
+
translate.add_argument("--adapter-command", required=True, help="trusted provider JSON argv array")
|
|
23
|
+
translate.add_argument("--timeout", type=int, default=120)
|
|
24
|
+
translate.add_argument("--character-budget", type=int, default=8000)
|
|
25
|
+
translate.add_argument("--confirm", action="store_true")
|
|
18
26
|
init = sub.add_parser("init"); init.add_argument("--project", type=Path, default=Path.cwd()); init.add_argument("--base-path", default="/our-blogs"); init.add_argument("--posts-per-page", type=int, default=9); init.add_argument("--confirm", action="store_true")
|
|
19
27
|
inspect = sub.add_parser("inspect"); inspect.add_argument("--project", type=Path, default=Path.cwd())
|
|
20
28
|
ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
|
|
@@ -22,13 +30,21 @@ def main() -> int:
|
|
|
22
30
|
publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
|
|
23
31
|
sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
|
|
24
32
|
settings = sub.add_parser("settings"); settings.add_argument("--project", type=Path, default=Path.cwd())
|
|
33
|
+
state = sub.add_parser("integration-state"); state.add_argument("--configured", action="store_true"); state.add_argument("--consent-required", action="store_true"); state.add_argument("--consent", action="store_true"); state.add_argument("--authorized", action="store_true"); state.add_argument("--error")
|
|
25
34
|
rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
|
|
26
35
|
args = parser.parse_args()
|
|
36
|
+
if args.command == "integration-state":
|
|
37
|
+
result = integration_state(configured=args.configured, consent_required=args.consent_required, consent=args.consent, authorized=args.authorized, error=args.error)
|
|
38
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
39
|
+
return 0 if result["status"] != "error" else 1
|
|
27
40
|
store = BlogStore(args.project.resolve())
|
|
28
|
-
if args.command in {"init", "ingest", "publish", "rollback"} and not args.confirm:
|
|
41
|
+
if args.command in {"init", "ingest", "publish", "rollback", "translate-pending"} and not args.confirm:
|
|
29
42
|
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
|
|
30
43
|
try:
|
|
31
44
|
if args.command == "init": result = store.init(args.base_path, args.posts_per_page)
|
|
45
|
+
elif args.command == "translate-pending":
|
|
46
|
+
adapter = process_adapter(json.loads(args.adapter_command), args.project, args.timeout)
|
|
47
|
+
result = store.translate_pending(adapter, args.character_budget)
|
|
32
48
|
elif args.command == "inspect": result = {"settings": store.settings(), "posts": store.posts(), "runs": json.loads(store.runs_path.read_text(encoding="utf-8")) if store.runs_path.exists() else []}
|
|
33
49
|
elif args.command == "ingest":
|
|
34
50
|
payload = json.loads(args.input.read_text(encoding="utf-8"));
|
|
@@ -42,6 +58,8 @@ def main() -> int:
|
|
|
42
58
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
43
59
|
print(f"maggie-blog: {error}", file=sys.stderr); return 1
|
|
44
60
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
61
|
+
if args.command in {"ingest", "translate-pending"} and result.get("status") == "partial":
|
|
62
|
+
return 1
|
|
45
63
|
return 0 if args.command != "validate" or result.get("valid") else 1
|
|
46
64
|
|
|
47
65
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capture real viewport/scroll evidence using an installed gstack browse CLI."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
RUNTIME = Path(__file__).resolve().parents[1] / "runtime"
|
|
11
|
+
sys.path.insert(0, str(RUNTIME))
|
|
12
|
+
from browser_behavior import validate_samples
|
|
13
|
+
from localization_runner import checkpoint
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def audit(args):
|
|
17
|
+
if urlparse(args.url).scheme not in {"http", "https", "file"}:
|
|
18
|
+
raise ValueError("URL must use http, https or file")
|
|
19
|
+
if not args.required:
|
|
20
|
+
raise ValueError("at least one --required selector is necessary")
|
|
21
|
+
output = args.output.resolve()
|
|
22
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
def call(*command):
|
|
24
|
+
result = subprocess.run([str(args.browse), *command], capture_output=True, text=True, timeout=45)
|
|
25
|
+
if result.returncode:
|
|
26
|
+
raise ValueError("browser command failed: " + command[0])
|
|
27
|
+
return result.stdout
|
|
28
|
+
report = {"schemaVersion": "maggie-browser-audit.v1", "url": args.url,
|
|
29
|
+
"passed": False, "viewports": [], "evidence": "browser-captured"}
|
|
30
|
+
try:
|
|
31
|
+
for index, viewport in enumerate(args.viewport or ["390x844", "768x1024", "1440x900"]):
|
|
32
|
+
width, height = [int(value) for value in viewport.split("x")]
|
|
33
|
+
if min(width, height) < 100:
|
|
34
|
+
raise ValueError("viewport dimensions must be at least 100")
|
|
35
|
+
call("viewport", viewport)
|
|
36
|
+
call("goto", args.url)
|
|
37
|
+
selectors = list(dict.fromkeys(args.required + args.sticky))
|
|
38
|
+
call("js", "window.__maggieAuditSelectors=" + json.dumps(selectors))
|
|
39
|
+
samples = []
|
|
40
|
+
for step, fraction in enumerate([0, 0.5, 0.9]):
|
|
41
|
+
call("js", f"window.scrollTo(0, (document.documentElement.scrollHeight-innerHeight)*{fraction})")
|
|
42
|
+
# Two animation frames allow layout/scroll handlers to settle.
|
|
43
|
+
call("js", "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))")
|
|
44
|
+
sample = output / f"viewport-{index}-sample-{step}.json"
|
|
45
|
+
call("eval", str(RUNTIME / "browser_geometry.js"), "--out", str(sample), "--raw")
|
|
46
|
+
samples.append(json.loads(sample.read_text()))
|
|
47
|
+
screenshot = output / f"viewport-{index}.png"
|
|
48
|
+
call("screenshot", "--viewport", str(screenshot))
|
|
49
|
+
if not screenshot.is_file():
|
|
50
|
+
raise ValueError("browser did not create screenshot")
|
|
51
|
+
finding = validate_samples(samples, args.required, args.sticky)
|
|
52
|
+
report["viewports"].append({"viewport": viewport, "samples": samples,
|
|
53
|
+
"screenshot": str(screenshot), **finding})
|
|
54
|
+
report["passed"] = all(item["passed"] for item in report["viewports"])
|
|
55
|
+
finally:
|
|
56
|
+
checkpoint(output / "report.json", report)
|
|
57
|
+
print(json.dumps(report, indent=2))
|
|
58
|
+
return 0 if report["passed"] else 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
63
|
+
parser.add_argument("url")
|
|
64
|
+
parser.add_argument("--browse", type=Path, required=True)
|
|
65
|
+
parser.add_argument("--output", type=Path, required=True, help="new directory for evidence")
|
|
66
|
+
parser.add_argument("--viewport", action="append")
|
|
67
|
+
parser.add_argument("--required", action="append", default=[])
|
|
68
|
+
parser.add_argument("--sticky", action="append", default=[])
|
|
69
|
+
args = parser.parse_args()
|
|
70
|
+
try:
|
|
71
|
+
return audit(args)
|
|
72
|
+
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
|
|
73
|
+
print(f"browser-audit: {error}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
raise SystemExit(main())
|
|
@@ -16,6 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
18
18
|
from maggie_dash_store import MaggieDashStore # noqa: E402
|
|
19
|
+
from service_variants import ServiceVariantStore # noqa: E402
|
|
20
|
+
from maggie_dash_ui import load_and_validate # noqa: E402
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def project_root(args: argparse.Namespace) -> Path:
|
|
@@ -92,6 +94,72 @@ def command_transition(args: argparse.Namespace) -> int:
|
|
|
92
94
|
store.close()
|
|
93
95
|
|
|
94
96
|
|
|
97
|
+
def command_cms(args: argparse.Namespace) -> int:
|
|
98
|
+
require_confirm(args)
|
|
99
|
+
store = store_for(project_root(args))
|
|
100
|
+
try:
|
|
101
|
+
if args.cms_command == "revisions":
|
|
102
|
+
result = store.list_revisions(args.project_id, args.document_id)
|
|
103
|
+
elif args.cms_command == "trash":
|
|
104
|
+
result = store.trash(args.project_id, args.document_id, args.actor, args.reason)
|
|
105
|
+
elif args.cms_command == "restore":
|
|
106
|
+
result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
|
|
107
|
+
elif args.cms_command == "schedule":
|
|
108
|
+
result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
|
|
109
|
+
elif args.cms_command == "duplicate":
|
|
110
|
+
result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
|
|
111
|
+
elif args.cms_command == "redirect":
|
|
112
|
+
result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
|
|
113
|
+
elif args.cms_command == "preview":
|
|
114
|
+
result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(f"unknown CMS command: {args.cms_command}")
|
|
117
|
+
emit(result)
|
|
118
|
+
return 0
|
|
119
|
+
finally:
|
|
120
|
+
store.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def command_ui(args: argparse.Namespace) -> int:
|
|
124
|
+
result = load_and_validate(Path(args.contract).resolve(), [Path(value).resolve() for value in args.source])
|
|
125
|
+
emit(result)
|
|
126
|
+
return 0 if result["passed"] else 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
|
|
130
|
+
return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def command_variant(args: argparse.Namespace) -> int:
|
|
134
|
+
require_confirm(args)
|
|
135
|
+
store = variant_store(args)
|
|
136
|
+
if args.variant_command == "create":
|
|
137
|
+
result = store.create(service_id=args.service_id, variant_id=args.variant_id,
|
|
138
|
+
variant_type=args.variant_type, locale=args.locale, market=args.market,
|
|
139
|
+
slug=args.slug, title=args.title, facts=json.loads(args.facts),
|
|
140
|
+
source_revision=args.source_revision, canonical_variant_id=args.canonical_variant_id,
|
|
141
|
+
cluster_links=args.cluster_link, layout_family=args.layout_family)
|
|
142
|
+
elif args.variant_command == "edit":
|
|
143
|
+
result = store.edit(args.variant_id, title=args.title, facts=json.loads(args.facts),
|
|
144
|
+
source_revision=args.source_revision, actor=args.actor, reason=args.reason)
|
|
145
|
+
elif args.variant_command == "translate":
|
|
146
|
+
result = store.translate(args.source_variant_id, variant_id=args.variant_id,
|
|
147
|
+
locale=args.locale, market=args.market, title=args.title, facts=json.loads(args.facts),
|
|
148
|
+
source_revision=args.source_revision, actor=args.actor)
|
|
149
|
+
elif args.variant_command in {"review", "preview", "publish", "archive"}:
|
|
150
|
+
target = {"review": "review", "preview": "preview", "publish": "published", "archive": "archived"}[args.variant_command]
|
|
151
|
+
result = store.transition(args.variant_id, target, args.actor, args.reason, preview_url=getattr(args, "preview_url", None))
|
|
152
|
+
elif args.variant_command == "validate":
|
|
153
|
+
render_report = json.loads(Path(args.render_report).read_text(encoding="utf-8")) if args.render_report else None
|
|
154
|
+
result = store.validate(args.variant_id, args.source_text, render_report)
|
|
155
|
+
elif args.variant_command == "slug-plan":
|
|
156
|
+
result = store.plan_slug_change(args.variant_id, args.new_slug, args.actor)
|
|
157
|
+
else:
|
|
158
|
+
result = [item for item in store.data["variants"] if not args.variant_id or item["id"] == args.variant_id]
|
|
159
|
+
emit(result)
|
|
160
|
+
return 0 if not isinstance(result, dict) or result.get("passed", True) else 1
|
|
161
|
+
|
|
162
|
+
|
|
95
163
|
def parser() -> argparse.ArgumentParser:
|
|
96
164
|
parser = argparse.ArgumentParser(prog="maggie dash", description=__doc__)
|
|
97
165
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -120,6 +188,39 @@ def parser() -> argparse.ArgumentParser:
|
|
|
120
188
|
transition.add_argument("--reason", required=True)
|
|
121
189
|
transition.add_argument("--confirm", action="store_true")
|
|
122
190
|
transition.set_defaults(func=command_transition)
|
|
191
|
+
cms = sub.add_parser("cms", help="manage revisions, trash, scheduling, redirects and previews")
|
|
192
|
+
cms_sub = cms.add_subparsers(dest="cms_command", required=True)
|
|
193
|
+
for name in ("revisions", "trash", "restore"):
|
|
194
|
+
command = cms_sub.add_parser(name)
|
|
195
|
+
command.add_argument("--project", default="."); command.add_argument("--project-id", default="local-project")
|
|
196
|
+
command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
|
|
197
|
+
schedule = cms_sub.add_parser("schedule")
|
|
198
|
+
schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
|
|
199
|
+
duplicate = cms_sub.add_parser("duplicate")
|
|
200
|
+
duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
|
|
201
|
+
redirect = cms_sub.add_parser("redirect")
|
|
202
|
+
redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
|
|
203
|
+
preview = cms_sub.add_parser("preview")
|
|
204
|
+
preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
|
|
205
|
+
cms.set_defaults(func=command_cms)
|
|
206
|
+
ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
|
|
207
|
+
ui_sub = ui.add_subparsers(dest="ui_command", required=True)
|
|
208
|
+
ui_validate = ui_sub.add_parser("validate")
|
|
209
|
+
ui_validate.add_argument("--contract", required=True)
|
|
210
|
+
ui_validate.add_argument("--source", action="append", default=[])
|
|
211
|
+
ui_validate.set_defaults(func=command_ui)
|
|
212
|
+
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
213
|
+
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
214
|
+
create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
|
|
215
|
+
edit = variant_sub.add_parser("edit"); edit.add_argument("--project", default="."); edit.add_argument("--variant-id", required=True); edit.add_argument("--title", required=True); edit.add_argument("--facts", required=True); edit.add_argument("--source-revision", required=True); edit.add_argument("--actor", required=True); edit.add_argument("--reason", required=True); edit.add_argument("--confirm", action="store_true")
|
|
216
|
+
translate = variant_sub.add_parser("translate"); translate.add_argument("--project", default="."); translate.add_argument("--source-variant-id", required=True); translate.add_argument("--variant-id", required=True); translate.add_argument("--locale", required=True); translate.add_argument("--market", required=True); translate.add_argument("--title", required=True); translate.add_argument("--facts", required=True); translate.add_argument("--source-revision", required=True); translate.add_argument("--actor", required=True); translate.add_argument("--confirm", action="store_true")
|
|
217
|
+
for name in ("review", "preview", "publish", "archive"):
|
|
218
|
+
command = variant_sub.add_parser(name); command.add_argument("--project", default="."); command.add_argument("--variant-id", required=True); command.add_argument("--actor", required=True); command.add_argument("--reason", required=True); command.add_argument("--confirm", action="store_true")
|
|
219
|
+
if name == "preview": command.add_argument("--preview-url", required=True)
|
|
220
|
+
validate = variant_sub.add_parser("validate"); validate.add_argument("--project", default="."); validate.add_argument("--variant-id", required=True); validate.add_argument("--source-text"); validate.add_argument("--render-report"); validate.add_argument("--confirm", action="store_true")
|
|
221
|
+
plan_slug = variant_sub.add_parser("slug-plan"); plan_slug.add_argument("--project", default="."); plan_slug.add_argument("--variant-id", required=True); plan_slug.add_argument("--new-slug", required=True); plan_slug.add_argument("--actor", required=True); plan_slug.add_argument("--confirm", action="store_true")
|
|
222
|
+
inspect = variant_sub.add_parser("inspect"); inspect.add_argument("--project", default="."); inspect.add_argument("--variant-id"); inspect.add_argument("--confirm", action="store_true")
|
|
223
|
+
variant.set_defaults(func=command_variant)
|
|
123
224
|
return parser
|
|
124
225
|
|
|
125
226
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
import subprocess
|
|
8
9
|
from datetime import datetime, timezone
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
@@ -106,6 +107,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
106
107
|
"restart": f"systemctl restart {service}",
|
|
107
108
|
"verify": f"systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
108
109
|
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && systemctl restart {service}",
|
|
110
|
+
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
109
111
|
},
|
|
110
112
|
"files": {
|
|
111
113
|
"systemd": f"{service}.service",
|
|
@@ -130,10 +132,36 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
130
132
|
if isinstance(required, list) and isinstance(current, dict):
|
|
131
133
|
missing = [table for table in required if table not in current]
|
|
132
134
|
if missing: errors.append("currentCounts is missing required tables")
|
|
135
|
+
empty = [table for table in required if table in current and (not isinstance(current[table], int) or current[table] <= 0)]
|
|
136
|
+
if empty: errors.append("currentCounts has empty required tables: " + ", ".join(empty))
|
|
133
137
|
if not value.get("release") or not value.get("checkedAt"): errors.append("release and checkedAt are required")
|
|
138
|
+
rollback = value.get("rollback")
|
|
139
|
+
if not isinstance(rollback, dict) or not rollback.get("backupId") or not rollback.get("restoreCommand"):
|
|
140
|
+
errors.append("rollback.backupId and rollback.restoreCommand are required for data rollback")
|
|
134
141
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
135
142
|
|
|
136
143
|
|
|
144
|
+
def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
|
|
145
|
+
errors = []
|
|
146
|
+
if method.upper() == "POST":
|
|
147
|
+
if not origin or not re.fullmatch(r"https?://[^/\s]+", origin): errors.append("POST scheduler requests require an explicit Origin header")
|
|
148
|
+
if not has_auth: errors.append("POST scheduler requests require the configured auth contract")
|
|
149
|
+
return {"schemaVersion": "maggie-deployment-request.v1", "method": method.upper(), "originPresent": bool(origin), "authPresent": bool(has_auth), "passed": not errors, "errors": errors}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify_infrastructure(units_dir: Path | None, service: str, timer: str | None = None) -> dict:
|
|
153
|
+
timer_name = timer or f"maggie-{service}.timer"
|
|
154
|
+
service_name = f"maggie-{service}.service" if not service.endswith(".service") else service
|
|
155
|
+
declared = bool(units_dir and (units_dir / service_name).is_file() and (units_dir / timer_name).is_file())
|
|
156
|
+
systemd = {"service": False, "timer": False}
|
|
157
|
+
for unit, key in ((service_name, "service"), (timer_name, "timer")):
|
|
158
|
+
try:
|
|
159
|
+
systemd[key] = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True, check=False, timeout=5).returncode == 0
|
|
160
|
+
except (OSError, subprocess.SubprocessError):
|
|
161
|
+
systemd[key] = False
|
|
162
|
+
return {"schemaVersion": "maggie-deployment-infra.v1", "service": service_name, "timer": timer_name, "declaredArtifacts": declared, "systemdEnabled": systemd, "passed": declared or all(systemd.values()), "errors": [] if declared or all(systemd.values()) else ["systemd service/timer are not declared or enabled"]}
|
|
163
|
+
|
|
164
|
+
|
|
137
165
|
def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
|
|
138
166
|
if keep < 2 or keep > 5:
|
|
139
167
|
raise ValueError("keep must be between 2 and 5")
|
|
@@ -206,6 +234,13 @@ def main() -> int:
|
|
|
206
234
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
207
235
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
208
236
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
237
|
+
parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
|
|
238
|
+
parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
|
|
239
|
+
parser.add_argument("--timer", help="systemd timer unit name")
|
|
240
|
+
parser.add_argument("--validate-request", action="store_true", help="validate a scheduler request contract")
|
|
241
|
+
parser.add_argument("--method", default="POST")
|
|
242
|
+
parser.add_argument("--origin")
|
|
243
|
+
parser.add_argument("--has-auth", action="store_true")
|
|
209
244
|
args = parser.parse_args()
|
|
210
245
|
try:
|
|
211
246
|
if args.vps_plan:
|
|
@@ -226,6 +261,14 @@ def main() -> int:
|
|
|
226
261
|
output.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
|
|
227
262
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
228
263
|
return 0
|
|
264
|
+
if args.validate_request:
|
|
265
|
+
result = validate_scheduler_request(args.method, args.origin, args.has_auth)
|
|
266
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
267
|
+
return 0 if result["passed"] else 1
|
|
268
|
+
if args.verify_infra:
|
|
269
|
+
result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
|
|
270
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
271
|
+
return 0 if result["passed"] else 1
|
|
229
272
|
result = preflight(Path(args.project).resolve(), args.target, args.environment)
|
|
230
273
|
if args.output:
|
|
231
274
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -34,6 +34,21 @@ def project_fingerprint(project: Path) -> str:
|
|
|
34
34
|
return hashlib.sha256(str(project.resolve()).encode()).hexdigest()[:16]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def installed_version(project: Path) -> str:
|
|
38
|
+
"""Resolve the version from the runtime wrapper or project install state."""
|
|
39
|
+
if os.environ.get("MAGGIE_VERSION"):
|
|
40
|
+
return safe_text(os.environ["MAGGIE_VERSION"])
|
|
41
|
+
for path in (project / ".maggie" / "install.json", project / "VERSION"):
|
|
42
|
+
try:
|
|
43
|
+
value = json.loads(path.read_text(encoding="utf-8")) if path.suffix == ".json" else path.read_text(encoding="utf-8").strip()
|
|
44
|
+
if isinstance(value, dict): value = value.get("version", "")
|
|
45
|
+
if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", str(value)):
|
|
46
|
+
return str(value)
|
|
47
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
48
|
+
continue
|
|
49
|
+
return "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
def feedback_id(project: Path) -> str:
|
|
38
53
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
39
54
|
return f"fb-{timestamp}-{project_fingerprint(project)[:6]}-{secrets.token_hex(3)}"
|
|
@@ -68,7 +83,7 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
68
83
|
"feedbackId": feedback_id(project),
|
|
69
84
|
"createdAt": now(),
|
|
70
85
|
"source": "maggie-cli",
|
|
71
|
-
"maggieVersion":
|
|
86
|
+
"maggieVersion": installed_version(project),
|
|
72
87
|
"type": args.type,
|
|
73
88
|
"skill": skill,
|
|
74
89
|
"runId": run_id,
|
|
@@ -13,6 +13,27 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
15
15
|
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_translation
|
|
16
|
+
from localization_runner import generate, process_adapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_job(args: argparse.Namespace) -> int:
|
|
20
|
+
if not args.confirm:
|
|
21
|
+
raise ValueError("generate requires --confirm: the adapter receives source text and may incur provider costs")
|
|
22
|
+
command = json.loads(args.adapter_command)
|
|
23
|
+
adapter = process_adapter(command, args.project, args.timeout)
|
|
24
|
+
job = load(args.job)
|
|
25
|
+
errors = validate_job(args.job, args.source)["errors"]
|
|
26
|
+
if errors:
|
|
27
|
+
raise ValueError("; ".join(errors))
|
|
28
|
+
source = load(args.source)
|
|
29
|
+
contract = {key: job.get(key) for key in (
|
|
30
|
+
"jobId", "contentId", "sourceRevision", "sourceLanguage", "targetLanguage",
|
|
31
|
+
"targetLocale", "market", "operation", "generationContract")}
|
|
32
|
+
|
|
33
|
+
state = generate(source["strings"], contract, args.output, adapter, args.character_budget)
|
|
34
|
+
print(json.dumps({"status": state["status"], "strings": len(state["translations"]),
|
|
35
|
+
"output": str(args.output), "publish": False}))
|
|
36
|
+
return 0
|
|
16
37
|
|
|
17
38
|
|
|
18
39
|
def now() -> str:
|
|
@@ -318,6 +339,15 @@ def glossary(project: Path) -> int:
|
|
|
318
339
|
def main() -> int:
|
|
319
340
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
320
341
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
342
|
+
generate_parser = sub.add_parser("generate", help="Generate resumable draft strings using a trusted provider process")
|
|
343
|
+
generate_parser.add_argument("job", type=Path)
|
|
344
|
+
generate_parser.add_argument("--project", type=Path, default=Path("."))
|
|
345
|
+
generate_parser.add_argument("--source", type=Path, required=True)
|
|
346
|
+
generate_parser.add_argument("--output", type=Path, required=True)
|
|
347
|
+
generate_parser.add_argument("--adapter-command", required=True, help="JSON argv array; executed without a shell")
|
|
348
|
+
generate_parser.add_argument("--character-budget", type=int, default=8000)
|
|
349
|
+
generate_parser.add_argument("--timeout", type=int, default=120)
|
|
350
|
+
generate_parser.add_argument("--confirm", action="store_true")
|
|
321
351
|
plan_parser = sub.add_parser("plan")
|
|
322
352
|
plan_parser.add_argument("--project", type=Path, default=Path("."))
|
|
323
353
|
plan_parser.add_argument("--content", type=Path, required=True)
|
|
@@ -346,6 +376,7 @@ def main() -> int:
|
|
|
346
376
|
command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
|
|
347
377
|
args = parser.parse_args()
|
|
348
378
|
try:
|
|
379
|
+
if args.command == "generate": return generate_job(args)
|
|
349
380
|
if args.command == "plan": return plan_job(args)
|
|
350
381
|
if args.command == "preview": return preview(args.job)
|
|
351
382
|
if args.command == "validate":
|
|
@@ -13,6 +13,9 @@ from runtime import maggie_memory as memory
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def output(value: object) -> None:
|
|
16
|
+
if isinstance(value, dict) and value.get("status") == "candidate" and value.get("id"):
|
|
17
|
+
value = dict(value)
|
|
18
|
+
value["notice"] = "Saved as candidate; excluded from search/context until reviewed and transitioned to active. Inspect with: maggie memory list --status candidate"
|
|
16
19
|
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
17
20
|
|
|
18
21
|
|
|
@@ -82,7 +85,7 @@ def main() -> int:
|
|
|
82
85
|
output(memory.transition(args.project, args.kind, args.item_id, args.status))
|
|
83
86
|
elif args.command in {"context", "search", "list"}:
|
|
84
87
|
query = args.query if args.command == "search" else getattr(args, "query", "")
|
|
85
|
-
result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query)
|
|
88
|
+
result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None))
|
|
86
89
|
if args.command == "list" and args.kind:
|
|
87
90
|
result = {args.kind: result[args.kind]}
|
|
88
91
|
if args.command == "list" and args.status:
|
|
@@ -9,6 +9,10 @@ import sys
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
|
+
from dependency_lock import audit as audit_lockfiles
|
|
14
|
+
from seed_evidence import validate_manifest
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
REQUIRED_ROUTES = (
|
|
14
18
|
"/ops", "/ops/posts", "/ops/sitemap", "/ops/reports",
|
|
@@ -132,14 +136,30 @@ def command_verify(args: argparse.Namespace) -> int:
|
|
|
132
136
|
return 0 if result["checks"]["ready"] and result["stateFile"] else 1
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
def command_lockfiles(args: argparse.Namespace) -> int:
|
|
140
|
+
result = audit_lockfiles(root(args))
|
|
141
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
142
|
+
return 0 if result["passed"] else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def command_seed_manifest(args: argparse.Namespace) -> int:
|
|
146
|
+
value = read_json(Path(args.manifest).resolve())
|
|
147
|
+
result = validate_manifest(value)
|
|
148
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
149
|
+
return 0 if result["passed"] else 1
|
|
150
|
+
|
|
151
|
+
|
|
135
152
|
def main() -> int:
|
|
136
153
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
137
154
|
parser.add_argument("--project", default=".")
|
|
138
155
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
139
|
-
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify)):
|
|
156
|
+
for name, handler in (("audit", command_audit), ("preflight", command_preflight), ("status", command_status), ("verify", command_verify), ("lockfiles", command_lockfiles)):
|
|
140
157
|
command = sub.add_parser(name)
|
|
141
158
|
if name == "preflight": command.add_argument("--write", action="store_true")
|
|
142
159
|
command.set_defaults(func=handler)
|
|
160
|
+
seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
|
|
161
|
+
seed.add_argument("--manifest", required=True)
|
|
162
|
+
seed.set_defaults(func=command_seed_manifest)
|
|
143
163
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
144
164
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
145
165
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -10,6 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
from urllib.parse import quote, urljoin, urlsplit
|
|
11
11
|
from urllib.request import Request, urlopen
|
|
12
12
|
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
14
|
+
from route_imports import imported_components # noqa: E402
|
|
15
|
+
|
|
13
16
|
NOW = lambda: datetime.now(timezone.utc).isoformat()
|
|
14
17
|
MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
|
|
15
18
|
DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
|
|
@@ -751,7 +754,7 @@ def inventory_candidates(project, pages_root):
|
|
|
751
754
|
found={}
|
|
752
755
|
for page in pages_root.rglob("*") if pages_root.exists() else []:
|
|
753
756
|
if page.is_file() and page.suffix.lower() in {".astro",".md",".mdx",".html"} and page.stem not in {"index","404"}:
|
|
754
|
-
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem"}
|
|
757
|
+
found["/"+str(page.relative_to(project)).replace("\\","/")]={"path":"/"+str(page.relative_to(project)).replace("\\","/"),"text":page_text(page),"canonical":None,"pageType":"filesystem","imports":imported_components(page)}
|
|
755
758
|
for name in ("pages.json","page-content.json"):
|
|
756
759
|
source=project/"docs"/name
|
|
757
760
|
if not source.exists(): continue
|
|
@@ -762,7 +765,7 @@ def inventory_candidates(project, pages_root):
|
|
|
762
765
|
if not isinstance(item,dict): continue
|
|
763
766
|
value=item.get("path") or item.get("url") or item.get("route")
|
|
764
767
|
if not value: continue
|
|
765
|
-
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory"}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
768
|
+
current=found.setdefault(value,{"path":value,"text":"","canonical":None,"pageType":"inventory","imports":[]}); current["text"]+=" "+str(item.get("content") or item.get("text") or item.get("title") or ""); current["canonical"]=item.get("canonicalUrl") or item.get("canonical") or current["canonical"]; current["pageType"]=item.get("pageType") or current["pageType"]
|
|
766
769
|
return list(found.values())
|
|
767
770
|
def cmd_match_pages_review(args):
|
|
768
771
|
project=root(args); data=load(project); candidates=inventory_candidates(project,(project/args.pages_dir).resolve()); aliases={"lymphatic drainage":{"lymphatic","lymph drainage","lymphdrainage","manual lymphatic","ml d"},"deep tissue":{"deep tissue","therapeutic massage"},"thai":{"thai massage","thai"},"postpartum":{"postpartum","new mom"},"facial":{"facial","hydra facial","hydrafacial"},"massage":{"massage"}}
|
|
@@ -779,7 +782,7 @@ def cmd_match_pages_review(args):
|
|
|
779
782
|
matches=[]
|
|
780
783
|
for page in candidates:
|
|
781
784
|
path_slug=slug(page["path"].rsplit("/",1)[-1].rsplit(".",1)[0]); page_words=page_tokens(path_slug+" "+page["text"][:2000]); exact=path_slug==service.get("slug"); overlap=len(expanded & page_words)/max(1,len(expanded)); generic=bool(concepts & {"massage","facial","acupuncture","waxing"}) and overlap<0.75; score=1.0 if exact else min(0.49 if generic else overlap,0.99)
|
|
782
|
-
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
785
|
+
if score>=0.45: matches.append({"path":page["path"],"role":"supporting","pageType":page["pageType"],"canonicalUrl":page["canonical"],"matchedConcepts":sorted(expanded & page_words),"imports":page.get("imports",[]),"method":"slug" if exact else "semantic-concepts","confidence":round(score,3)})
|
|
783
786
|
matches.sort(key=lambda x:(-x["confidence"],x["path"])); report.append({"serviceId":service["id"],"title":service["title"],"candidates":matches})
|
|
784
787
|
for path_value in selections.get(service["id"], []):
|
|
785
788
|
if path_value in {m["path"] for m in matches}: selected.append((service,path_value))
|