Skip to content

Commit b359eef

Browse files
committed
Add certificate receipt support
1 parent 9a597ca commit b359eef

5 files changed

Lines changed: 400 additions & 0 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CT_CERT_RECEIPT_API_URL=https://cert.complete.tech/api/skill-runs
2+
CT_CERT_CLASS_ID=cls_agentic_case_study_skill
3+
CT_CERT_SESSION_ID=ses_YYYYMMDD_agentic_case_study_skill
4+
CT_CERT_COMPLETION_KEY=replace-with-shared-class-key
5+
CT_CERT_COMPLETION_KEY_ENV=CT_CERT_COMPLETION_KEY
6+
CT_CERT_SKILL_ID=agentic-case-study-skill
7+
CT_CERT_SKILL_VERSION=1.0.0
8+
9+
# Optional profile variables for --registry-profile agentic_case_study_skill
10+
CT_CERT_AGENTIC_CASE_STUDY_SKILL_CLASS_ID=cls_agentic_case_study_skill
11+
CT_CERT_AGENTIC_CASE_STUDY_SKILL_SESSION_ID=ses_YYYYMMDD_agentic_case_study_skill
12+
CT_CERT_AGENTIC_CASE_STUDY_SKILL_COMPLETION_KEY=replace-with-shared-class-key

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,30 @@ Use careful evidence packaging. Distinguish measured outcomes from qualitative o
107107
## License
108108

109109
Code, templates, and documentation are licensed under the MIT License. CompleteTech LLC names, logos, seals, and brand assets are reserved and are not licensed for reuse except to identify this project. See `LICENSE` and `BRAND_ASSETS.md`.
110+
111+
## Certificate Receipts
112+
113+
This skill can run normally without a classroom key. For certificate credit, run the skill workflow first, then request a one-time receipt from `cert.complete.tech`:
114+
115+
```bash
116+
python scripts/request_receipt.py \
117+
--class-id "cls_agentic_case_study_skill" \
118+
--session-id "ses_YYYYMMDD_agentic_case_study_skill" \
119+
--completion-key "$CT_CERT_COMPLETION_KEY"
120+
```
121+
122+
The helper sends `class_id`, `session_id`, `completion_key`, `skill_id`, `skill_version`, a generated `run_id`, optional artifact hash, and metadata to `https://cert.complete.tech/api/skill-runs`. It prints the receipt code and writes a receipt JSON file. Students use the receipt code at `https://cert.complete.tech/claim`. Do not commit real completion keys.
123+
124+
If the skill produced a file, include it so the receipt records an artifact hash:
125+
126+
```bash
127+
python scripts/request_receipt.py --artifact output/example.pdf
128+
```
129+
130+
### Receipt Tests
131+
132+
```bash
133+
python tests/test_receipt_cli.py
134+
```
135+
136+
The test uses a local fake receipt API and does not require live keys or the live `cert.complete.tech` endpoint.

SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,11 @@ python3 scripts/render_proof.py --template public-named-client-case-study \
112112
- `--no-pdf` emits Markdown only (the original behavior); `--no-cover` drops the cover page.
113113
- Already drafted the Markdown yourself? Render it directly: `python3 scripts/render_pdf.py --markdown artifact.md --out artifact.pdf --logo assets/logo.png --title "..."`.
114114
- The PDF supports a Markdown subset: `#`/`##`/`###` headings, paragraphs, `-` bullets, tables, `>` callouts, `**bold**`, and `[PAGE_BREAK]`. PDF requires `reportlab`; the optional `--png` preview requires `pypdfium2` and `pillow`. See `assets/examples/` for a rendered example.
115+
116+
## Certificate Receipt Guidance
117+
118+
The skill remains usable without a classroom key. When certificate credit is needed, use `scripts/request_receipt.py` after the skill run. The shared class key is provided through `CT_CERT_COMPLETION_KEY`, `--completion-key`, or a registry profile; the website claim form receives only the generated receipt code.
119+
120+
Receipt requests include this skill ID: `agentic-case-study-skill`. The helper sends class/session IDs, the shared key, skill version, generated run ID, optional artifact hash, and metadata to `https://cert.complete.tech/api/skill-runs`. The student claims the certificate at `https://cert.complete.tech/claim` with the returned receipt.
121+
122+
Do not print, store, or commit real classroom completion keys.

