Spaces:
Sleeping
Sleeping
File size: 21,398 Bytes
8c21c91 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 | """
CO β SDG Mapping API
OBEP ERP | NBA / NAAC / ABET Compliance Module
HuggingFace Spaces β FastAPI + Docker
Endpoints:
POST /map Map a single CO to SDGs
POST /batch Map multiple COs in one call
POST /feedback Save faculty correction for retraining
GET /sdgs All 17 SDGs as structured JSON
GET /sdgs/{key} Single SDG detail
GET /sdg-wheel SDG wheel data for UI rendering
GET /health Health + stats
GET /docs Swagger UI (auto)
"""
import json
import os
import pickle
import uuid
import numpy as np
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from fastapi import FastAPI, HTTPException, Security, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security.api_key import APIKeyHeader
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# CONFIG
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Set your API key as a HuggingFace Space secret named: API_KEY
# In HF Spaces: Settings β Variables and Secrets β New Secret β API_KEY
# If no secret is set, auth is disabled (useful for testing)
API_KEY_SECRET = os.environ.get("API_KEY", None)
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
FEEDBACK_LOG_PATH = Path("feedback_log.json")
MODEL_PATH = "finetuned_model" if Path("finetuned_model").exists() else "all-mpnet-base-v2"
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# AUTH
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def verify_api_key(api_key: str = Security(API_KEY_HEADER)):
"""
Validates X-API-Key header.
If no API_KEY secret is configured in HF Spaces, auth is skipped.
"""
if API_KEY_SECRET is None:
return True # auth disabled
if api_key == API_KEY_SECRET:
return True
raise HTTPException(
status_code=403,
detail="Invalid or missing API key. Pass it as header: X-API-Key: <your-key>"
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# APP INIT
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
app = FastAPI(
title="CO β SDG Mapping API",
description=(
"Maps Course Outcome (CO) statements to UN Sustainable Development Goals "
"using semantic similarity. Generates formal NBA/NAAC/ABET-ready justifications.\n\n"
"**Auth:** Pass your API key as `X-API-Key` header.\n\n"
"**Data source:** UN Global Indicator Framework, Post-2025 Review (A/RES/71/313)"
),
version="2.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# LOAD ARTIFACTS ON STARTUP
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
print(f"Loading model from: {MODEL_PATH}")
model = SentenceTransformer(MODEL_PATH)
indicator_embeddings = np.load("indicator_embeddings.npy")
with open("indicator_corpus.pkl", "rb") as f:
indicator_corpus = pickle.load(f)
with open("sdg_data.json", "r") as f:
SDG_DATA = json.load(f)
# Build SDG β indicators lookup for feedback endpoint
SDG_IND_MAP = {}
for entry in indicator_corpus:
SDG_IND_MAP.setdefault(entry["sdg_key"], []).append(entry["embedding_text"])
print(f"Ready β {len(indicator_corpus)} indicators | {len(SDG_DATA)} SDGs | model: {MODEL_PATH}")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# PYDANTIC MODELS
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class FacultyOverride(BaseModel):
sdg_key: str = Field(..., example="SDG4", description="e.g. SDG1 through SDG17")
reason: Optional[str] = Field("Not specified", example="Course offered under Dept of Education")
class MapRequest(BaseModel):
course_outcome: str = Field(
...,
min_length=10,
max_length=2000,
example="Students will be able to design energy-efficient IoT systems for smart cities using renewable energy sources."
)
faculty_override: Optional[FacultyOverride] = None
class BatchMapRequest(BaseModel):
course_outcomes: List[str] = Field(
...,
min_items=1,
max_items=50,
example=[
"Students will design sustainable water treatment systems.",
"Students will analyse ICT skills for employment in digital industries."
]
)
faculty_overrides: Optional[List[Optional[FacultyOverride]]] = Field(
None,
description="Optional list of overrides, one per CO. Use null for auto-mapping."
)
class FeedbackRequest(BaseModel):
co_text: str = Field(..., example="Students will design wastewater treatment systems.")
correct_sdg_key: str = Field(..., example="SDG6")
incorrect_sdg_key: Optional[str] = Field(None, example="SDG3")
accepted: bool = Field(True, description="True if faculty accepted ML result, False if overridden")
faculty_name: Optional[str] = Field(None, example="Dr. Sharma")
course_code: Optional[str] = Field(None, example="CE401")
class MappedSDG(BaseModel):
goal: str
confidence_score: float
confidence_level: str
confidence_explanation: str
class MappingResult(BaseModel):
course_outcome: str
mapped_sdg: MappedSDG
mapped_targets: List[str]
mapped_indicators: List[str]
secondary_sdgs: List[dict]
justification: str
override_applied: bool
override_note: Optional[str]
request_id: str
class BatchMappingResult(BaseModel):
results: List[MappingResult]
total: int
processed_at: str
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# CORE LOGIC
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def compute_mapping(co_text: str, top_k: int = 10) -> dict:
co_emb = model.encode([co_text], normalize_embeddings=True)[0]
sims = np.dot(indicator_embeddings, co_emb)
top_idx = np.argsort(sims)[::-1][:top_k]
top_inds = [dict(indicator_corpus[i], score=float(sims[i])) for i in top_idx]
sdg_scores, sdg_inds = {}, {}
for ind in top_inds:
k = ind["sdg_key"]
sdg_scores.setdefault(k, []).append(ind["score"])
sdg_inds.setdefault(k, []).append(ind)
def agg(scores):
s = sorted(scores, reverse=True)
return 0.6 * s[0] + 0.4 * np.mean(s[:3])
ranked = sorted({k: agg(v) for k, v in sdg_scores.items()}.items(),
key=lambda x: x[1], reverse=True)
pk = ranked[0][0]
ps = ranked[0][1]
pinds = sorted(sdg_inds[pk], key=lambda x: x["score"], reverse=True)
targets = {}
for ind in pinds:
targets.setdefault(ind["target_code"], ind["target_text"])
secondary = []
for sk, score in ranked[1:3]:
if sk in sdg_inds:
best = max(sdg_inds[sk], key=lambda x: x["score"])
secondary.append({
"goal": SDG_DATA[sk]["goal"],
"sdg_key": sk,
"confidence_score": round(score, 4),
"top_indicator": f"{best['indicator_code']} β {best['indicator_text']}"
})
return {
"primary_key": pk,
"primary_score": round(ps, 4),
"primary_inds": pinds,
"matched_targets": targets,
"secondary": secondary
}
def build_justification(co_text: str, goal: str, score: float,
targets: dict, inds: list, secondary: list) -> str:
phrase = (
"demonstrates a strong and direct alignment" if score >= 0.75 else
"exhibits a meaningful and substantive alignment" if score >= 0.60 else
"demonstrates a moderate alignment" if score >= 0.45 else
"shows a partial alignment"
)
target_str = ", ".join(targets.keys())
ind_refs = "; ".join([
f"Indicator {i['indicator_code']} ({i['indicator_text']})"
for i in inds[:3]
])
sec_note = ""
if secondary:
sec_str = " and ".join(s["goal"] for s in secondary)
sec_note = (f" Secondary alignment was also observed with {sec_str}, "
f"reflecting the interdisciplinary nature of this course outcome.")
return (
f"The course outcome β '{co_text}' β {phrase} with {goal} "
f"(semantic similarity score: {score:.2f}). "
f"Specifically, this outcome maps to Target(s) {target_str} of the UN 2030 Agenda, "
f"as evidenced by its correspondence with the following official UN SDG indicators: "
f"{ind_refs}. "
f"The pedagogical intent and measurable competencies embedded in this course outcome "
f"directly contribute to the attainment of the aforementioned global development targets, "
f"thereby reinforcing the institution's commitment to outcome-based education "
f"aligned with internationally recognized sustainability frameworks."
f"{sec_note}"
)
def get_confidence(score: float):
if score >= 0.75:
return "HIGH", "CO vocabulary closely mirrors official SDG indicator language."
if score >= 0.60:
return "MODERATE-HIGH", "CO aligns well with SDG indicators at the conceptual and thematic level."
if score >= 0.45:
return "MODERATE", "Partial semantic overlap detected. Faculty review recommended."
return "LOW", "Weak alignment detected. Faculty override strongly advised."
def run_full_mapping(co_text: str, override: Optional[FacultyOverride] = None) -> MappingResult:
mapping = compute_mapping(co_text)
pk = mapping["primary_key"]
score = mapping["primary_score"]
inds = mapping["primary_inds"]
targets = mapping["matched_targets"]
secondary = mapping["secondary"]
override_applied = False
override_note = None
if override:
key = override.sdg_key.upper()
if key not in SDG_DATA:
raise HTTPException(
status_code=400,
detail=f"Invalid sdg_key '{override.sdg_key}'. Valid: SDG1βSDG17."
)
original = SDG_DATA[pk]["goal"]
pk = key
override_applied = True
override_note = (
f"Faculty Override Applied | ML suggested: {original} | "
f"Override: {SDG_DATA[pk]['goal']} | Reason: {override.reason}"
)
goal = SDG_DATA[pk]["goal"]
level, explanation = get_confidence(score)
return MappingResult(
course_outcome=co_text,
mapped_sdg=MappedSDG(
goal=goal,
confidence_score=score,
confidence_level=level,
confidence_explanation=explanation
),
mapped_targets=list(targets.keys()),
mapped_indicators=[f"{i['indicator_code']} β {i['indicator_text']}" for i in inds[:3]],
secondary_sdgs=secondary,
justification=build_justification(co_text, goal, score, targets, inds, secondary),
override_applied=override_applied,
override_note=override_note,
request_id=str(uuid.uuid4())[:8]
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ENDPOINTS
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@app.post(
"/map",
response_model=MappingResult,
tags=["Mapping"],
summary="Map a single Course Outcome to UN SDGs"
)
async def map_single(
request: MapRequest,
_: bool = Depends(verify_api_key)
):
"""
Maps one CO statement to the most relevant SDG, targets, and indicators.
Returns a formal, accreditation-ready justification.
Optionally accepts a `faculty_override` to document manual SDG selection
with a full audit trail (for NBA/NAAC SAR documentation).
"""
return run_full_mapping(request.course_outcome, request.faculty_override)
@app.post(
"/batch",
response_model=BatchMappingResult,
tags=["Mapping"],
summary="Map multiple Course Outcomes in one API call"
)
async def map_batch(
request: BatchMapRequest,
_: bool = Depends(verify_api_key)
):
"""
Maps up to 50 COs in a single request.
Ideal for bulk processing when uploading a course plan or curriculum.
`faculty_overrides` is a list parallel to `course_outcomes`.
Use `null` for any CO you want auto-mapped.
"""
overrides = request.faculty_overrides or [None] * len(request.course_outcomes)
if len(overrides) != len(request.course_outcomes):
raise HTTPException(
status_code=400,
detail="Length of faculty_overrides must match length of course_outcomes."
)
results = []
for co, override in zip(request.course_outcomes, overrides):
result = run_full_mapping(co.strip(), override)
results.append(result)
return BatchMappingResult(
results=results,
total=len(results),
processed_at=datetime.utcnow().isoformat() + "Z"
)
@app.post(
"/feedback",
tags=["Feedback"],
summary="Submit faculty correction for model retraining"
)
async def submit_feedback(
request: FeedbackRequest,
_: bool = Depends(verify_api_key)
):
"""
Records a faculty correction or acceptance.
- **accepted=true** β ML was correct, faculty approved
- **accepted=false** β Faculty overrode the ML result
These entries are saved to `feedback_log.json` and used
in the next Colab retraining run to improve the model.
"""
if request.correct_sdg_key.upper() not in SDG_DATA:
raise HTTPException(
status_code=400,
detail=f"Invalid correct_sdg_key '{request.correct_sdg_key}'. Valid: SDG1βSDG17."
)
entry = {
"id": str(uuid.uuid4()),
"co_text": request.co_text,
"correct_sdg_key": request.correct_sdg_key.upper(),
"incorrect_sdg_key": request.incorrect_sdg_key.upper() if request.incorrect_sdg_key else None,
"accepted": request.accepted,
"faculty_name": request.faculty_name,
"course_code": request.course_code,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
existing = []
if FEEDBACK_LOG_PATH.exists():
with open(FEEDBACK_LOG_PATH) as f:
existing = json.load(f)
existing.append(entry)
with open(FEEDBACK_LOG_PATH, "w") as f:
json.dump(existing, f, indent=2)
return {
"status": "saved",
"feedback_id": entry["id"],
"total_feedback_entries": len(existing),
"message": "Feedback saved. Will be used in next model retraining cycle."
}
@app.get(
"/feedback/stats",
tags=["Feedback"],
summary="Get feedback statistics"
)
async def feedback_stats(_: bool = Depends(verify_api_key)):
"""Returns counts of accepted vs overridden mappings per SDG."""
if not FEEDBACK_LOG_PATH.exists():
return {"total": 0, "accepted": 0, "overridden": 0, "by_sdg": {}}
with open(FEEDBACK_LOG_PATH) as f:
entries = json.load(f)
by_sdg = {}
accepted = sum(1 for e in entries if e.get("accepted"))
overridden = sum(1 for e in entries if not e.get("accepted"))
for e in entries:
key = e.get("correct_sdg_key", "UNKNOWN")
by_sdg.setdefault(key, {"accepted": 0, "overridden": 0})
if e.get("accepted"):
by_sdg[key]["accepted"] += 1
else:
by_sdg[key]["overridden"] += 1
return {
"total": len(entries),
"accepted": accepted,
"overridden": overridden,
"acceptance_rate": round(accepted / len(entries) * 100, 1) if entries else 0,
"by_sdg": by_sdg
}
@app.get(
"/sdgs",
tags=["Reference"],
summary="List all 17 UN SDGs"
)
async def list_sdgs():
"""
Returns all 17 SDGs with goal name, description, and counts.
No auth required β use this to populate dropdowns, filters, etc.
"""
return {
sdg_key: {
"goal": val["goal"],
"description": val["description"],
"num_targets": len(val["targets"]),
"num_indicators": sum(len(t["indicators"]) for t in val["targets"].values())
}
for sdg_key, val in SDG_DATA.items()
}
@app.get(
"/sdgs/{sdg_key}",
tags=["Reference"],
summary="Get full detail for one SDG"
)
async def get_sdg(sdg_key: str):
"""
Returns the complete target and indicator list for a single SDG.
Use sdg_key like: SDG1, SDG4, SDG7, SDG13, etc.
"""
key = sdg_key.upper()
if key not in SDG_DATA:
raise HTTPException(status_code=404, detail=f"'{sdg_key}' not found. Valid: SDG1βSDG17.")
return SDG_DATA[key]
@app.get(
"/sdg-wheel",
tags=["Reference"],
summary="SDG wheel data for UI rendering"
)
async def sdg_wheel():
"""
Returns all 17 SDGs in a format optimised for rendering a
visual SDG wheel or colour-coded grid in your React/Next.js app.
Each SDG includes the official UN colour code so you can
match the standard SDG colour scheme in your UI.
"""
# Official UN SDG colours
sdg_colors = {
"SDG1": "#E5243B", "SDG2": "#DDA63A", "SDG3": "#4C9F38",
"SDG4": "#C5192D", "SDG5": "#FF3A21", "SDG6": "#26BDE2",
"SDG7": "#FCC30B", "SDG8": "#A21942", "SDG9": "#FD6925",
"SDG10": "#DD1367", "SDG11": "#FD9D24", "SDG12": "#BF8B2E",
"SDG13": "#3F7E44", "SDG14": "#0A97D9", "SDG15": "#56C02B",
"SDG16": "#00689D", "SDG17": "#19486A",
}
sdg_numbers = {f"SDG{i}": i for i in range(1, 18)}
sdg_short_names = {
"SDG1": "No Poverty", "SDG2": "Zero Hunger",
"SDG3": "Good Health", "SDG4": "Quality Education",
"SDG5": "Gender Equality", "SDG6": "Clean Water",
"SDG7": "Clean Energy", "SDG8": "Decent Work",
"SDG9": "Innovation", "SDG10": "Reduced Inequalities",
"SDG11": "Sustainable Cities", "SDG12": "Responsible Consumption",
"SDG13": "Climate Action", "SDG14": "Life Below Water",
"SDG15": "Life on Land", "SDG16": "Peace & Justice",
"SDG17": "Partnerships",
}
return [
{
"sdg_key": key,
"number": sdg_numbers[key],
"short_name": sdg_short_names[key],
"full_name": SDG_DATA[key]["goal"],
"description": SDG_DATA[key]["description"],
"color": sdg_colors[key],
"num_targets": len(SDG_DATA[key]["targets"]),
}
for key in SDG_DATA.keys()
]
@app.get(
"/health",
tags=["System"],
summary="Health check"
)
async def health():
"""Returns model status, indicator counts, and feedback stats."""
feedback_count = 0
if FEEDBACK_LOG_PATH.exists():
with open(FEEDBACK_LOG_PATH) as f:
feedback_count = len(json.load(f))
return {
"status": "healthy",
"model": MODEL_PATH,
"sdgs": len(SDG_DATA),
"indicators": len(indicator_corpus),
"embeddings_shape": list(indicator_embeddings.shape),
"feedback_entries": feedback_count,
"auth_enabled": API_KEY_SECRET is not None,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ENTRY POINT
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)
|