Skip to content

Commit a29d65b

Browse files
authored
Merge pull request #28 from eduNEXT/fmo/laiser
feat: using the laiser API to create skills
2 parents 13cc091 + c0776cf commit a29d65b

8 files changed

Lines changed: 466 additions & 3 deletions

File tree

backend/openedx_ai_badges/processors/badge_processor.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
"""
44
import json
55
import logging
6+
import time
67
from pathlib import Path
78

9+
import requests
10+
from django.conf import settings
811
from openedx_ai_extensions.processors import LLMProcessor
912

1013
logger = logging.getLogger(__name__)
@@ -97,3 +100,105 @@ def generate_skills(self):
97100
prompt = self.fill_prompt(prompt)
98101
result = self._call_completion_wrapper(system_role=prompt)
99102
return result
103+
104+
def generate_skills_laiser_api(self):
105+
"""
106+
Submit course context to the LAiSER API and poll for extracted skills.
107+
108+
Resolves base_url and api_key from processor config or Django settings.
109+
POSTs context to /laiser, polls /result until a terminal state, then
110+
normalizes the result array into the internal skills alignment format.
111+
"""
112+
base_url = (self.config.get("base_url") or getattr(settings, "LAISER_API_BASE_URL", "")).rstrip("/")
113+
api_key = self.config.get("api_key") or getattr(settings, "LAISER_API_KEY", "")
114+
115+
if not base_url or not api_key:
116+
logger.error("LAiSER API is not configured. Check LAISER_API_BASE_URL or LAISER_API_KEY")
117+
return {"error": "LAiSER API incorrectly configured"}
118+
119+
try:
120+
submit_response = requests.post(
121+
f"{base_url}/laiser",
122+
json={"inputText": self.context},
123+
headers={"x-api-key": api_key, "Content-Type": "application/json"},
124+
timeout=30,
125+
)
126+
submit_response.raise_for_status()
127+
submit_data = submit_response.json()
128+
except requests.exceptions.RequestException as exc:
129+
logger.error("LAiSER API submit failed: %s", exc)
130+
return {"error": str(exc)}
131+
except ValueError as exc:
132+
logger.error("LAiSER API submit returned non-JSON: %s", exc)
133+
return {"error": f"Invalid JSON from LAiSER submit: {exc}"}
134+
135+
job_id = submit_data.get("jobId")
136+
if not job_id:
137+
logger.error("LAiSER API submit response missing jobId: %s", submit_data)
138+
return {"error": "No jobId in LAiSER submit response"}
139+
140+
result = self._poll_laiser_job(base_url, api_key, job_id)
141+
if "error" in result:
142+
return result
143+
144+
skills = [self._normalize_laiser_skill(s) for s in result.get("result", [])]
145+
return {"response": json.dumps({"skills": skills}), "status": "success"}
146+
147+
def _poll_laiser_job(self, base_url, api_key, job_id):
148+
"""Poll GET /result until the job reaches a terminal state or timeout."""
149+
timeout = getattr(settings, "LAISER_API_TIMEOUT_SECONDS", 90)
150+
poll_interval = getattr(settings, "LAISER_API_POLL_INTERVAL_SECONDS", 2)
151+
elapsed = 0
152+
while elapsed < timeout:
153+
time.sleep(poll_interval)
154+
elapsed += poll_interval
155+
156+
try:
157+
response = requests.get(
158+
f"{base_url}/result",
159+
params={"jobId": job_id},
160+
headers={"x-api-key": api_key},
161+
timeout=30,
162+
)
163+
response.raise_for_status()
164+
data = response.json()
165+
except requests.exceptions.RequestException as exc:
166+
logger.error("LAiSER API poll failed (jobId=%s): %s", job_id, exc)
167+
return {"error": str(exc)}
168+
except ValueError as exc:
169+
logger.error("LAiSER API poll returned non-JSON (jobId=%s): %s", job_id, exc)
170+
return {"error": f"Invalid JSON from LAiSER poll: {exc}"}
171+
172+
status = data.get("status")
173+
if status in ("QUEUED", "RUNNING"):
174+
continue
175+
176+
if status != "SUCCEEDED":
177+
logger.error("LAiSER API job terminal failure (jobId=%s): status=%s", job_id, status)
178+
return {"error": f"LAiSER job failed with status: {status}"}
179+
180+
return data
181+
182+
logger.error("LAiSER API timed out after %ds, jobId=%s", timeout, job_id)
183+
return {"error": f"LAiSER API timed out after {timeout} seconds"}
184+
185+
@staticmethod
186+
def _normalize_laiser_skill(skill: dict) -> dict:
187+
"""Map a LAiSER API result entry to the internal skills alignment format."""
188+
source = (skill.get("Taxonomy Source") or "").lower()
189+
target_type_map = {
190+
"esco": "ESCO:Skill",
191+
"ukos": "UKOS:Skill",
192+
"onet_tech": "ONET:Skill",
193+
}
194+
return {
195+
"type": "Alignment",
196+
"skill_tag": skill.get("Raw Skill", ""),
197+
"target_name": skill.get("Taxonomy Skill", ""),
198+
"target_description": skill.get("Taxonomy Description", ""),
199+
"target_url": skill.get("Source URL", ""),
200+
"target_type": target_type_map.get(source, source),
201+
"correlation_coefficient": skill.get("Correlation Coefficient", 0),
202+
"task_abilities": [],
203+
"knowledge_required": [],
204+
}

backend/openedx_ai_badges/settings/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@ def plugin_settings(settings):
4545
settings.MIT_SLM_OLLAMA_URL = ""
4646
settings.MIT_SLM_OLLAMA_TOKEN = ""
4747
settings.MIT_DCC_BADGE_API_HEALTH_URL = "http://mit-slm:8000/health"
48+
49+
# -------------------------
50+
# LAiSER API
51+
# -------------------------
52+
settings.LAISER_API_BASE_URL = ""
53+
settings.LAISER_API_KEY = ""
54+
settings.LAISER_API_TIMEOUT_SECONDS = 90
55+
settings.LAISER_API_POLL_INTERVAL_SECONDS = 2

backend/openedx_ai_badges/settings/production.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,20 @@ def plugin_settings(settings):
4242
settings.OPENEDX_AI_BADGES_MAX_IMAGE_SIZE_BYTES = settings.ENV_TOKENS.get(
4343
"OPENEDX_AI_BADGES_MAX_IMAGE_SIZE_BYTES", settings.OPENEDX_AI_BADGES_MAX_IMAGE_SIZE_BYTES
4444
)
45+
46+
# -------------------------
47+
# LAiSER API
48+
# -------------------------
49+
if hasattr(settings, "ENV_TOKENS"):
50+
settings.LAISER_API_BASE_URL = settings.ENV_TOKENS.get(
51+
"LAISER_API_BASE_URL", settings.LAISER_API_BASE_URL
52+
)
53+
settings.LAISER_API_KEY = settings.ENV_TOKENS.get(
54+
"LAISER_API_KEY", settings.LAISER_API_KEY
55+
)
56+
settings.LAISER_API_TIMEOUT_SECONDS = settings.ENV_TOKENS.get(
57+
"LAISER_API_TIMEOUT_SECONDS", settings.LAISER_API_TIMEOUT_SECONDS
58+
)
59+
settings.LAISER_API_POLL_INTERVAL_SECONDS = settings.ENV_TOKENS.get(
60+
"LAISER_API_POLL_INTERVAL_SECONDS", settings.LAISER_API_POLL_INTERVAL_SECONDS
61+
)

backend/openedx_ai_badges/workflows/orchestrators.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ def regenerate(self, input_data):
263263
}
264264

265265
if skills_requested:
266-
self._set_status_message("Generating skills alignment...")
266+
skills_fn = self.profile.processor_config.get("SkillsProcessor", {}).get("function", "generate_skills")
267+
self._set_status_message(f'Generating skills alignment using "{skills_fn}"...')
267268
skills = self._get_skills(course_context, input_data, regenerate=True)
268269
if isinstance(skills, dict) and 'error' in skills:
269270
return skills
@@ -471,7 +472,8 @@ def run(self, input_data):
471472
}
472473

473474
if skills_enabled:
474-
self._set_status_message("Generating skills alignment...")
475+
skills_fn = self.profile.processor_config.get("SkillsProcessor", {}).get("function", "generate_skills")
476+
self._set_status_message(f'Generating skills alignment using "{skills_fn}"...')
475477
skills = self._get_skills(course_context, input_data)
476478
if isinstance(skills, dict) and 'error' in skills:
477479
return skills

backend/openedx_ai_badges/workflows/profiles/badges_base.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
"provider": "openai",
1111
},
1212
"SkillsProcessor": {
13+
// "function": "generate_skills_laiser_api", // make sure to configure the api key and remove the provider
1314
"function": "generate_skills",
14-
"provider": "openai",
15+
"provider": "openai"
1516
}
1617
},
1718
"actuator_config": {

0 commit comments

Comments
 (0)