scripts/request_receipt.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env python3
2+
"""Request a CompleteTech certificate receipt for a skill run.
3+
4+
This helper is intentionally self-contained so each skill repository can carry
5+
the same classroom receipt workflow without depending on a shared package.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import hashlib
12+
import json
13+
import os
14+
import re
15+
import uuid
16+
from datetime import datetime, timezone
17+
from pathlib import Path
18+
from typing import Any, Optional
19+
from urllib import error, request
20+
21+
ROOT = Path(__file__).resolve().parents[1]
22+
DEFAULT_RECEIPT_API_URL = "https://cert.complete.tech/api/skill-runs"
23+
24+
25+
def load_env_file(path: Path) -> None:
26+
if not path.is_file():
27+
return
28+
for raw_line in path.read_text(encoding="utf-8").splitlines():
29+
line = raw_line.strip()
30+
if not line or line.startswith("#") or "=" not in line:
31+
continue
32+
key, value = line.split("=", 1)
33+
key = key.strip()
34+
value = value.strip()
35+
if not key or key in os.environ:
36+
continue
37+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
38+
value = value[1:-1]
39+
os.environ[key] = value
40+
41+
42+
def first_present(*values: Optional[str]) -> str:
43+
for value in values:
44+
if value is not None and value.strip():
45+
return value.strip()
46+
return ""
47+
48+
49+
def env_profile_prefix(profile: str) -> str:
50+
normalized = re.sub(r"[^A-Za-z0-9]+", "_", profile).strip("_").upper()
51+
return f"CT_CERT_{normalized}"
52+
53+
54+
def skill_metadata() -> tuple[str, str]:
55+
skill_md = ROOT / "SKILL.md"
56+
text = skill_md.read_text(encoding="utf-8") if skill_md.exists() else ""
57+
name_match = re.search(r"^name:\s*([A-Za-z0-9._-]+)\s*$", text, re.MULTILINE)
58+
version_match = re.search(r"^version:\s*([A-Za-z0-9._-]+)\s*$", text, re.MULTILINE)
59+
fallback = ROOT.name.replace("_", "-")
60+
return (
61+
name_match.group(1) if name_match else fallback,
62+
version_match.group(1) if version_match else "1.0.0",
63+
)
64+
65+
66+
def sha256_file(path: Path) -> str:
67+
digest = hashlib.sha256()
68+
with path.open("rb") as handle:
69+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
70+
digest.update(chunk)
71+
return "sha256:" + digest.hexdigest()
72+
73+
74+
def load_receipts(raw_values: list[str], raw_file: Optional[str]) -> list[str]:
75+
receipts = [value.strip() for value in raw_values if value and value.strip()]
76+
if not raw_file:
77+
return receipts
78+
data = json.loads(Path(raw_file).expanduser().read_text(encoding="utf-8"))
79+
if isinstance(data, list):
80+
receipts.extend(str(value).strip() for value in data if str(value).strip())
81+
elif isinstance(data, dict):
82+
raw_receipts = data.get("prerequisite_receipts") or data.get("receipts") or []
83+
if not isinstance(raw_receipts, list):
84+
raise SystemExit("Prerequisite receipt JSON must contain a receipts array.")
85+
receipts.extend(str(value).strip() for value in raw_receipts if str(value).strip())
86+
else:
87+
raise SystemExit("Prerequisite receipt JSON must be an array or object.")
88+
return receipts
89+
90+
91+
def post_json(api_url: str, payload: dict[str, Any]) -> dict[str, Any]:
92+
body = json.dumps(payload).encode("utf-8")
93+
req = request.Request(
94+
api_url,
95+
data=body,
96+
headers={
97+
"Accept": "application/json",
98+
"Content-Type": "application/json",
99+
"User-Agent": f"{payload['skill_id']}/receipt-helper",
100+
},
101+
method="POST",
102+
)
103+
try:
104+
with request.urlopen(req, timeout=30) as response:
105+
data = response.read().decode("utf-8")
106+
except error.HTTPError as exc:
107+
details = exc.read().decode("utf-8", errors="replace")
108+
raise RuntimeError(f"Receipt API rejected the request with HTTP {exc.code}: {details}") from exc
109+
except error.URLError as exc:
110+
raise RuntimeError(f"Receipt API request failed: {exc.reason}") from exc
111+
decoded = json.loads(data)
112+
if not isinstance(decoded, dict) or decoded.get("ok") is not True:
113+
raise RuntimeError(f"Receipt API returned an unsuccessful response: {decoded!r}")
114+
return decoded
115+
116+
117+
def parse_args() -> argparse.Namespace:
118+
default_skill_id, default_skill_version = skill_metadata()
119+
parser = argparse.ArgumentParser(description=__doc__)
120+
parser.add_argument("--receipt-api-url", default=None)
121+
parser.add_argument("--registry-profile", default=None)
122+
parser.add_argument("--class-id", default=None)
123+
parser.add_argument("--session-id", default=None)
124+
parser.add_argument("--completion-key", default=None)
125+
parser.add_argument("--completion-key-env", default=None)
126+
parser.add_argument("--skill-id", default=None)
127+
parser.add_argument("--skill-version", default=None)
128+
parser.add_argument("--run-id", default=None)
129+
parser.add_argument("--artifact", default=None, help="Optional output artifact path to hash.")
130+
parser.add_argument("--artifact-hash", default=None)
131+
parser.add_argument("--metadata", action="append", default=[], help="Metadata as key=value.")
132+
parser.add_argument("--receipt-out", default=None, help="Receipt JSON output path.")
133+
parser.add_argument("--prerequisite-receipt", action="append", default=[])
134+
parser.add_argument("--prerequisite-receipts-file", default=None)
135+
parser.set_defaults(default_skill_id=default_skill_id, default_skill_version=default_skill_version)
136+
return parser.parse_args()
137+
138+
139+
def main() -> int:
140+
args = parse_args()
141+
load_env_file(ROOT / ".env")
142+
load_env_file(ROOT / ".env.local")
143+
144+
profile: dict[str, str] = {}
145+
if args.registry_profile:
146+
prefix = env_profile_prefix(args.registry_profile)
147+
profile = {
148+
"class_id": os.environ.get(f"{prefix}_CLASS_ID", ""),
149+
"session_id": os.environ.get(f"{prefix}_SESSION_ID", ""),
150+
"completion_key": os.environ.get(f"{prefix}_COMPLETION_KEY", ""),
151+
}
152+
153+
key_env = first_present(args.completion_key_env, os.environ.get("CT_CERT_COMPLETION_KEY_ENV"), "CT_CERT_COMPLETION_KEY")
154+
artifact_hash = first_present(args.artifact_hash)
155+
if not artifact_hash and args.artifact:
156+
artifact_hash = sha256_file(Path(args.artifact).expanduser())
157+
158+
metadata: dict[str, str] = {
159+
"requested_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
160+
"repository": ROOT.name,
161+
}
162+
if args.artifact:
163+
metadata["artifact"] = Path(args.artifact).expanduser().name
164+
for entry in args.metadata:
165+
if "=" not in entry:
166+
raise SystemExit(f"--metadata must be key=value, got: {entry}")
167+
key, value = entry.split("=", 1)
168+
metadata[key.strip()] = value.strip()
169+
170+
payload: dict[str, Any] = {
171+
"class_id": first_present(args.class_id, profile.get("class_id"), os.environ.get("CT_CERT_CLASS_ID")),
172+
"session_id": first_present(args.session_id, profile.get("session_id"), os.environ.get("CT_CERT_SESSION_ID")),
173+
"completion_key": first_present(args.completion_key, profile.get("completion_key"), os.environ.get(key_env)),
174+
"skill_id": first_present(args.skill_id, os.environ.get("CT_CERT_SKILL_ID"), args.default_skill_id),
175+
"skill_version": first_present(args.skill_version, os.environ.get("CT_CERT_SKILL_VERSION"), args.default_skill_version),
176+
"run_id": first_present(args.run_id, str(uuid.uuid4())),
177+
"metadata": metadata,
178+
}
179+
if artifact_hash:
180+
payload["artifact_hash"] = artifact_hash
181+
prerequisite_receipts = load_receipts(args.prerequisite_receipt, args.prerequisite_receipts_file)
182+
if prerequisite_receipts:
183+
payload["prerequisite_receipts"] = prerequisite_receipts
184+
185+
missing = [key for key in ("class_id", "session_id", "completion_key", "skill_id", "run_id") if not payload[key]]
186+
if missing:
187+
raise SystemExit(f"Missing receipt field(s): {', '.join(missing)}")
188+
189+
api_url = first_present(args.receipt_api_url, os.environ.get("CT_CERT_RECEIPT_API_URL"), DEFAULT_RECEIPT_API_URL)
190+
receipt = post_json(api_url, payload)
191+
record = {
192+
"receipt_code": str(receipt["receipt_code"]),
193+
"claim_url": str(receipt["claim_url"]),
194+
"expires_at": str(receipt["expires_at"]),
195+
"class_id": payload["class_id"],
196+
"session_id": payload["session_id"],
197+
"skill_id": payload["skill_id"],
198+
"skill_version": payload["skill_version"],
199+
"run_id": str(receipt.get("run_id") or payload["run_id"]),
200+
"artifact_hash": artifact_hash,
201+
"prerequisite_receipts_count": len(prerequisite_receipts),
202+
}
203+
204+
receipt_out = Path(args.receipt_out).expanduser() if args.receipt_out else ROOT / "output" / f"{record['run_id']}.receipt.json"
205+
receipt_out.parent.mkdir(parents=True, exist_ok=True)
206+
receipt_out.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
207+
208+
print(f"Receipt Code: {record['receipt_code']}")
209+
print(f"Claim URL: {record['claim_url']}")
210+
print(f"Receipt JSON: {receipt_out}")
211+
return 0
212+
213+
214+
if __name__ == "__main__":
215+
try:
216+
raise SystemExit(main())
217+
except RuntimeError as exc:
218+
print(f"Error: {exc}")
219+
raise SystemExit(1)

0 commit comments

Comments
 (0)