diff --git a/CHANGELOG.md b/CHANGELOG.md index a606945..dfa213f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Adds a reusable diploma template image and dynamic `certificate.diploma` artifact to completed certificate responses, including render fields, SVG markup, and SVG data URI output for client display/export. + ## 1.0.15 - 2026-05-08 - Adds WordPress themelet favicons, Apple touch icon, web app manifest, and a 1200x630 Open Graph share image. diff --git a/assets/certificates/diploma-template.png b/assets/certificates/diploma-template.png new file mode 100644 index 0000000..5595240 Binary files /dev/null and b/assets/certificates/diploma-template.png differ diff --git a/bin/http-course-completion-smoke.php b/bin/http-course-completion-smoke.php index 416d750..7b085f7 100644 --- a/bin/http-course-completion-smoke.php +++ b/bin/http-course-completion-smoke.php @@ -158,6 +158,11 @@ static function ( array $tool ): string { fail( 'get-certificate did not return an eligible certificate with identifiers.' ); } +$diploma = $certificate['certificate']['diploma'] ?? null; +if ( ! is_array( $diploma ) || empty( $diploma['template_url'] ) || empty( $diploma['fields']['recipient_name'] ) || empty( $diploma['svg_markup'] ) || empty( $diploma['svg_data_uri'] ) ) { + fail( 'get-certificate did not return a dynamic diploma artifact with template, fields, SVG markup, and SVG data URI.' ); +} + $graduation_reflection = graduation_reflection_from_certificate( $certificate['certificate'] ); if ( $graduation_reflection === null ) { fail( 'get-certificate did not return a graduation reflection for the completed enrollment.' ); @@ -187,6 +192,7 @@ static function ( array $tool ): string { $summary['certificate_id'] = $certificate['certificate']['certificate_id']; $summary['transcript_count'] = count( $transcript ); $summary['checks'][] = 'get-certificate issued anonymous certificate, transcript, graduation speech, and graduation reflection'; +$summary['checks'][] = 'get-certificate returned a dynamic diploma artifact'; $summary['checks'][] = 'get-certificate returned post-certificate memory/reflection next work'; assert_suggested_tools_exist( $certificate, $tool_names, 'get-certificate' ); $summary['status'] = 'ok'; diff --git a/docs/http-smoke.md b/docs/http-smoke.md index 8515105..68786f1 100644 --- a/docs/http-smoke.md +++ b/docs/http-smoke.md @@ -43,7 +43,7 @@ The smoke test performs these MCP JSON-RPC calls: 13. `tools/call` for `get-certificate` and confirm an unfinished enrollment gets remaining work. 14. `tools/call` for `get-exercise` with `include_model_answer=true`. -The completion smoke performs the same MCP initialize/session flow, then calls `attempt-exercise` for every bundled exercise and finishes with `get-next-work` plus `get-certificate`. The certificate response includes `graduation_speech`, which tells the Agent to stand at the podium and tell everyone what it learned before closing the course. After certificate issuance, the returned next work must point to `get-learning-memory` and reflection/feedback instead of asking for `get-certificate` again. The live course also exposes an MCP-ready `take-course` tool, which is the LLM autopilot entry point for reading course packets without asking a human to advance lesson by lesson. +The completion smoke performs the same MCP initialize/session flow, then calls `attempt-exercise` for every bundled exercise and finishes with `get-next-work` plus `get-certificate`. The certificate response includes `graduation_speech`, which tells the Agent to stand at the podium and tell everyone what it learned before closing the course. It also includes `certificate.diploma`, a dynamic diploma artifact with a reusable PNG template URL, render fields, SVG markup, and an SVG data URI that clients can display or export. After certificate issuance, the returned next work must point to `get-learning-memory` and reflection/feedback instead of asking for `get-certificate` again. The live course also exposes an MCP-ready `take-course` tool, which is the LLM autopilot entry point for reading course packets without asking a human to advance lesson by lesson. The endpoint is POST-based. A browser GET to `/mcp` or `/mcp/wordpress-plugin-craft` may return `405 Method Not Allowed`; that is expected for the current MCP HTTP transport. Use an MCP client or the smoke script to prove the endpoint. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3050a3e..16aa449 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -59,7 +59,7 @@ Model Context Polytechnic is a WordPress plugin and a course-pack distribution. - Call `attempt-exercise`. - Call `get-learning-memory`. - Call `get-certificate` before completion and confirm it returns remaining work. -- In a full-course completion rehearsal or `http-course-completion-smoke`, call `get-certificate` after every exercise is passed and confirm it returns a certificate ID, verification code, transcript, and `graduation_speech`. +- In a full-course completion rehearsal or `http-course-completion-smoke`, call `get-certificate` after every exercise is passed and confirm it returns a certificate ID, verification code, transcript, `graduation_speech`, and `certificate.diploma` with template URL, fields, SVG markup, and SVG data URI. - Confirm completed progress shows `completion_percent=100` and `completion_ratio=1`, and post-certificate next work goes to learning memory/reflection rather than another certificate call. - After certificate issuance, deliver the graduation speech, then submit confidence and reflection feedback about how the course will improve future WordPress plugin work. - Call `submit-feedback`. diff --git a/includes/class-learning.php b/includes/class-learning.php index c2f18a6..41d29c9 100644 --- a/includes/class-learning.php +++ b/includes/class-learning.php @@ -1993,7 +1993,7 @@ private static function register_get_certificate_tool( array $course ): void { 'properties' => [ 'enrollment_key' => [ 'type' => 'string', 'description' => 'Anonymous course enrollment key returned by begin-course.' ], 'session_id' => [ 'type' => 'string', 'description' => 'Deprecated alias for enrollment_key.' ], - 'recipient_name' => [ 'type' => 'string', 'description' => 'Optional display name for this response only. It is not used as authentication.' ], + 'recipient_name' => [ 'type' => 'string', 'description' => 'Optional display name for this response only, such as the agent name chosen by the human. It is not used as authentication.' ], 'include_transcript' => [ 'type' => 'boolean', 'default' => true ], ], 'anyOf' => [ @@ -3232,6 +3232,96 @@ private static function verification_code( string $certificate_id, string $hash, return substr( hash( 'sha256', $certificate_id . '|' . $hash . '|' . $course['slug'] ), 0, 24 ); } + private static function certificate_diploma_artifact( array $course, array $certificate, array $completion_snapshot ): array { + $completed_count = (int) ( $completion_snapshot['completed_count'] ?? 0 ); + $total_count = (int) ( $completion_snapshot['total_exercise_count'] ?? 0 ); + $fields = [ + 'institution' => 'Model Context Polytechnic', + 'title' => __( 'Certificate of Agentic WordPress Plugin Craft', 'model-context-polytechnic' ), + 'recipient_name' => (string) ( $certificate['recipient'] ?? __( 'Anonymous MCP Learner', 'model-context-polytechnic' ) ), + 'course_name' => (string) ( $course['name'] ?? ( $completion_snapshot['course_name'] ?? '' ) ), + 'labs_passed' => sprintf( '%1$d / %2$d', $completed_count, $total_count ), + 'confidence' => __( 'Graduate reflection pending', 'model-context-polytechnic' ), + 'certificate_id' => (string) ( $certificate['certificate_id'] ?? '' ), + 'verification_code' => (string) ( $certificate['verification_code'] ?? '' ), + 'issued_at' => (string) ( $certificate['issued_at'] ?? '' ), + 'motto' => __( 'Bootstrap with discipline. Ship with care.', 'model-context-polytechnic' ), + ]; + $svg = self::certificate_diploma_svg( $fields ); + $svg_data_uri = 'data:image/svg+xml;charset=utf-8,' . rawurlencode( $svg ); + + return [ + 'type' => 'dynamic_diploma', + 'template_url' => self::certificate_diploma_template_url(), + 'template_mime_type' => 'image/png', + 'fields' => $fields, + 'svg_markup' => $svg, + 'svg_data_uri' => $svg_data_uri, + 'display_markdown' => '![' . self::markdown_alt( sprintf( + /* translators: %s: recipient name. */ + __( 'Model Context Polytechnic diploma for %s', 'model-context-polytechnic' ), + $fields['recipient_name'] + ) ) . '](' . $svg_data_uri . ')', + 'dynamic_fields' => [ + 'recipient_name', + 'course_name', + 'labs_passed', + 'confidence', + 'certificate_id', + 'verification_code', + 'issued_at', + ], + 'generation_note' => __( 'Use svg_markup or svg_data_uri when the client can render generated images. Use template_url plus fields when the client wants to render or export its own diploma image.', 'model-context-polytechnic' ), + ]; + } + + private static function certificate_diploma_template_url(): string { + $asset_path = 'assets/certificates/diploma-template.png'; + if ( function_exists( 'plugins_url' ) && defined( 'MODEL_CONTEXT_POLYTECHNIC_FILE' ) ) { + $url = plugins_url( $asset_path, MODEL_CONTEXT_POLYTECHNIC_FILE ); + } else { + $url = $asset_path; + } + + $version = defined( 'MODEL_CONTEXT_POLYTECHNIC_VERSION' ) ? MODEL_CONTEXT_POLYTECHNIC_VERSION : Server::SERVER_VERSION; + $separator = str_contains( $url, '?' ) ? '&' : '?'; + return $url . $separator . 'v=' . rawurlencode( $version ); + } + + private static function certificate_diploma_svg( array $fields ): string { + $template_url = self::svg_text( self::certificate_diploma_template_url() ); + $recipient = self::svg_text( (string) ( $fields['recipient_name'] ?? '' ) ); + $course = self::svg_text( (string) ( $fields['course_name'] ?? '' ) ); + $labs = self::svg_text( (string) ( $fields['labs_passed'] ?? '' ) ); + $confidence = self::svg_text( (string) ( $fields['confidence'] ?? '' ) ); + $certificate_id = self::svg_text( (string) ( $fields['certificate_id'] ?? '' ) ); + $verification_code = self::svg_text( (string) ( $fields['verification_code'] ?? '' ) ); + $issued_at = self::svg_text( (string) ( $fields['issued_at'] ?? '' ) ); + $motto = self::svg_text( (string) ( $fields['motto'] ?? '' ) ); + + return << + + + {$recipient} + has completed {$course} with distinction + Labs Passed: {$labs} ยท Confidence: {$confidence} + {$motto} + Certificate ID: {$certificate_id} + Verification: {$verification_code} + Issued: {$issued_at} + +SVG; + } + + private static function svg_text( string $value ): string { + return htmlspecialchars( str_replace( [ "\r", "\n" ], ' ', $value ), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); + } + + private static function markdown_alt( string $value ): string { + return str_replace( [ "\r", "\n", ']' ], [ ' ', ' ', ')' ], $value ); + } + private static function certificate_record_for_hash( int $course_id, string $hash ): ?array { global $wpdb; $table = $wpdb->prefix . self::CERTIFICATES_TABLE; @@ -3430,6 +3520,7 @@ private static function certificate_from_record( array $course, string $hash, ar $certificate['transcript'] = $snapshot['transcript']; } + $certificate['diploma'] = self::certificate_diploma_artifact( $course, $certificate, $certificate['completion_snapshot'] ); $certificate['graduation_speech'] = self::graduation_speech_prompt( $course, '', $certificate ); return $certificate; @@ -3545,6 +3636,7 @@ static function ( array $exercise ): string { $certificate['transcript'] = self::certificate_transcript( $public_exercises, $progress['exercises'] ?? [] ); } + $certificate['diploma'] = self::certificate_diploma_artifact( $course, $certificate, $snapshot ); $certificate['graduation_speech'] = self::graduation_speech_prompt( $course, '', $certificate ); return $certificate;