sdg_indicator / app.py
Jrine's picture
deploy v2
8c21c91
Raw
History Blame Contribute Delete
21.4 kB
"""
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)