unaltraweb 0.7.4 → 0.8.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.
- checksums.yaml +4 -4
- data/Makefile +4 -4
- data/docs/_documentation/en/47-manual-context.md +140 -0
- data/docs/agents/manual-context-121.md +191 -0
- data/docs/agents/manual-context-fix-121.md +130 -0
- data/docs/agents/manual-context-pilot-review-121.md +277 -0
- data/docs/agents/mcp-contract.md +7 -4
- data/docs/agents/scaffold-closeout-118.md +30 -3
- data/scripts/manual/build_pdf.py +5 -2
- data/scripts/review_manual_context_pilots.py +287 -0
- data/scripts/test_manual_context_export.py +145 -0
- data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
- data/src/unaltraweb_mcp/component-contract.json +15 -15
- metadata +8 -2
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Opt-in, version-bound #121 review against the published 0.8.0 controller.
|
|
2
|
+
|
|
3
|
+
Creates synthetic sources, invokes the REAL installed CLI without network/socket,
|
|
4
|
+
and retains successes AND expected refusals. It does not repair the exporter,
|
|
5
|
+
render figures, manufacture transport archives, or mount a teaching manual.
|
|
6
|
+
Native computation locks below are explicitly synthetic check fixtures, not
|
|
7
|
+
evidence that R/Python rendering took place. Itemarium checking is optional and
|
|
8
|
+
uses its read-only source tree with a separately prepared Python interpreter.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tarfile
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
IMAGE = "sha256:e0502369c85c59f9e512fbd9fdb17dbc128845d057a1423375425e68ce2e79a2"
|
|
27
|
+
OLD_IMAGE = "sha256:ea14b1224382e47031b979d294f970b66773155584ac8f9eb94a33dc6421b0c9"
|
|
28
|
+
SOURCE = "54afe7588c36e959a7eb75d5f52763a7456108d7"
|
|
29
|
+
PRODUCER = "sha256:a678d2b2649e3479d0049ca0ca8a500aba5bb3f0a59b070cd7aaf5c0c6b466d0"
|
|
30
|
+
SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20"><rect width="30" height="20" fill="white"/><text x="1" y="12">Fixture</text></svg>'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha(data):
|
|
34
|
+
return hashlib.sha256(data).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save(path, value):
|
|
38
|
+
with path.open("x", encoding="utf-8") as stream:
|
|
39
|
+
json.dump(value, stream, indent=2, sort_keys=True, ensure_ascii=False)
|
|
40
|
+
stream.write("\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write(root, name, data):
|
|
44
|
+
path = root / name
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with path.open("xb") as stream:
|
|
47
|
+
stream.write(data.encode() if isinstance(data, str) else data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def command(argv, *, cwd=None, expected=0, env=None):
|
|
51
|
+
result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=180)
|
|
52
|
+
if expected is not None and result.returncode != expected:
|
|
53
|
+
raise RuntimeError(f"Command failed: {argv}: {result.returncode}\n{result.stderr[-4000:]}")
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cases(root):
|
|
58
|
+
from PIL import Image
|
|
59
|
+
import yaml
|
|
60
|
+
png = io.BytesIO()
|
|
61
|
+
Image.new("RGB", (2, 2), "navy").save(png, format="PNG")
|
|
62
|
+
encoded = base64.b64encode(png.getvalue()).decode()
|
|
63
|
+
rows = [
|
|
64
|
+
{"id": "baseline", "body": 'Text.\n\n\n', "ok": True},
|
|
65
|
+
{"id": "missing-id", "no_id": True, "error": "unaltraweb.manual.id"},
|
|
66
|
+
{"id": "missing-figure", "body": '\n', "error": "Missing editorial input"},
|
|
67
|
+
{"id": "public-input", "compute": "assets/data/input.gpkg", "ok": True},
|
|
68
|
+
{"id": "private-input", "compute": "_practices/fixture/data/input.gpkg", "error": "outside the public manual source roots"},
|
|
69
|
+
{"id": "private-input-other-chapter", "compute": "_practices/fixture/data/input.gpkg", "unreferenced": True,
|
|
70
|
+
"chapter": "first", "error": "outside the public manual source roots"},
|
|
71
|
+
{"id": "changed-public-input", "compute": "assets/data/input.gpkg", "changed_input": True,
|
|
72
|
+
"error": "source_or_environment_changed"},
|
|
73
|
+
{"id": "private-image", "body": '\n',
|
|
74
|
+
"extra": {"_practices/fixture/private.png": png.getvalue()}, "error": "outside the public manual source roots"},
|
|
75
|
+
{"id": "components", "body": '''{% include note.md %}
|
|
76
|
+
|
|
77
|
+
{% cite fixture %}
|
|
78
|
+
|
|
79
|
+
::: table "Table caption"
|
|
80
|
+
| A | B |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| 1 | 2 |
|
|
83
|
+
:::
|
|
84
|
+
|
|
85
|
+
::: subfigures a+b "Group caption"
|
|
86
|
+

|
|
87
|
+

|
|
88
|
+
:::
|
|
89
|
+
''', "extra": {"_includes/note.md": "Term\n: Exact definition.\n\n>>>> Warning text.\n",
|
|
90
|
+
"_bibliography/manual.bib": '@book{fixture, title={Fixture reference}, author={Author, A}, year={2026}}'}, "ok": True},
|
|
91
|
+
{"id": "liquid-expression", "body": "Title: {{ page.title }}\n", "error": "unresolved Liquid/include"},
|
|
92
|
+
{"id": "include-parameters", "body": '{% include note.md label="Example" %}\n',
|
|
93
|
+
"extra": {"_includes/note.md": "A note."}, "error": "without parameters"},
|
|
94
|
+
{"id": "html-caption-code", "body": '<img src="assets/img/plain.svg" alt="Alt `code`" title="Caption `literal`">\n',
|
|
95
|
+
"ok": True, "expected_caption": "Caption `literal`"},
|
|
96
|
+
{"id": "embedded-raster", "body": '\n',
|
|
97
|
+
"image": "assets/img/embedded.svg", "extra": {"assets/img/embedded.svg": '<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,' + encoded + '" width="2" height="2"/></svg>'},
|
|
98
|
+
"error": "cannot receive SVG <image>"},
|
|
99
|
+
{"id": "external-raster", "body": '\n',
|
|
100
|
+
"image": "assets/img/external.svg", "extra": {"assets/img/external.svg": '<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.invalid/image.png"/></svg>'},
|
|
101
|
+
"error": "cannot receive SVG <image>"},
|
|
102
|
+
{"id": "qgis-vector", "body": '\n', "image": "assets/img/map.svg",
|
|
103
|
+
"extra": {"assets/img/map.svg": SVG.replace("<rect", '<metadata>{"producer":"unaltracaptura-qgis"}</metadata><rect')},
|
|
104
|
+
"error": "QGIS capture handoff is not supported"},
|
|
105
|
+
{"id": "qgis-untyped", "body": '\n',
|
|
106
|
+
"image": "assets/img/map.svg", "extra": {"assets/img/map.svg": SVG}, "ok": True},
|
|
107
|
+
]
|
|
108
|
+
for row in rows:
|
|
109
|
+
case = root / row["id"]
|
|
110
|
+
case.mkdir()
|
|
111
|
+
config = {"theme": "unaltraweb", "title": "Synthetic review", "default_lang": "ca", "languages": ["ca"],
|
|
112
|
+
"exclude": ["tmp/", "_practices/"], "unaltraweb": {"site_profile": "unaltremanual", "manual": {"id": "review-121"}}}
|
|
113
|
+
if row.get("no_id"):
|
|
114
|
+
del config["unaltraweb"]["manual"]["id"]
|
|
115
|
+
write(case, "_config.yml", yaml.safe_dump(config))
|
|
116
|
+
write(case, ".gitignore", "/tmp/\n")
|
|
117
|
+
write(case, "assets/img/plain.svg", SVG)
|
|
118
|
+
body = row.get("body", 'Text.\n\n\n')
|
|
119
|
+
if row.get("compute"):
|
|
120
|
+
write(case, ".unaltraweb/computations.yml", "version: 1\nsource_roots: [assets/compute]\n")
|
|
121
|
+
write(case, row["compute"], b"OPAQUE SYNTHETIC FINGERPRINT INPUT; NOT A GIS VALIDITY TEST\n")
|
|
122
|
+
front = {"title": "Computed fixture", "lang": "ca", "ref": "computed-fixture", "unaltraweb_compute": {
|
|
123
|
+
"engine": "python", "mode": "figure", "inputs": [row["compute"]], "outputs": ["assets/img/result.svg"]}}
|
|
124
|
+
write(case, "assets/compute/figure.qmd", "---\n" + yaml.safe_dump(front) + "---\nSynthetic source; no computation execution.\n")
|
|
125
|
+
write(case, "assets/img/result.svg", SVG)
|
|
126
|
+
if not row.get("unreferenced"):
|
|
127
|
+
body = '\n'
|
|
128
|
+
write(case, "_chapters/ca/first.md", "---\ntitle: First\nlang: ca\nref: first\n---\n" + body)
|
|
129
|
+
for name, data in row.pop("extra", {}).items():
|
|
130
|
+
write(case, name, data)
|
|
131
|
+
command(["git", "init", "-b", "fixture"], cwd=case)
|
|
132
|
+
paths = [p.relative_to(case).as_posix() for p in case.rglob("*") if p.is_file() and ".git" not in p.parts]
|
|
133
|
+
command(["git", "add", "--", *paths], cwd=case)
|
|
134
|
+
command(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "Synthetic review sources"], cwd=case)
|
|
135
|
+
save(root / "cases.json", rows)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def worker(root):
|
|
139
|
+
"""Executed in the exact published image. No exporter function is replaced."""
|
|
140
|
+
from unaltraweb_mcp import __version__
|
|
141
|
+
from unaltraweb_mcp.manual_context import producer_identity
|
|
142
|
+
assert root == Path("/cases") and os.environ.get("UW_REVIEW_WORKER") == "1"
|
|
143
|
+
identity = producer_identity(Path("/opt/unaltraweb"))
|
|
144
|
+
assert __version__ == "0.8.0" and identity["revision"] == PRODUCER
|
|
145
|
+
rows = json.loads((root / "cases.json").read_text())
|
|
146
|
+
results = []
|
|
147
|
+
for row in rows:
|
|
148
|
+
case = root / row["id"]
|
|
149
|
+
native_status = None
|
|
150
|
+
if row.get("compute"):
|
|
151
|
+
spec = importlib.util.spec_from_file_location("review_native_compute", "/opt/unaltraweb/scripts/computations/render.py")
|
|
152
|
+
native = importlib.util.module_from_spec(spec)
|
|
153
|
+
spec.loader.exec_module(native)
|
|
154
|
+
config, config_path = native.load_config(case)
|
|
155
|
+
record, = native.discover_sources(case, config)
|
|
156
|
+
fingerprint, dependencies, image = native.fingerprint(case, config, config_path, record)
|
|
157
|
+
saved = {"fingerprint": fingerprint, "dependencies": dependencies, "image": image,
|
|
158
|
+
"image_identity": native.inspect_image(image["image"]),
|
|
159
|
+
"outputs": [{"path": p, **native.file_signature(case / p)} for p in record["output_paths"]]}
|
|
160
|
+
save(case / ".unaltraweb/computations.lock.json", {"version": 1, "records": {record["source_path"]: saved}})
|
|
161
|
+
native_status = native.status(case)
|
|
162
|
+
assert native_status["ok"], native_status
|
|
163
|
+
if row.get("changed_input"):
|
|
164
|
+
with (case / row["compute"]).open("ab") as stream:
|
|
165
|
+
stream.write(b"CHANGED INPUT\n")
|
|
166
|
+
before = {p.relative_to(case).as_posix(): sha(p.read_bytes()) for p in case.rglob("*")
|
|
167
|
+
if p.is_file() and ".git" not in p.parts and "tmp" not in p.relative_to(case).parts}
|
|
168
|
+
argv = [sys.executable, "-I", "-B", "-m", "unaltraweb_mcp.cli", "--project", str(case), "mcp",
|
|
169
|
+
"manual-context-export", "--name", "review", "--language", "ca"]
|
|
170
|
+
if row.get("chapter"):
|
|
171
|
+
argv += ["--chapter", row["chapter"]]
|
|
172
|
+
ran = command(argv, expected=None)
|
|
173
|
+
result = json.loads(ran.stdout)
|
|
174
|
+
expected = (ran.returncode == 0 and result.get("ok") is True) if row.get("ok") else (
|
|
175
|
+
ran.returncode == 1 and result.get("ok") is False and row["error"] in result.get("error", ""))
|
|
176
|
+
item = {"id": row["id"], "expected_behavior_observed": expected, "command": argv, "exit_code": ran.returncode,
|
|
177
|
+
"result": result, "stderr": ran.stderr, "source_hashes": before,
|
|
178
|
+
"synthetic_native_status": native_status, "renderer_executed": False}
|
|
179
|
+
archive = case / "tmp/manual-context/review/manual-context.tar.gz"
|
|
180
|
+
item["archive_created"] = archive.exists()
|
|
181
|
+
if result.get("ok"):
|
|
182
|
+
assert archive.is_file() and sha(archive.read_bytes()) == result["sha256"]
|
|
183
|
+
with tarfile.open(archive) as content:
|
|
184
|
+
members = {m.name: content.extractfile(m).read() for m in content.getmembers()}
|
|
185
|
+
manifest = json.loads(members.pop("manual-context.json"))
|
|
186
|
+
assert set(manifest["files"]) == set(members)
|
|
187
|
+
assert all(entry["sha256"] == sha(members[p]) and entry["size_bytes"] == len(members[p]) for p, entry in manifest["files"].items())
|
|
188
|
+
item["manifest"] = manifest
|
|
189
|
+
if row.get("expected_caption"):
|
|
190
|
+
item["expected_caption"] = row["expected_caption"]
|
|
191
|
+
item["caption_preserved"] = manifest["figures"][0]["caption"] == row["expected_caption"]
|
|
192
|
+
else:
|
|
193
|
+
assert not archive.exists(), "A refusal left a transport package"
|
|
194
|
+
assert all(sha((case / p).read_bytes()) == value for p, value in before.items())
|
|
195
|
+
save(root / (row["id"] + ".result.json"), item)
|
|
196
|
+
results.append(item)
|
|
197
|
+
save(root / "producer-review.json", {"ok": all(row["expected_behavior_observed"] for row in results),
|
|
198
|
+
"purpose": "Version-bound baseline review, not acceptance of known defects", "producer": identity,
|
|
199
|
+
"synthetic_only": True, "real_manual_mounted": False, "renderer_executed": False, "cases": results})
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
RECEIVER = '''import json, sys
|
|
203
|
+
from pathlib import Path
|
|
204
|
+
import itemarium
|
|
205
|
+
from itemarium.app.media import read_image
|
|
206
|
+
from itemarium.app.manual_package import check_package
|
|
207
|
+
from itemarium.runtime_identity import source_sha256
|
|
208
|
+
root = Path(sys.argv[1]); engine = Path(itemarium.__file__).parent
|
|
209
|
+
before = source_sha256(engine); results = []
|
|
210
|
+
for case in json.loads((root/'cases.json').read_text()):
|
|
211
|
+
result = json.loads((root/(case['id']+'.result.json')).read_text())['result']
|
|
212
|
+
row = {'id':case['id']}
|
|
213
|
+
if result.get('ok'):
|
|
214
|
+
row['package_check'] = check_package(package_path=root/case['id']/result['path'], sha256=result['sha256'])
|
|
215
|
+
if case.get('image'):
|
|
216
|
+
try:
|
|
217
|
+
read_image(root/case['id']/case['image']); row['image_check'] = {'ok':True}
|
|
218
|
+
except ValueError as error:
|
|
219
|
+
row['image_check'] = {'ok':False, 'error':str(error)}
|
|
220
|
+
results.append(row)
|
|
221
|
+
assert source_sha256(engine) == before
|
|
222
|
+
report = {'engine_version':itemarium.__version__, 'engine_source_sha256':before, 'cases':results,
|
|
223
|
+
'imports_performed':False, 'manufacturer_of_alternate_packages':False,
|
|
224
|
+
'image_checks_are_member_validation_only':True}
|
|
225
|
+
with (root/'receiver-review.json').open('x') as output:
|
|
226
|
+
json.dump(report, output, sort_keys=True, indent=2); output.write('\\n')
|
|
227
|
+
'''
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
232
|
+
parser.add_argument("--output", type=Path)
|
|
233
|
+
parser.add_argument("--receiver-python", type=Path)
|
|
234
|
+
parser.add_argument("--receiver-source", type=Path)
|
|
235
|
+
parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS)
|
|
236
|
+
args = parser.parse_args()
|
|
237
|
+
if args.worker:
|
|
238
|
+
worker(args.worker)
|
|
239
|
+
return
|
|
240
|
+
if not args.output or bool(args.receiver_python) != bool(args.receiver_source):
|
|
241
|
+
parser.error("Supply --output; pair --receiver-python with --receiver-source")
|
|
242
|
+
if not (ROOT / "mcp-factory.yml").is_file():
|
|
243
|
+
parser.error("Run this review harness from the unaltraweb source checkout")
|
|
244
|
+
output = args.output.resolve()
|
|
245
|
+
if output != args.output or not output.is_relative_to(ROOT / "tmp") or not output.parent.is_dir():
|
|
246
|
+
parser.error("Use a new absolute output below this checkout's tmp, with an existing parent")
|
|
247
|
+
observed, = json.loads(command(["docker", "image", "inspect", IMAGE]).stdout)
|
|
248
|
+
assert observed["Id"] == IMAGE and observed["Config"]["Labels"]["org.opencontainers.image.revision"] == SOURCE
|
|
249
|
+
output.mkdir(mode=0o700)
|
|
250
|
+
cases(output)
|
|
251
|
+
name = "unaltraweb-review121-" + uuid.uuid4().hex
|
|
252
|
+
argv = ["docker", "run", "--rm", "--pull", "never", "--name", name, "--network", "none", "--read-only",
|
|
253
|
+
"--user", f"{os.getuid()}:{os.getgid()}", "--cpus", "2", "--memory", "2g", "--pids-limit", "256",
|
|
254
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,nosuid,nodev,size=268435456",
|
|
255
|
+
"-e", "HOME=/tmp", "-e", "PYTHONDONTWRITEBYTECODE=1", "-e", "UW_REVIEW_WORKER=1",
|
|
256
|
+
"--mount", f"type=bind,source={output},target=/cases",
|
|
257
|
+
"--mount", f"type=bind,source={Path(__file__).resolve()},target=/review.py,readonly",
|
|
258
|
+
"--entrypoint", "python3", IMAGE, "-I", "-B", "/review.py", "--worker", "/cases"]
|
|
259
|
+
for path in (output, Path(__file__).resolve()):
|
|
260
|
+
if any(c in str(path) for c in ",\r\n"):
|
|
261
|
+
raise ValueError("Review mount path needs a simple literal spelling")
|
|
262
|
+
ran = command(argv, expected=None)
|
|
263
|
+
save(output / "execution.json", {"command": argv, "exit_code": ran.returncode, "stdout": ran.stdout, "stderr": ran.stderr})
|
|
264
|
+
command(["docker", "info", "--format", "{{.ID}}"])
|
|
265
|
+
absent = command(["docker", "container", "inspect", name], expected=None)
|
|
266
|
+
assert absent.returncode == 1 and "No such" in absent.stderr, "Exact review container absence not established"
|
|
267
|
+
if ran.returncode:
|
|
268
|
+
raise RuntimeError(ran.stderr[-6000:])
|
|
269
|
+
report = json.loads((output / "producer-review.json").read_text())
|
|
270
|
+
assert report["ok"], "A scenario differed from the recorded 0.8.0 behavior; inspect retained results"
|
|
271
|
+
old, = json.loads(command(["docker", "image", "inspect", OLD_IMAGE]).stdout)
|
|
272
|
+
assert old["Id"] == OLD_IMAGE
|
|
273
|
+
old_result = command(["docker", "run", "--rm", "--pull", "never", "--network", "none", "--read-only",
|
|
274
|
+
"--entrypoint", "unaltraweb-mcp", OLD_IMAGE, "mcp", "manual-context-export", "--name", "probe"], expected=None)
|
|
275
|
+
assert old_result.returncode == 2 and "invalid choice" in old_result.stderr
|
|
276
|
+
save(output / "old-runtime.json", {"image_id": OLD_IMAGE, "exit_code": old_result.returncode, "stderr": old_result.stderr})
|
|
277
|
+
if args.receiver_python:
|
|
278
|
+
environment = {**os.environ, "PYTHONPATH": str(args.receiver_source.resolve()), "PYTHONDONTWRITEBYTECODE": "1"}
|
|
279
|
+
# Keep a venv interpreter's spelling: resolving its python symlink would
|
|
280
|
+
# silently select the system interpreter and lose the prepared dependencies.
|
|
281
|
+
command([str(args.receiver_python.absolute()), "-B", "-c", RECEIVER, str(output)], env=environment)
|
|
282
|
+
print(json.dumps({"ok": True, "output": str(output), "cases": len(report["cases"]),
|
|
283
|
+
"receipt_sha256": sha((output / "producer-review.json").read_bytes()), "exact_container_absent": True}, indent=2))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Retained fixture acceptance through the real producer MCP, never a fabricated tar.
|
|
2
|
+
|
|
3
|
+
Run Itemarium's independent conformance command on the returned paths/hashes.
|
|
4
|
+
This script itself has no Itemarium dependency and uses no teaching workspace.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import sys
|
|
15
|
+
import subprocess
|
|
16
|
+
import uuid
|
|
17
|
+
import tarfile
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
20
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
21
|
+
sys.path.insert(0, str(ROOT / "test"))
|
|
22
|
+
from test_manual_context import fixture, write, commit, computation_fixture # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save(path, value):
|
|
26
|
+
with path.open("x") as stream:
|
|
27
|
+
json.dump(value, stream, ensure_ascii=False, sort_keys=True, indent=2)
|
|
28
|
+
stream.write("\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify_private_inputs_html(project, result):
|
|
32
|
+
with tarfile.open(project / result["path"]) as archive:
|
|
33
|
+
files = {entry.name: archive.extractfile(entry).read() for entry in archive.getmembers()}
|
|
34
|
+
manifest = json.loads(files["manual-context.json"])
|
|
35
|
+
assert manifest["figures"][0]["caption"] == "Caption `literal`"
|
|
36
|
+
assert manifest["figures"][0]["alt"] == "Alt `code`"
|
|
37
|
+
assert set(files) == {"manual-context.json", *manifest["files"]}
|
|
38
|
+
data = b"".join(files.values())
|
|
39
|
+
assert b"PRIVATE_CHECK_ONLY_SENTINEL" not in data and b"_practices/" not in data
|
|
40
|
+
assert b"UNALTRAWEBEXPORTPROTECTED" not in data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def export(project, name, image=""):
|
|
44
|
+
from mcp import ClientSession, StdioServerParameters
|
|
45
|
+
from mcp.client.stdio import stdio_client
|
|
46
|
+
def payload(result):
|
|
47
|
+
return result.structuredContent or json.loads("\n".join(row.text for row in result.content if hasattr(row, "text")))
|
|
48
|
+
parameters = StdioServerParameters(command=sys.executable,
|
|
49
|
+
args=["-B", "-m", "unaltraweb_mcp.cli", "mcp", "serve"],
|
|
50
|
+
env={"PATH": os.environ["PATH"], "HOME": str(project), "PYTHONPATH": str(ROOT / "src"),
|
|
51
|
+
"MCP_CONSUMER_WORKSPACE": str(project), "UNALTRAWEB_FACTORY_DIR": str(ROOT)})
|
|
52
|
+
container = ""
|
|
53
|
+
image_id = ""
|
|
54
|
+
if image:
|
|
55
|
+
from unaltraweb_mcp.docker_mount import docker_bind_mount
|
|
56
|
+
image_id = subprocess.run(["docker", "image", "inspect", image, "--format", "{{.Id}}"], check=True,
|
|
57
|
+
capture_output=True, text=True, timeout=20).stdout.strip()
|
|
58
|
+
container = "unaltraweb-context-121-" + uuid.uuid4().hex
|
|
59
|
+
parameters = StdioServerParameters(command="docker", args=["run", "--rm", "-i", "--pull", "never",
|
|
60
|
+
"--name", container, "--network", "none", "--read-only", "--user", f"{os.getuid()}:{os.getgid()}",
|
|
61
|
+
"--cpus", "2", "--memory", "2g", "--pids-limit", "256", "--tmpfs", "/tmp:rw,nosuid,nodev,size=268435456",
|
|
62
|
+
"--label", "io.context.mcp-factory=unaltraweb", "--label", "io.context.mcp-role=context-acceptance",
|
|
63
|
+
"--mount", docker_bind_mount(project, "/workspace"), "-e", "HOME=/tmp",
|
|
64
|
+
"-e", "UNALTRAWEB_DOCKER_ROOT=" + str(project), "-e", "UNALTRAWEB_MCP_IMAGE=" + image_id,
|
|
65
|
+
"--entrypoint", "unaltraweb-mcp", image_id, "--project", "/workspace", "mcp", "serve"])
|
|
66
|
+
async with stdio_client(parameters) as (incoming, outgoing):
|
|
67
|
+
async with ClientSession(incoming, outgoing) as client:
|
|
68
|
+
await client.initialize()
|
|
69
|
+
tools = await client.list_tools()
|
|
70
|
+
tool = next(row for row in tools.tools if row.name == "manual_context_export")
|
|
71
|
+
assert not {"root", "project", "workspace"} & tool.inputSchema["properties"].keys()
|
|
72
|
+
identity = payload(await client.call_tool("runtime_identity", {}))
|
|
73
|
+
result = payload(await client.call_tool("manual_context_export", {"name": name, "language": "ca"}))
|
|
74
|
+
assert result["ok"], result
|
|
75
|
+
again = payload(await client.call_tool("manual_context_export", {"name": name, "language": "ca"}))
|
|
76
|
+
assert not again["ok"] and "already exists" in again["error"], again
|
|
77
|
+
await client.call_tool("runtime_drain", {"confirm": True})
|
|
78
|
+
result["absolute_path"] = str(project / result["path"])
|
|
79
|
+
assert hashlib.sha256(Path(result["absolute_path"]).read_bytes()).hexdigest() == result["sha256"]
|
|
80
|
+
observed = {"identity": identity, "export": result, "create_only_refusal": again}
|
|
81
|
+
if image:
|
|
82
|
+
assert identity["package"]["mode"] == "installed" and not identity["package"]["drift"]
|
|
83
|
+
absent = subprocess.run(["docker", "container", "inspect", container], capture_output=True, timeout=20)
|
|
84
|
+
assert absent.returncode != 0, "Fixture MCP container remains after stdio close"
|
|
85
|
+
observed.update(image_id=image_id, exact_container_absent=True)
|
|
86
|
+
return observed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
91
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
92
|
+
parser.add_argument("--single", action="store_true")
|
|
93
|
+
parser.add_argument("--image", default="", help="Already-built MCP image; pull-never, network-disabled fixture stdio")
|
|
94
|
+
parser.add_argument("--private-inputs-html", action="store_true", help="Exercise P1/P2 with explicitly synthetic native computation records")
|
|
95
|
+
args = parser.parse_args()
|
|
96
|
+
output = args.output.resolve()
|
|
97
|
+
if output != args.output or not output.is_relative_to(ROOT / "tmp") or not output.parent.is_dir():
|
|
98
|
+
raise ValueError("Use a new absolute output directory below the factory tmp with an existing parent")
|
|
99
|
+
output.mkdir(mode=0o700)
|
|
100
|
+
project = output / "manual-fixture"
|
|
101
|
+
project.mkdir()
|
|
102
|
+
fixture(project)
|
|
103
|
+
if args.private_inputs_html:
|
|
104
|
+
write(project, "_practices/fixture/data/input.csv", "PRIVATE_CHECK_ONLY_SENTINEL,1\n")
|
|
105
|
+
write(project, "_practices/fixture/data/parameters.yml", "defaults: &defaults {value: PRIVATE_CHECK_ONLY_SENTINEL}\nselected: *defaults\n")
|
|
106
|
+
computation_fixture(project, inputs=["_practices/fixture/data/input.csv", "_practices/fixture/data/parameters.yml"])
|
|
107
|
+
chapter = project / "_chapters/ca/first.md"
|
|
108
|
+
original = chapter.read_text()
|
|
109
|
+
original = original.replace('{: data-caption-source="Autoria sintètica"}',
|
|
110
|
+
'<img src="assets/img/map.png" alt="Alt `code`" title="Caption `literal`">')
|
|
111
|
+
write(project, "_chapters/ca/first.md", original)
|
|
112
|
+
commit(project, ["_practices/fixture/data/input.csv", "_practices/fixture/data/parameters.yml", ".unaltraweb/computations.yml", ".unaltraweb/computations.lock.json",
|
|
113
|
+
"assets/compute/figure.qmd", "assets/img/figure.svg", "_chapters/ca/first.md"], "P1/P2 synthetic native check inputs")
|
|
114
|
+
first = asyncio.run(export(project, "v1", args.image))
|
|
115
|
+
if args.private_inputs_html:
|
|
116
|
+
verify_private_inputs_html(project, first["export"])
|
|
117
|
+
save(output / "producer-v1.json", first)
|
|
118
|
+
command = [sys.executable, "-B", "-m", "unaltraweb_mcp.cli", "--project", str(project), "mcp", "manual-context-export",
|
|
119
|
+
"--name", "cli-block", "--language", "ca", "--chapter", "first", "--section", "first#method"]
|
|
120
|
+
completed = subprocess.run(command, env={**os.environ, "PYTHONPATH": str(ROOT / "src"), "UNALTRAWEB_FACTORY_DIR": str(ROOT)},
|
|
121
|
+
check=True, capture_output=True, text=True, timeout=120)
|
|
122
|
+
cli = json.loads(completed.stdout)
|
|
123
|
+
assert cli["ok"] and cli["sections"] == 1 and cli["figures"] == 1, cli
|
|
124
|
+
save(output / "producer-cli.json", {"command": command, "result": cli})
|
|
125
|
+
result = {"ok": True, "synthetic_manual": True, "producer_export_invoked": True, "transport": "mcp-stdio",
|
|
126
|
+
"first": first["export"], "cli_verified": True, "questions_generated": 0,
|
|
127
|
+
"private_inputs_html": args.private_inputs_html, "computation_render_executed": False}
|
|
128
|
+
if not args.single:
|
|
129
|
+
original = (project / "_chapters/ca/first.md").read_text()
|
|
130
|
+
write(project, "_chapters/ca/first.md", original + "\nSegona versió: text afegit.\n")
|
|
131
|
+
write(project, "_chapters/ca/second.md", "---\ntitle: Segon\nref: second\nlang: ca\nweight: 20\n---\nCapítol afegit en la segona versió.\n")
|
|
132
|
+
commit(project, ["_chapters/ca/first.md", "_chapters/ca/second.md"], "Second fixture revision")
|
|
133
|
+
second = asyncio.run(export(project, "v2", args.image))
|
|
134
|
+
if args.private_inputs_html:
|
|
135
|
+
verify_private_inputs_html(project, second["export"])
|
|
136
|
+
save(output / "producer-v2.json", second)
|
|
137
|
+
result["next"] = second["export"]
|
|
138
|
+
assert first["export"]["manual"]["id"] == second["export"]["manual"]["id"]
|
|
139
|
+
assert first["export"]["manual"]["revision"] != second["export"]["manual"]["revision"]
|
|
140
|
+
save(output / "producer-receipt.json", result)
|
|
141
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -15,7 +15,7 @@ Managed mode requires an immutable reference or an expected full image ID.
|
|
|
15
15
|
USAGE
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:
|
|
18
|
+
image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:8b41abacad9ca2894755db7f3587a6b47c8fa8f536d88a151bdfc4ae53ba7f08}"
|
|
19
19
|
project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
|
|
20
20
|
operation=serve
|
|
21
21
|
expected_image="${UNALTRAWEB_EXPECTED_IMAGE_ID:-}"
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "component-contract.schema.json",
|
|
3
3
|
"schema_version": 1,
|
|
4
4
|
"release": {
|
|
5
|
-
"version": "0.
|
|
6
|
-
"tag": "v0.
|
|
5
|
+
"version": "0.8.1",
|
|
6
|
+
"tag": "v0.8.1"
|
|
7
7
|
},
|
|
8
8
|
"consumer_integration": {
|
|
9
9
|
"schema_version": 1,
|
|
@@ -55,17 +55,17 @@
|
|
|
55
55
|
"package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
|
|
56
56
|
"factory_required_commands": ["factory-dir"],
|
|
57
57
|
"package_only_mcp": ["artifact-import-check", "import-artifact-bundle", "bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "editorial-policy", "editorial-publication-check", "editorial-review-prepare", "editorial-review-record", "editorial-review-resolve", "editorial-status", "html-audit", "http-check", "image-background-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "prose-check", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
|
|
58
|
-
"factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
|
|
58
|
+
"factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-context-export", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
|
|
59
59
|
"not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals", "qgis_practice_verifier"]
|
|
60
60
|
},
|
|
61
61
|
"components": {
|
|
62
62
|
"gem": {
|
|
63
63
|
"kind": "gem",
|
|
64
64
|
"name": "unaltraweb",
|
|
65
|
-
"version": "0.
|
|
66
|
-
"release": "v0.
|
|
65
|
+
"version": "0.8.1",
|
|
66
|
+
"release": "v0.8.1",
|
|
67
67
|
"release_status": "ready",
|
|
68
|
-
"reference": "unaltraweb (= 0.
|
|
68
|
+
"reference": "unaltraweb (= 0.8.1)",
|
|
69
69
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
70
70
|
"included_in_wheel": false,
|
|
71
71
|
"features": ["jekyll-core", "site-build"]
|
|
@@ -73,10 +73,10 @@
|
|
|
73
73
|
"wheel": {
|
|
74
74
|
"kind": "python-wheel",
|
|
75
75
|
"name": "unaltraweb-mcp",
|
|
76
|
-
"version": "0.
|
|
77
|
-
"release": "v0.
|
|
76
|
+
"version": "0.8.1",
|
|
77
|
+
"release": "v0.8.1",
|
|
78
78
|
"release_status": "ready",
|
|
79
|
-
"reference": "unaltraweb-mcp==0.
|
|
79
|
+
"reference": "unaltraweb-mcp==0.8.1",
|
|
80
80
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
81
81
|
"included_in_wheel": true,
|
|
82
82
|
"features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane", "source-ci"]
|
|
@@ -84,10 +84,10 @@
|
|
|
84
84
|
"runtime": {
|
|
85
85
|
"kind": "container",
|
|
86
86
|
"name": "unaltraweb runtime",
|
|
87
|
-
"version": "0.
|
|
88
|
-
"release": "v0.
|
|
87
|
+
"version": "0.8.1",
|
|
88
|
+
"release": "v0.8.1",
|
|
89
89
|
"release_status": "ready",
|
|
90
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.
|
|
90
|
+
"reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.8.1",
|
|
91
91
|
"image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
|
|
92
92
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
93
93
|
"included_in_wheel": false,
|
|
@@ -96,10 +96,10 @@
|
|
|
96
96
|
"mcp": {
|
|
97
97
|
"kind": "container",
|
|
98
98
|
"name": "unaltraweb MCP runtime",
|
|
99
|
-
"version": "0.
|
|
100
|
-
"release": "v0.
|
|
99
|
+
"version": "0.8.1",
|
|
100
|
+
"release": "v0.8.1",
|
|
101
101
|
"release_status": "ready",
|
|
102
|
-
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.
|
|
102
|
+
"reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.8.1",
|
|
103
103
|
"image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
|
|
104
104
|
"repository": "https://github.com/dosquartsdedocs/unaltraweb",
|
|
105
105
|
"included_in_wheel": false,
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unaltraweb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dosquartsdedocs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: classifier-reborn
|
|
@@ -783,6 +783,7 @@ files:
|
|
|
783
783
|
- docs/_documentation/en/44-editorial-review.md
|
|
784
784
|
- docs/_documentation/en/45-retained-documents.md
|
|
785
785
|
- docs/_documentation/en/46-live-runtime.md
|
|
786
|
+
- docs/_documentation/en/47-manual-context.md
|
|
786
787
|
- docs/_documentation/en/47-private-practice-pdfs.md
|
|
787
788
|
- docs/_documentation/en/50-faq.md
|
|
788
789
|
- docs/_pages/ca/index.md
|
|
@@ -806,6 +807,9 @@ files:
|
|
|
806
807
|
- docs/agents/deploy-provenance-86.md
|
|
807
808
|
- docs/agents/deployment-integration-0.7.1.md
|
|
808
809
|
- docs/agents/manual-authoring-components.md
|
|
810
|
+
- docs/agents/manual-context-121.md
|
|
811
|
+
- docs/agents/manual-context-fix-121.md
|
|
812
|
+
- docs/agents/manual-context-pilot-review-121.md
|
|
809
813
|
- docs/agents/mcp-contract.md
|
|
810
814
|
- docs/agents/owner-closeout-76.md
|
|
811
815
|
- docs/agents/owner-closeout-80.md
|
|
@@ -857,7 +861,9 @@ files:
|
|
|
857
861
|
- scripts/manual/verify_release_assets.py
|
|
858
862
|
- scripts/prepare_practice_candidate.py
|
|
859
863
|
- scripts/prepare_practice_editions_candidate.py
|
|
864
|
+
- scripts/review_manual_context_pilots.py
|
|
860
865
|
- scripts/test_gem_build.py
|
|
866
|
+
- scripts/test_manual_context_export.py
|
|
861
867
|
- scripts/test_reproducible_jekyll_build.py
|
|
862
868
|
- scripts/test_scaffold_release.py
|
|
863
869
|
- scripts/test_wheel_install.py
|