Spaces:
Running
Running
| """ | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| 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" | |
| ) | |
| 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." | |
| } | |
| 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 | |
| } | |
| 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() | |
| } | |
| 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] | |
| 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() | |
| ] | |
| 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) | |