Skip to content

Commit e5f54cd

Browse files
Merge pull request #13 from eduNEXT/hpg/send-event
feat: add BADGE_GENERATION event and submit button
2 parents 4990f7f + e01ec0c commit e5f54cd

16 files changed

Lines changed: 659 additions & 124 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Local Open edX event definitions for openedx-ai-badges.
3+
4+
These events follow OEP-41 naming and OEP-49 data conventions.
5+
They are defined here pending contribution to the openedx/openedx-events repository.
6+
"""
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Data classes for openedx-ai-badges local events.
3+
4+
``BadgeTemplateData`` (from openedx-events) covers uuid/origin/name/description/image_url
5+
but has no ``course_id`` or ``criteria_narrative``. ``BadgeData`` adds a ``UserData``
6+
(learner PII) which is wrong for a course-level event.
7+
8+
``BadgeGenerationData`` extends the template concept with those two missing fields.
9+
It follows OEP-49 (frozen attrs) so it can be contributed to openedx/openedx-events later.
10+
11+
Once accepted upstream, replace these imports with:
12+
from openedx_events.learning.data import BadgeGenerationData
13+
"""
14+
import attr
15+
from opaque_keys.edx.keys import CourseKey
16+
17+
18+
@attr.s(frozen=True)
19+
class BadgeGenerationData:
20+
"""
21+
Data for the BADGE_GENERATION event.
22+
23+
Carries the complete session badge entry (Open Badges 3.0 payload plus
24+
course context and image) as a single ``badge_data`` field. No learner
25+
PII is included — this is a course-level artifact.
26+
27+
All keys in ``badge_data`` are **camelCase** — snake_case is normalised
28+
away before the event is emitted.
29+
30+
``badge_data`` is a serialized **OpenBadgeCredential** (OB 3.0 / W3C VC)
31+
document. Only the fields that have a direct mapping in the spec are
32+
included; internal fields (``course_context``, ``badge_configuration``,
33+
``enable_skill_extraction``, etc.) are intentionally omitted.
34+
35+
``badge_data`` structure::
36+
37+
{
38+
"@context": [
39+
"https://www.w3.org/ns/credentials/v2",
40+
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
41+
],
42+
"id": str, # "urn:uuid:<badge_id>"
43+
"type": ["VerifiableCredential", "OpenBadgeCredential"],
44+
"name": str, # achievement name
45+
"image": {
46+
"type": "Image",
47+
"id": str, # image URL
48+
"caption": str
49+
}
50+
"validFrom": str, # ISO-8601 timestamp
51+
"issuer": {
52+
"id": str,
53+
"type": "Profile",
54+
"name": str
55+
},
56+
"credentialSubject": {
57+
"type": ["AchievementSubject"],
58+
"achievement": {
59+
"id": str, # "urn:uuid:<achievement_id>"
60+
"type": ["Achievement"],
61+
"name": str,
62+
"description": str,
63+
"criteria": {
64+
"narrative": str
65+
},
66+
"alignment": [ # present only when skills were generated
67+
{
68+
"type": ["Alignment"],
69+
"targetName": str,
70+
"targetUrl": str,
71+
"targetType": str, # e.g. "ESCO:Skill"
72+
"targetDescription": str # optional
73+
}
74+
],
75+
"image": { # present only when a badge image exists
76+
"type": "Image",
77+
"id": str, # image URL
78+
"caption": str
79+
}
80+
}
81+
}
82+
}
83+
84+
Attributes:
85+
uuid (str): Unique identifier for this generation event (UUID v4).
86+
course_id (CourseKey): The course for which the badge was generated.
87+
origin (str): Identifier of the system that generated the badge.
88+
badge_data (dict): Complete camelCase badge entry (see above).
89+
"""
90+
91+
uuid = attr.ib(type=str)
92+
course_id = attr.ib(type=CourseKey)
93+
badge_data = attr.ib(type=dict, factory=dict)
94+
origin = attr.ib(type=str, default="openedx-ai-badges")
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Serializers for converting internal badge session data to Open Badges 3.0 format.
3+
4+
The internal badge entry stored in ``AIWorkflowSession.metadata['badges']``
5+
uses a mix of snake_case keys and a structure tailored to the LLM pipeline.
6+
Before the ``BADGE_GENERATION`` event is emitted, the payload is normalised to
7+
a valid **OpenBadgeCredential** (OB 3.0 / W3C VC) document so that consumers
8+
can process a standards-compliant structure without any knowledge of the
9+
internal representation.
10+
11+
Reference spec:
12+
https://1edtech.github.io/openbadges-specification/ob_v3p0.html
13+
"""
14+
import uuid as _uuid
15+
16+
# OB 3.0 JSON-LD contexts
17+
_OB3_CONTEXTS = [
18+
"https://www.w3.org/ns/credentials/v2",
19+
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json",
20+
]
21+
22+
23+
def to_open_badge_credential(badge_info: dict) -> dict:
24+
"""
25+
Serialize a session badge entry to an Open Badges 3.0 ``OpenBadgeCredential``.
26+
27+
Only the fields that map cleanly onto the OB 3.0 schema are included.
28+
Internal fields such as ``course_context``, ``badge_configuration``, or
29+
``enable_skill_extraction`` are intentionally omitted from the event
30+
payload.
31+
32+
Args:
33+
badge_info (dict): Full session badge entry as stored in
34+
``AIWorkflowSession.metadata['badges']``. Expected keys::
35+
36+
{
37+
"id": str, # internal UUID
38+
"status": str,
39+
"created_at": str, # ISO-8601
40+
"generated_response": {
41+
"credentialSubject" | "credential_subject": {
42+
"achievement": {
43+
"name": str,
44+
"description": str,
45+
"criteria": {"narrative": str}
46+
}
47+
},
48+
"skills": [ # optional
49+
{
50+
"type": str,
51+
"targetName": str,
52+
"targetType": str,
53+
"targetUrl": str,
54+
"targetDescription": str # optional
55+
}
56+
]
57+
},
58+
"image": {
59+
"id": str, # URL of the badge image
60+
}
61+
}
62+
63+
Returns:
64+
dict: An ``OpenBadgeCredential`` document::
65+
66+
{
67+
"@context": [...],
68+
"id": "urn:uuid:<badge_id>",
69+
"type": ["VerifiableCredential", "OpenBadgeCredential"],
70+
"name": "<achievement name>",
71+
"image": {...}, # present only when a badge image exists
72+
"validFrom": "<ISO-8601 timestamp>",
73+
"issuer": {...},
74+
"credentialSubject": {
75+
"type": ["AchievementSubject"],
76+
"achievement": {
77+
"id": "urn:uuid:<achievement_id>",
78+
"type": ["Achievement"],
79+
"name": "<name>",
80+
"description": "<description>",
81+
"criteria": {"narrative": "<narrative>"},
82+
"alignment": [...], # present only when skills exist
83+
"image": {...}
84+
}
85+
}
86+
}
87+
"""
88+
generated = badge_info.get('generated_response') or {}
89+
90+
subject_data = (
91+
generated.get('credentialSubject')
92+
or generated.get('credential_subject')
93+
or {}
94+
)
95+
achievement_data = subject_data.get('achievement', {})
96+
criteria_data = achievement_data.get('criteria', {})
97+
98+
badge_id = badge_info.get('id') or str(_uuid.uuid4())
99+
valid_from = badge_info.get('created_at', '')
100+
achievement_name = achievement_data.get('name', '')
101+
102+
# Build Achievement node
103+
achievement: dict = {
104+
"id": f"urn:uuid:{str(_uuid.uuid4())}",
105+
"type": ["Achievement"],
106+
"name": achievement_name,
107+
"description": achievement_data.get('description', ''),
108+
"criteria": {
109+
"narrative": criteria_data.get('narrative', ''),
110+
},
111+
}
112+
113+
skills = generated.get('skills') or []
114+
if skills:
115+
alignment = []
116+
for skill in skills:
117+
entry: dict = {
118+
"type": ["Alignment"],
119+
"targetName": skill.get('target_name', ''),
120+
"targetUrl": skill.get('target_url', ''),
121+
"targetType": skill.get('target_type', ''),
122+
}
123+
target_description = skill.get('target_description')
124+
if target_description:
125+
entry["targetDescription"] = target_description
126+
alignment.append(entry)
127+
achievement["alignment"] = alignment
128+
129+
badge_image = badge_info.get('image') or {}
130+
achievement["image"] = {
131+
"type": "Image",
132+
"id": badge_image.get('id', ''),
133+
"caption": achievement_name,
134+
}
135+
136+
issuer = {
137+
"id": "urn:uuid:issuer-" + str(_uuid.uuid4()),
138+
"type": "Profile",
139+
"name": badge_info.get('organization', 'Unknown Organization'),
140+
}
141+
142+
credential: dict = {
143+
"@context": _OB3_CONTEXTS,
144+
"id": f"urn:uuid:{badge_id}",
145+
"type": ["VerifiableCredential", "OpenBadgeCredential"],
146+
"name": achievement_name,
147+
"image": achievement.get("image"),
148+
"credentialSubject": {
149+
"id": f"urn:uuid:{str(_uuid.uuid4())}",
150+
"type": ["AchievementSubject"],
151+
"achievement": achievement,
152+
},
153+
"issuer": issuer,
154+
}
155+
156+
if valid_from:
157+
credential["validFrom"] = valid_from
158+
159+
return credential
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Local signal definitions for openedx-ai-badges.
3+
4+
These follow the OEP-41 event naming convention and are intended to be
5+
contributed to openedx/openedx-events once the API is stabilised.
6+
7+
Once accepted upstream, replace this file's usage with:
8+
from openedx_events.learning.signals import BADGE_GENERATION
9+
"""
10+
from openedx_events.tooling import OpenEdxPublicSignal
11+
12+
from openedx_ai_badges.events.data import BadgeGenerationData
13+
14+
# .. event_type: org.openedx.content_authoring.badge.generation.v1
15+
# .. event_name: BADGE_GENERATION
16+
# .. event_description: Emitted when an AI-generated badge definition is published
17+
# for a course. Downstream systems (e.g. Credentials → Credly)
18+
# consume this event to create the corresponding badge class.
19+
# NOTE: This event carries the badge *template* only — no user (learner)
20+
# data. Learner-specific awarding is handled separately via
21+
# ``BADGE_AWARDED`` (openedx_events.learning.signals).
22+
# .. event_data: BadgeGenerationData
23+
# .. event_trigger_repository: eduNEXT/openedx-ai-badges
24+
# .. event_warning: Local event pending contribution to openedx/openedx-events.
25+
BADGE_GENERATION = OpenEdxPublicSignal(
26+
event_type="org.openedx.content_authoring.badge.generation.v1",
27+
data={
28+
"badge_generation": BadgeGenerationData,
29+
},
30+
)

0 commit comments

Comments
 (0)