| |
| """ |
| PLRS β Logic Engine v0.2.0 |
| HuggingFace Space entry point (self-contained). |
| |
| Loads all assets from HuggingFace Hub (Clementio/PLRS). |
| Works without local files β everything is downloaded on first run. |
| """ |
|
|
| import json |
| import sys |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| import streamlit as st |
| import torch |
| import torch.nn as nn |
| import networkx as nx |
|
|
| |
| from huggingface_hub import hf_hub_download |
|
|
| HF_REPO = "Clementio/PLRS" |
|
|
|
|
| def _hub_download(filename: str) -> str: |
| """Download a file from the HF repo, cached.""" |
| return hf_hub_download(repo_id=HF_REPO, filename=filename) |
|
|
|
|
| |
| |
| |
| class SAKTModel(nn.Module): |
| """Self-Attentive Knowledge Tracing (SAKT) β PyTorch implementation.""" |
|
|
| def __init__( |
| self, |
| num_skills: int, |
| embed_dim: int = 128, |
| num_heads: int = 8, |
| num_layers: int = 2, |
| max_seq_len: int = 200, |
| dropout: float = 0.2, |
| ): |
| super().__init__() |
| self.num_skills = num_skills |
| self.interaction_embed = nn.Embedding(num_skills * 2 + 1, embed_dim, padding_idx=0) |
| self.skill_embed = nn.Embedding(num_skills + 1, embed_dim, padding_idx=0) |
| self.pos_embed = nn.Embedding(max_seq_len + 1, embed_dim) |
|
|
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=embed_dim, |
| nhead=num_heads, |
| dropout=dropout, |
| batch_first=True, |
| dim_feedforward=embed_dim * 4, |
| norm_first=True, |
| ) |
| self.transformer = nn.TransformerEncoder( |
| encoder_layer, num_layers=num_layers, enable_nested_tensor=False |
| ) |
| self.dropout = nn.Dropout(dropout) |
| self.output = nn.Linear(embed_dim, 1) |
|
|
| def forward(self, interactions, target_skills, mask): |
| batch_size, seq_len = interactions.shape |
| positions = torch.arange(seq_len, device=interactions.device).unsqueeze(0).expand(batch_size, -1) |
| x = self.interaction_embed(interactions) |
| x = x + self.pos_embed(positions) |
| x = x * mask.unsqueeze(-1).float() |
| x = self.dropout(x) |
| causal_mask = torch.triu(torch.full((seq_len, seq_len), float("-inf"), device=interactions.device), diagonal=1) |
| x = self.transformer(x, mask=causal_mask, is_causal=False) |
| x = x * mask.unsqueeze(-1).float() |
| x = x + self.skill_embed(target_skills) |
| return self.output(x).squeeze(-1) |
|
|
|
|
| |
| |
| |
| class SAKTWithDecay(nn.Module): |
| """SAKT with time-decay attention (v0.2.0).""" |
|
|
| def __init__( |
| self, |
| num_skills: int, |
| embed_dim: int = 128, |
| num_heads: int = 8, |
| num_layers: int = 2, |
| max_seq_len: int = 200, |
| dropout: float = 0.2, |
| decay_init: float = 1.0, |
| ): |
| super().__init__() |
| self.num_skills = num_skills |
| self.embed_dim = embed_dim |
| self.decay = nn.Parameter(torch.tensor(decay_init)) |
| self.interaction_embed = nn.Embedding(num_skills * 2 + 1, embed_dim, padding_idx=0) |
| self.skill_embed = nn.Embedding(num_skills + 1, embed_dim, padding_idx=0) |
| self.pos_embed = nn.Embedding(max_seq_len + 1, embed_dim) |
|
|
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=embed_dim, |
| nhead=num_heads, |
| dropout=dropout, |
| batch_first=True, |
| dim_feedforward=embed_dim * 4, |
| norm_first=True, |
| ) |
| self.transformer = nn.TransformerEncoder( |
| encoder_layer, num_layers=num_layers, enable_nested_tensor=False |
| ) |
| self.dropout = nn.Dropout(dropout) |
| self.output = nn.Linear(embed_dim, 1) |
|
|
| def forward(self, interactions, target_skills, mask): |
| batch_size, seq_len = interactions.shape |
| positions = torch.arange(seq_len, device=interactions.device).unsqueeze(0).expand(batch_size, -1) |
| x = self.interaction_embed(interactions) |
| x = x + self.pos_embed(positions) |
| |
| decay_weights = torch.exp(-self.decay * torch.arange(seq_len, device=interactions.device).float()) |
| x = x * decay_weights.view(1, seq_len, 1) |
| x = x * mask.unsqueeze(-1).float() |
| x = self.dropout(x) |
| causal_mask = torch.triu(torch.full((seq_len, seq_len), float("-inf"), device=interactions.device), diagonal=1) |
| x = self.transformer(x, mask=causal_mask, is_causal=False) |
| x = x * mask.unsqueeze(-1).float() |
| x = x + self.skill_embed(target_skills) |
| return self.output(x).squeeze(-1) |
|
|
|
|
| |
| |
| |
| class Curriculum: |
| """Wrapper around a NetworkX DiGraph for prerequisite knowledge maps.""" |
|
|
| def __init__(self, graph: nx.DiGraph, domain: str = ""): |
| self._g = graph |
| self.domain = domain |
|
|
| @property |
| def nodes(self) -> List[str]: |
| return list(self._g.nodes()) |
|
|
| @property |
| def num_nodes(self) -> int: |
| return self._g.number_of_nodes() |
|
|
| @property |
| def num_edges(self) -> int: |
| return self._g.number_of_edges() |
|
|
| def label(self, node: str) -> str: |
| return self._g.nodes[node].get("label", node) |
|
|
| def level(self, node: str) -> str: |
| return self._g.nodes[node].get("level", "") |
|
|
| def prerequisites(self, node: str) -> List[str]: |
| return list(self._g.predecessors(node)) |
|
|
| def successors(self, node: str) -> List[str]: |
| return list(self._g.successors(node)) |
|
|
| def descendants(self, node: str) -> List[str]: |
| return list(nx.descendants(self._g, node)) |
|
|
|
|
| def load_dag(path: str, domain: str = "") -> Curriculum: |
| with open(path) as f: |
| data = json.load(f) |
| g = nx.DiGraph() |
| for node in data["nodes"]: |
| g.add_node(node["id"], label=node.get("label", node["id"]), level=node.get("level", ""), term=node.get("term", "")) |
| for edge in data["edges"]: |
| g.add_edge(edge["from"], edge["to"]) |
| assert nx.is_directed_acyclic_graph(g), "Cycle detected in knowledge map!" |
| return Curriculum(g, domain=domain) |
|
|
|
|
| |
| |
| |
| class PLRSPipeline: |
| """Three-layer recommendation pipeline: SAKT β DAG constraints β Ranking.""" |
|
|
| def __init__(self, curriculum: Curriculum, threshold: float = 0.70, soft_threshold: float = 0.50, top_n: int = 5): |
| self.curriculum = curriculum |
| self.threshold = threshold |
| self.soft_threshold = soft_threshold |
| self.top_n = top_n |
| self._model = None |
|
|
| |
| scores = {n: len(self.curriculum.descendants(n)) for n in self.curriculum.nodes} |
| mx = max(scores.values()) if scores else 1 |
| self._downstream = {n: s / mx for n, s in scores.items()} |
|
|
| def recommend_from_mastery(self, mastery_scores: Dict[str, float]) -> Dict: |
| approved, challenging, vetoed = [], [], [] |
|
|
| for node in self.curriculum.nodes: |
| status, reasoning, unmet = self._validate(node, mastery_scores) |
| current = mastery_scores.get(node, 0.0) |
| entry = { |
| "topic_id": node, |
| "topic_label": self.curriculum.label(node), |
| "mastery": round(current, 3), |
| "reasoning": reasoning, |
| "status": status, |
| "unmet_prerequisites": unmet, |
| "downstream_count": len(self.curriculum.descendants(node)), |
| } |
|
|
| if status == "approved" and current < self.threshold: |
| entry["score"] = self._rank(node, mastery_scores) |
| approved.append(entry) |
| elif status == "challenging" and current < self.threshold: |
| entry["score"] = self._rank(node, mastery_scores) * 0.8 |
| challenging.append(entry) |
| elif status == "vetoed": |
| vetoed.append(entry) |
|
|
| approved.sort(key=lambda x: x["score"], reverse=True) |
| challenging.sort(key=lambda x: x["score"], reverse=True) |
|
|
| mastered = [n for n in self.curriculum.nodes if mastery_scores.get(n, 0.0) >= self.threshold] |
| return { |
| "approved": approved[: self.top_n], |
| "challenging": challenging[:3], |
| "vetoed": vetoed[:5], |
| "mastery_summary": { |
| "total_topics": self.curriculum.num_nodes, |
| "mastered": len(mastered), |
| "mastery_rate": round(len(mastered) / max(self.curriculum.num_nodes, 1), 3), |
| }, |
| "stats": { |
| "approved_count": len(approved), |
| "challenging_count": len(challenging), |
| "vetoed_count": len(vetoed), |
| "prerequisite_violation_rate": round(len(vetoed) / max(self.curriculum.num_nodes, 1), 3), |
| }, |
| } |
|
|
| def _validate(self, node: str, mastery: Dict[str, float]) -> Tuple[str, str, List[str]]: |
| prereqs = self.curriculum.prerequisites(node) |
| if not prereqs: |
| return "approved", "β
Foundational topic β no prerequisites.", [] |
|
|
| hard_fails = [] |
| soft_fails = [] |
| for p in prereqs: |
| m = mastery.get(p, 0.0) |
| if m < self.soft_threshold: |
| hard_fails.append(p) |
| elif m < self.threshold: |
| soft_fails.append(p) |
|
|
| if hard_fails: |
| labels = [self.curriculum.label(p) for p in hard_fails] |
| return "vetoed", f"β Prerequisites not met: {', '.join(labels)}", labels |
| elif soft_fails: |
| labels = [self.curriculum.label(p) for p in soft_fails] |
| return "challenging", f"β οΈ Challenging β prerequisites nearly met: {', '.join(labels)}", labels |
| else: |
| return "approved", "β
All prerequisites mastered.", [] |
|
|
| def _rank(self, node: str, mastery: Dict[str, float]) -> float: |
| current = mastery.get(node, 0.0) |
| gap = min(max(0.0, self.threshold - current) / self.threshold, 1.0) |
| prereqs = self.curriculum.prerequisites(node) |
| readiness = 1.0 if not prereqs else sum(1 for p in prereqs if mastery.get(p, 0.0) >= self.threshold) / len(prereqs) |
| downstream = self._downstream.get(node, 0.0) |
| boost = 0.0 |
| if 0.10 <= current < self.threshold: |
| boost = 0.15 * (current / self.threshold) |
| return round(0.40 * gap + 0.35 * readiness + 0.25 * downstream + boost, 3) |
|
|
| def what_if(self, node: str) -> Dict: |
| direct = [{"id": s, "label": self.curriculum.label(s)} for s in self.curriculum.successors(node)] |
| all_down = [{"id": d, "label": self.curriculum.label(d)} for d in self.curriculum.descendants(node)] |
| blocked = [{"id": p, "label": self.curriculum.label(p)} for p in self.curriculum.prerequisites(node)] |
| return { |
| "direct_unlocks": direct, |
| "all_unlocks": all_down, |
| "blocked_by": blocked, |
| "total_unlocked": len(all_down), |
| } |
|
|
|
|
| |
| |
| |
| def load_model_from_hub(device: str = "cpu"): |
| """ |
| Load SAKT model from HuggingFace Hub. |
| Tries v0.2.0 (SAKTWithDecay) first, then v0.1.0 (SAKTModel). |
| Returns (model, model_type_str) or (None, "unavailable"). |
| """ |
| for filename, model_type in [ |
| ("models/sakt_decay_best.pt", "SAKTWithDecay"), |
| ("models/sakt_vanilla_best.pt", "SAKTModel"), |
| ("models/sakt_model.pt", "SAKTModel"), |
| ("sakt_model.pt", "SAKTModel"), |
| ]: |
| try: |
| path = _hub_download(filename) |
| payload = torch.load(path, map_location=device, weights_only=False) |
|
|
| |
| if isinstance(payload, dict) and "state_dict" in payload: |
| cfg = payload.get("config", {}) |
| if model_type == "SAKTWithDecay": |
| model = SAKTWithDecay( |
| num_skills=cfg.get("num_skills", 5737), |
| embed_dim=cfg.get("embed_dim", 128), |
| num_heads=cfg.get("num_heads", 8), |
| dropout=cfg.get("dropout", 0.2), |
| max_seq_len=cfg.get("max_seq_len", 200), |
| decay_init=cfg.get("decay_init", 1.0), |
| ) |
| else: |
| model = SAKTModel( |
| num_skills=cfg.get("num_skills", 5737), |
| embed_dim=cfg.get("embed_dim", 128), |
| num_heads=cfg.get("num_heads", 8), |
| dropout=cfg.get("dropout", 0.2), |
| max_seq_len=cfg.get("max_seq_len", 200), |
| ) |
| model.load_state_dict(payload["state_dict"], strict=False) |
| model.eval() |
| model.to(device) |
| return model, model_type |
|
|
| |
| else: |
| config_path = _hub_download("config.json") |
| with open(config_path) as f: |
| config = json.load(f) |
| model = SAKTModel( |
| num_skills=config.get("num_skills", 5737), |
| embed_dim=config.get("embed_dim", 128), |
| num_heads=config.get("num_heads", 8), |
| dropout=config.get("dropout", 0.2), |
| max_seq_len=config.get("max_seq_len", 200), |
| ) |
| model.load_state_dict(payload, strict=False) |
| model.eval() |
| model.to(device) |
| return model, "SAKTModel" |
|
|
| except Exception: |
| continue |
|
|
| return None, "unavailable" |
|
|
|
|
| |
| |
| |
| st.set_page_config( |
| page_title="PLRS Β· Logic Engine", |
| page_icon="π§ ", |
| layout="wide", |
| initial_sidebar_state="expanded", |
| ) |
|
|
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap'); |
| |
| html, body, [class*="css"] { |
| font-family: 'Syne', sans-serif; |
| background-color: #0a0e1a; |
| color: #c8d0e0; |
| } |
| #MainMenu, footer, header { visibility: hidden; } |
| .block-container { padding: 1.5rem 2rem 2rem 2rem; max-width: 1400px; } |
| |
| [data-testid="stSidebar"] { |
| background: #0d1221; |
| border-right: 1px solid #1e2a40; |
| } |
| [data-testid="stSidebar"] .stMarkdown p { |
| font-family: 'DM Mono', monospace; |
| font-size: 0.75rem; |
| color: #4a5568; |
| letter-spacing: 0.08em; |
| } |
| |
| .plrs-header { |
| display: flex; align-items: baseline; gap: 1rem; |
| padding-bottom: 1rem; border-bottom: 1px solid #1e2a40; margin-bottom: 1.5rem; |
| } |
| .plrs-title { font-size: 1.75rem; font-weight: 800; letter-spacing: -0.02em; color: #e8edf5; } |
| .plrs-sub { |
| font-family: 'DM Mono', monospace; font-size: 0.7rem; color: #3d8bcd; |
| letter-spacing: 0.12em; text-transform: uppercase; padding: 2px 8px; |
| border: 1px solid #1e3a5f; border-radius: 2px; |
| } |
| |
| .stat-row { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; } |
| .stat-card { |
| flex: 1; background: #0d1221; border: 1px solid #1e2a40; |
| border-radius: 4px; padding: 0.9rem 1rem; position: relative; overflow: hidden; |
| } |
| .stat-card::before { |
| content: ''; position: absolute; top: 0; left: 0; right: 0; |
| height: 2px; background: var(--accent, #3d8bcd); |
| } |
| .stat-card.green::before { --accent: #22c55e; } |
| .stat-card.amber::before { --accent: #f59e0b; } |
| .stat-card.red::before { --accent: #ef4444; } |
| .stat-card.blue::before { --accent: #3d8bcd; } |
| .stat-label { font-family: 'DM Mono', monospace; font-size: 0.62rem; color: #4a5568; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 0.25rem; } |
| .stat-value { font-size: 1.6rem; font-weight: 700; color: #e8edf5; line-height: 1; } |
| .stat-sub { font-family: 'DM Mono', monospace; font-size: 0.65rem; color: #4a5568; margin-top: 0.2rem; } |
| |
| .rec-card { |
| background: #0d1221; border: 1px solid #1e2a40; border-radius: 4px; |
| padding: 0.9rem 1rem; margin-bottom: 0.5rem; |
| } |
| .rec-card.approved { border-left: 3px solid #22c55e; } |
| .rec-card.challenging { border-left: 3px solid #f59e0b; } |
| .rec-card.vetoed { border-left: 3px solid #ef4444; opacity: 0.6; } |
| .rec-title { font-size: 0.95rem; font-weight: 700; color: #e8edf5; margin-bottom: 0.15rem; } |
| .rec-meta { font-family: 'DM Mono', monospace; font-size: 0.65rem; color: #4a5568; letter-spacing: 0.06em; } |
| .rec-reason { font-size: 0.75rem; color: #8899aa; margin-top: 0.35rem; padding-top: 0.35rem; border-top: 1px solid #1e2a40; } |
| .score-bar-wrap { background: #131a2e; border-radius: 2px; height: 3px; margin-top: 0.5rem; overflow: hidden; } |
| .score-bar { height: 100%; border-radius: 2px; background: var(--bar-color, #3d8bcd); } |
| |
| .section-label { |
| font-family: 'DM Mono', monospace; font-size: 0.65rem; letter-spacing: 0.14em; |
| text-transform: uppercase; color: #4a5568; border-bottom: 1px solid #1e2a40; |
| padding-bottom: 0.4rem; margin-bottom: 0.75rem; margin-top: 1.25rem; |
| } |
| .unlock-chip { |
| display: inline-block; font-family: 'DM Mono', monospace; font-size: 0.65rem; |
| background: #131a2e; border: 1px solid #1e3a5f; border-radius: 2px; |
| padding: 2px 7px; margin: 2px 3px 2px 0; color: #3d8bcd; |
| } |
| .blocked-chip { |
| display: inline-block; font-family: 'DM Mono', monospace; font-size: 0.65rem; |
| background: #1a1010; border: 1px solid #3f1e1e; border-radius: 2px; |
| padding: 2px 7px; margin: 2px 3px 2px 0; color: #ef4444; |
| } |
| |
| .stTabs [data-baseweb="tab-list"] { gap: 0; border-bottom: 1px solid #1e2a40; background: transparent; } |
| .stTabs [data-baseweb="tab"] { font-family: 'DM Mono', monospace; font-size: 0.7rem; letter-spacing: 0.08em; color: #4a5568; padding: 0.5rem 1.25rem; border-bottom: 2px solid transparent; } |
| .stTabs [aria-selected="true"] { color: #3d8bcd; border-bottom-color: #3d8bcd; background: transparent; } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| @st.cache_resource(show_spinner="Loading curriculum & model from HuggingFace...") |
| def load_pipelines(): |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| |
| math_path = _hub_download("data/knowledge_maps/math_dag.json") |
| cs_path = _hub_download("data/knowledge_maps/cs_dag.json") |
|
|
| math_curriculum = load_dag(math_path, domain="Nigerian SS Mathematics") |
| cs_curriculum = load_dag(cs_path, domain="CS Fundamentals") |
|
|
| |
| model, model_type = load_model_from_hub(device=str(device)) |
|
|
| pipelines = {} |
| for key, curriculum in [("math", math_curriculum), ("cs", cs_curriculum)]: |
| pipeline = PLRSPipeline(curriculum) |
| if model is not None: |
| pipeline._model = model |
| pipelines[key] = pipeline |
|
|
| return pipelines, model is not None, model_type |
|
|
|
|
| @st.cache_data |
| def load_skill_encoder(): |
| """Download and load the skill encoder CSV.""" |
| try: |
| path = _hub_download("data/skill_encoder_v2.csv") |
| return pd.read_csv(path) |
| except Exception: |
| return None |
|
|
|
|
| pipelines, has_model, model_type = load_pipelines() |
| skill_encoder = load_skill_encoder() |
|
|
| |
| with st.sidebar: |
| st.markdown("### π§ PLRS") |
| st.markdown('<p style="font-family:\'DM Mono\',monospace;font-size:0.65rem;color:#4a5568;letter-spacing:0.1em;">LOGIC ENGINE v0.2.0</p>', unsafe_allow_html=True) |
|
|
| if has_model: |
| st.markdown(f'<p style="color:#22c55e;font-size:0.7rem;font-family:\'DM Mono\',monospace;">β {model_type} LOADED</p>', unsafe_allow_html=True) |
| else: |
| st.markdown('<p style="color:#f59e0b;font-size:0.7rem;font-family:\'DM Mono\',monospace;">β MANUAL MODE</p>', unsafe_allow_html=True) |
|
|
| st.markdown("---") |
|
|
| domain_label = st.selectbox("Curriculum", ["Nigerian SS Mathematics", "CS Fundamentals"]) |
| domain_key = "math" if "Mathematics" in domain_label else "cs" |
| pipeline = pipelines[domain_key] |
| curriculum = pipeline.curriculum |
|
|
| st.markdown("---") |
| threshold = st.slider("Mastery threshold", 0.50, 0.90, 0.70, 0.05) |
| soft_threshold = st.slider("Challenging threshold", 0.20, 0.65, 0.50, 0.05) |
| top_n = st.slider("Top N recommendations", 3, 10, 5) |
|
|
| pipeline.threshold = threshold |
| pipeline.soft_threshold = soft_threshold |
| pipeline.top_n = top_n |
|
|
| st.markdown("---") |
| st.markdown(f'<p style="font-family:\'DM Mono\',monospace;font-size:0.65rem;color:#4a5568;">NODES: <span style="color:#e8edf5;">{curriculum.num_nodes}</span></p>', unsafe_allow_html=True) |
| st.markdown(f'<p style="font-family:\'DM Mono\',monospace;font-size:0.65rem;color:#4a5568;">EDGES: <span style="color:#e8edf5;">{curriculum.num_edges}</span></p>', unsafe_allow_html=True) |
| st.markdown(f'<p style="font-family:\'DM Mono\',monospace;font-size:0.65rem;color:#4a5568;">MODEL: <span style="color:#e8edf5;">{model_type}</span></p>', unsafe_allow_html=True) |
| st.markdown(f'<p style="font-family:\'DM Mono\',monospace;font-size:0.65rem;color:#4a5568;">VIOLATION RATE: <span style="color:#22c55e;">0.0%</span></p>', unsafe_allow_html=True) |
|
|
| st.markdown("---") |
| st.markdown('<p style="font-family:\'DM Mono\',monospace;font-size:0.6rem;color:#2a3a50;">github.com/clementina-tom/plrs</p>', unsafe_allow_html=True) |
|
|
|
|
| |
| st.markdown(""" |
| <div class="plrs-header"> |
| <span class="plrs-title">Logic Engine</span> |
| <span class="plrs-sub">Personalized Learning Β· Constraint-Aware Β· SAKT + DAG</span> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| tab1, tab2, tab3 = st.tabs(["RECOMMENDATIONS", "WHAT-IF SIMULATOR", "CURRICULUM MAP"]) |
|
|
| ACTIVITY_TO_DOMAIN = { |
| "math": { |
| "oucontent": "algebraic_expressions", "forumng": "statistics_basic", |
| "homepage": "whole_numbers", "subpage": "plane_shapes", |
| "resource": "indices", "url": "number_bases", |
| "ouwiki": "proportion_variation", "glossary": "algebraic_factorization", |
| "quiz": "quadratic_equations", |
| }, |
| "cs": { |
| "oucontent": "programming_concepts", "forumng": "ethics_technology", |
| "homepage": "computer_basics", "subpage": "html_basics", |
| "resource": "networking_fundamentals", "url": "internet_basics", |
| "ouwiki": "cloud_basics", "glossary": "intro_databases", |
| "quiz": "python_basics", |
| }, |
| } |
|
|
|
|
| |
| |
| |
| with tab1: |
| col_left, col_right = st.columns([1, 1.4], gap="large") |
|
|
| with col_left: |
| st.markdown('<div class="section-label">Learner Profile</div>', unsafe_allow_html=True) |
| mode = st.radio("Input mode", ["Manual sliders", "Simulate student"], horizontal=True, label_visibility="collapsed") |
|
|
| mastery_scores = {} |
|
|
| if mode == "Manual sliders": |
| for node in curriculum.nodes: |
| label = curriculum.label(node) |
| level = curriculum.level(node) |
| val = st.slider( |
| f"{label}", |
| 0.0, 1.0, 0.0, 0.05, |
| key=f"mastery_{node}", |
| help=f"Level: {level}" |
| ) |
| mastery_scores[node] = val |
| else: |
| seq_len = st.slider("Sequence length", 10, 200, 50) |
| seed = st.number_input("Student seed", 1, 9999, 42) |
| np.random.seed(int(seed)) |
|
|
| mapping = ACTIVITY_TO_DOMAIN[domain_key] |
|
|
| if skill_encoder is not None: |
| available_skills = skill_encoder["skill_id"].tolist() |
| sim_skills = np.random.choice(available_skills, seq_len).tolist() |
| else: |
| n_skills = 5736 |
| sim_skills = np.random.randint(0, n_skills, seq_len).tolist() |
|
|
| sim_corrects = np.random.randint(0, 2, seq_len).tolist() |
| topic_scores = {} |
|
|
| for skill_id, correct in zip(sim_skills, sim_corrects): |
| if skill_encoder is not None: |
| row = skill_encoder[skill_encoder["skill_id"] == skill_id] |
| if not row.empty: |
| act = row["activity_type"].values[0] |
| else: |
| continue |
| else: |
| |
| activity_types = list(mapping.keys()) |
| activity_probs = [0.38, 0.20, 0.15, 0.10, 0.06, 0.04, 0.03, 0.02, 0.02] |
| act_idx = skill_id % 100 |
| cumulative = 0 |
| thresholds = [int(p * 100) for p in activity_probs] |
| thresholds[-1] += 100 - sum(thresholds) |
| act = activity_types[-1] |
| for a, thresh in zip(activity_types, thresholds): |
| cumulative += thresh |
| if act_idx < cumulative: |
| act = a |
| break |
|
|
| topic_id = mapping.get(act) |
| if topic_id and topic_id in curriculum.nodes: |
| prob = float(correct) * 0.6 + 0.1 + np.random.random() * 0.3 |
| topic_scores[topic_id] = max(topic_scores.get(topic_id, 0.0), min(prob, 1.0)) |
|
|
| mastery_scores = {n: 0.0 for n in curriculum.nodes} |
| mastery_scores.update(topic_scores) |
| st.success(f"Simulated {seq_len} real interactions β {len(topic_scores)} topics mapped") |
|
|
| if topic_scores: |
| st.markdown('<div class="section-label">Mapped Mastery Signal</div>', unsafe_allow_html=True) |
| for tid, score in sorted(topic_scores.items(), key=lambda x: -x[1]): |
| pct = int(score * 100) |
| color = "#22c55e" if score >= threshold else "#f59e0b" if score >= soft_threshold else "#ef4444" |
| st.markdown(f""" |
| <div style="margin-bottom:6px;"> |
| <div style="display:flex;justify-content:space-between;font-size:0.72rem;color:#8899aa;margin-bottom:2px;"> |
| <span>{curriculum.label(tid)}</span> |
| <span style="font-family:'DM Mono',monospace;">{pct}%</span> |
| </div> |
| <div class="score-bar-wrap"> |
| <div class="score-bar" style="width:{pct}%;--bar-color:{color};"></div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| run = st.button("β‘ Generate Recommendations", type="primary", use_container_width=True) |
|
|
| with col_right: |
| if run or mode == "Simulate student": |
| results = pipeline.recommend_from_mastery(mastery_scores) |
| summary = results["mastery_summary"] |
| stats = results["stats"] |
|
|
| mastery_pct = int(summary["mastery_rate"] * 100) |
| vrate_pct = int(stats["prerequisite_violation_rate"] * 100) |
|
|
| st.markdown(f""" |
| <div class="stat-row"> |
| <div class="stat-card blue"> |
| <div class="stat-label">Mastered</div> |
| <div class="stat-value">{summary['mastered']}<span style="font-size:0.9rem;color:#4a5568;">/{summary['total_topics']}</span></div> |
| <div class="stat-sub">{mastery_pct}% rate</div> |
| </div> |
| <div class="stat-card green"> |
| <div class="stat-label">Approved</div> |
| <div class="stat-value">{stats['approved_count']}</div> |
| <div class="stat-sub">ready to learn</div> |
| </div> |
| <div class="stat-card amber"> |
| <div class="stat-label">Challenging</div> |
| <div class="stat-value">{stats['challenging_count']}</div> |
| <div class="stat-sub">partial prereqs</div> |
| </div> |
| <div class="stat-card red"> |
| <div class="stat-label">Violation rate</div> |
| <div class="stat-value">{vrate_pct}<span style="font-size:0.9rem;color:#4a5568;">%</span></div> |
| <div class="stat-sub">blocked topics</div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| if results["approved"]: |
| st.markdown('<div class="section-label">β
Approved Recommendations</div>', unsafe_allow_html=True) |
| for i, rec in enumerate(results["approved"]): |
| score_pct = int(rec["score"] * 100) |
| st.markdown(f""" |
| <div class="rec-card approved"> |
| <div class="rec-title">{i+1}. {rec['topic_label']}</div> |
| <div class="rec-meta">score: {rec['score']:.3f} Β· mastery: {int(rec['mastery']*100)}% Β· unlocks: {rec['downstream_count']}</div> |
| <div class="rec-reason">{rec['reasoning']}</div> |
| <div class="score-bar-wrap"><div class="score-bar" style="width:{score_pct}%;--bar-color:#22c55e;"></div></div> |
| </div> |
| """, unsafe_allow_html=True) |
| else: |
| st.info("No approved topics β lower the mastery threshold or set some mastery levels.") |
|
|
| if results["challenging"]: |
| st.markdown('<div class="section-label">β οΈ Challenging</div>', unsafe_allow_html=True) |
| for rec in results["challenging"]: |
| score_pct = int(rec["score"] * 100) |
| unmet = ", ".join(rec["unmet_prerequisites"]) or "β" |
| st.markdown(f""" |
| <div class="rec-card challenging"> |
| <div class="rec-title">{rec['topic_label']}</div> |
| <div class="rec-meta">score: {rec['score']:.3f} Β· strengthen: {unmet}</div> |
| <div class="rec-reason">{rec['reasoning']}</div> |
| <div class="score-bar-wrap"><div class="score-bar" style="width:{score_pct}%;--bar-color:#f59e0b;"></div></div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| if results["vetoed"]: |
| with st.expander(f"β Vetoed topics ({stats['vetoed_count']} total β prerequisite check failed)"): |
| for rec in results["vetoed"]: |
| unmet = ", ".join(rec["unmet_prerequisites"]) or "β" |
| st.markdown(f""" |
| <div class="rec-card vetoed"> |
| <div class="rec-title">{rec['topic_label']}</div> |
| <div class="rec-meta">blocked by: {unmet}</div> |
| </div> |
| """, unsafe_allow_html=True) |
| else: |
| st.markdown(""" |
| <div style="height:280px;display:flex;align-items:center;justify-content:center; |
| border:1px dashed #1e2a40;border-radius:4px;color:#2a3a50;"> |
| <div style="text-align:center;"> |
| <div style="font-size:2rem;margin-bottom:0.5rem;">β‘</div> |
| <div style="font-family:'DM Mono',monospace;font-size:0.7rem;letter-spacing:0.1em;"> |
| SET MASTERY LEVELS Β· THEN GENERATE |
| </div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
| with tab2: |
| st.markdown('<div class="section-label">Prerequisite Impact Simulator</div>', unsafe_allow_html=True) |
| st.markdown('<p style="font-size:0.8rem;color:#8899aa;">Select any topic to see what it unlocks and what currently blocks it.</p>', unsafe_allow_html=True) |
|
|
| node_options = {curriculum.label(n): n for n in curriculum.nodes} |
| selected_label = st.selectbox("Select topic", list(node_options.keys())) |
| selected_id = node_options[selected_label] |
| wi = pipeline.what_if(selected_id) |
|
|
| col_a, col_b = st.columns(2, gap="large") |
|
|
| with col_a: |
| st.markdown('<div class="section-label">π What This Unlocks</div>', unsafe_allow_html=True) |
| if wi["direct_unlocks"]: |
| st.markdown("**Directly unlocks:**") |
| st.markdown("".join(f'<span class="unlock-chip">{u["label"]}</span>' for u in wi["direct_unlocks"]), unsafe_allow_html=True) |
| else: |
| st.markdown('<span style="color:#4a5568;font-size:0.8rem;">Leaf node β no further topics.</span>', unsafe_allow_html=True) |
|
|
| if wi["all_unlocks"]: |
| st.markdown(f"**All downstream ({wi['total_unlocked']}):**") |
| st.markdown("".join(f'<span class="unlock-chip">{u["label"]}</span>' for u in wi["all_unlocks"]), unsafe_allow_html=True) |
|
|
| st.markdown(f""" |
| <div class="stat-card blue" style="margin-top:1rem;max-width:180px;"> |
| <div class="stat-label">Total Unlocked</div> |
| <div class="stat-value">{wi['total_unlocked']}</div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| with col_b: |
| st.markdown('<div class="section-label">π What Blocks This</div>', unsafe_allow_html=True) |
| if wi["blocked_by"]: |
| st.markdown("**Prerequisites:**") |
| st.markdown("".join(f'<span class="blocked-chip">{b["label"]}</span>' for b in wi["blocked_by"]), unsafe_allow_html=True) |
| else: |
| st.markdown('<span style="color:#22c55e;font-size:0.8rem;font-family:\'DM Mono\',monospace;">Root topic β no prerequisites.</span>', unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
| with tab3: |
| st.markdown('<div class="section-label">Curriculum Knowledge Graph</div>', unsafe_allow_html=True) |
|
|
| col_info, col_table = st.columns([1, 2], gap="large") |
|
|
| with col_info: |
| roots = [n for n in curriculum.nodes if not curriculum.prerequisites(n)] |
| leaves = [n for n in curriculum.nodes if not curriculum.successors(n)] |
|
|
| st.markdown(f""" |
| <div class="stat-card blue" style="margin-bottom:0.75rem;"> |
| <div class="stat-label">Domain</div> |
| <div style="font-size:0.85rem;font-weight:700;color:#e8edf5;">{curriculum.domain}</div> |
| </div> |
| <div class="stat-card green" style="margin-bottom:0.75rem;"> |
| <div class="stat-label">Topics</div><div class="stat-value">{curriculum.num_nodes}</div> |
| </div> |
| <div class="stat-card amber"> |
| <div class="stat-label">Prerequisite Edges</div><div class="stat-value">{curriculum.num_edges}</div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown('<div class="section-label">Root Topics</div>', unsafe_allow_html=True) |
| st.markdown("".join(f'<span class="unlock-chip">{curriculum.label(r)}</span>' for r in roots), unsafe_allow_html=True) |
|
|
| st.markdown('<div class="section-label">Leaf Topics</div>', unsafe_allow_html=True) |
| st.markdown("".join(f'<span class="blocked-chip">{curriculum.label(l)}</span>' for l in leaves), unsafe_allow_html=True) |
|
|
| with col_table: |
| st.markdown('<div class="section-label">All Topics</div>', unsafe_allow_html=True) |
| rows = [] |
| for node in curriculum.nodes: |
| rows.append({ |
| "Topic": curriculum.label(node), |
| "Level": curriculum.level(node), |
| "Prerequisites": len(curriculum.prerequisites(node)), |
| "Unlocks (direct)": len(curriculum.successors(node)), |
| "Total Downstream": len(curriculum.descendants(node)), |
| }) |
| df = pd.DataFrame(rows).sort_values("Total Downstream", ascending=False) |
| st.dataframe(df, use_container_width=True, height=480, hide_index=True) |
|
|