LazyHuman10 commited on
Commit
fbe7a99
·
0 Parent(s):

Prepare Hugging Face Space deployment

Browse files
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Python 3",
3
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
4
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
5
+ "customizations": {
6
+ "codespaces": {
7
+ "openFiles": [
8
+ "README.md",
9
+ "Home.py"
10
+ ]
11
+ },
12
+ "vscode": {
13
+ "settings": {},
14
+ "extensions": [
15
+ "ms-python.python",
16
+ "ms-python.vscode-pylance"
17
+ ]
18
+ }
19
+ },
20
+ "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
21
+ "postAttachCommand": {
22
+ "server": "streamlit run Home.py --server.enableCORS false --server.enableXsrfProtection false"
23
+ },
24
+ "portsAttributes": {
25
+ "8501": {
26
+ "label": "Application",
27
+ "onAutoForward": "openPreview"
28
+ }
29
+ },
30
+ "forwardPorts": [
31
+ 8501
32
+ ]
33
+ }
.gitattributes ADDED
File without changes
.github/workflows/main_plexi.yml ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
2
+ # More GitHub Actions for Azure: https://github.com/Azure/actions
3
+ # More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
4
+
5
+ name: Build and deploy Python app to Azure Web App - plexi
6
+
7
+ on:
8
+ push:
9
+ branches:
10
+ - main
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read #This is required for actions/checkout
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Set up Python version
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: '3.11'
26
+
27
+ # 🛠️ Local Build Section (Optional)
28
+ # The following section in your workflow is designed to catch build issues early on the client side, before deployment. This can be helpful for debugging and validation. However, if this step significantly increases deployment time and early detection is not critical for your workflow, you may remove this section to streamline the deployment process.
29
+ - name: Create and Start virtual environment and Install dependencies
30
+ run: |
31
+ python -m venv antenv
32
+ source antenv/bin/activate
33
+ pip install -r requirements.txt
34
+
35
+ # By default, when you enable GitHub CI/CD integration through the Azure portal, the platform automatically sets the SCM_DO_BUILD_DURING_DEPLOYMENT application setting to true. This triggers the use of Oryx, a build engine that handles application compilation and dependency installation (e.g., pip install) directly on the platform during deployment. Hence, we exclude the antenv virtual environment directory from the deployment artifact to reduce the payload size.
36
+ - name: Upload artifact for deployment jobs
37
+ uses: actions/upload-artifact@v4
38
+ with:
39
+ name: python-app
40
+ path: |
41
+ .
42
+ !antenv/
43
+
44
+ # 🚫 Opting Out of Oryx Build
45
+ # If you prefer to disable the Oryx build process during deployment, follow these steps:
46
+ # 1. Remove the SCM_DO_BUILD_DURING_DEPLOYMENT app setting from your Azure App Service Environment variables.
47
+ # 2. Refer to sample workflows for alternative deployment strategies: https://github.com/Azure/actions-workflow-samples/tree/master/AppService
48
+
49
+
50
+ deploy:
51
+ runs-on: ubuntu-latest
52
+ needs: build
53
+ permissions:
54
+ id-token: write #This is required for requesting the JWT
55
+ contents: read #This is required for actions/checkout
56
+
57
+ steps:
58
+ - name: Download artifact from build job
59
+ uses: actions/download-artifact@v4
60
+ with:
61
+ name: python-app
62
+
63
+ - name: Login to Azure
64
+ uses: azure/login@v2
65
+ with:
66
+ client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_57930D6EA9E0445795ADC1381E0BF637 }}
67
+ tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_4402E3B59B64422D9FCB755F7BE45091 }}
68
+ subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_407A4A4497714262919D51936A8FEC81 }}
69
+
70
+ - name: 'Deploy to Azure Web App'
71
+ uses: azure/webapps-deploy@v3
72
+ id: deploy-to-webapp
73
+ with:
74
+ app-name: 'plexi'
75
+ slot-name: 'Production'
76
+
.github/workflows/wake-up-streamlit.yml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Keep Streamlit Warm
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 */5 * * *"
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ warm_app:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 15
12
+
13
+ steps:
14
+ - name: Set up Node.js
15
+ uses: actions/setup-node@v4
16
+ with:
17
+ node-version: "20"
18
+
19
+ - name: Install Playwright
20
+ run: |
21
+ npm init -y
22
+ npm install playwright
23
+ npx playwright install --with-deps chromium
24
+
25
+ - name: Wake Streamlit Community Cloud app
26
+ env:
27
+ APP_URL: https://plexi-study.streamlit.app/
28
+ run: |
29
+ cat <<'EOF' > wake-streamlit.mjs
30
+ import { chromium } from "playwright";
31
+
32
+ const appUrl = process.env.APP_URL;
33
+ const browser = await chromium.launch({ headless: true });
34
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
35
+
36
+ try {
37
+ await page.goto(appUrl, { waitUntil: "domcontentloaded", timeout: 90000 });
38
+
39
+ const wakeButton = page
40
+ .getByRole("button", { name: /yes, get this app back up|wake up/i })
41
+ .first();
42
+
43
+ if (await wakeButton.isVisible({ timeout: 10000 }).catch(() => false)) {
44
+ await wakeButton.click();
45
+ await page.waitForTimeout(8000);
46
+ console.log("App was asleep. Wake-up button clicked.");
47
+ } else {
48
+ console.log("Wake-up prompt not shown.");
49
+ }
50
+
51
+ await page.waitForSelector(
52
+ "[data-testid='stAppViewContainer'], section.main, div[data-testid='stApp']",
53
+ { timeout: 90000 }
54
+ );
55
+
56
+ console.log("App is reachable.");
57
+ } finally {
58
+ await browser.close();
59
+ }
60
+ EOF
61
+ node wake-streamlit.mjs
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ __pycache__
3
+ .github
4
+ !.github/
5
+ !.github/workflows/
6
+ !.github/workflows/*.yml
7
+
8
+
9
+ # Environment variables
10
+ .env
11
+
12
+ # Credentials and tokens
13
+ credentials.json
14
+ token.json
15
+ service_account.json
16
+
17
+
18
+ # Python virtual environments
19
+ venv/
20
+ env/
21
+ ENV/
22
+ .venv/
23
+ .pyenv/
24
+
25
+ .streamlit/*
26
+ !.streamlit/config.toml
27
+ .zencoder
.streamlit/config.toml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [theme]
2
+ base = "light"
3
+ primaryColor = "#1D7A63"
4
+ backgroundColor = "#FBF7EF"
5
+ secondaryBackgroundColor = "#F1E7D7"
6
+ textColor = "#16312C"
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ CMD ["streamlit", "run", "Home.py", "--server.port=8501", "--server.address=0.0.0.0"]
Home.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils import (
3
+ inject_theme,
4
+ render_page_header,
5
+ render_sidebar,
6
+ )
7
+
8
+ st.set_page_config(page_title="Plexi | Home", layout="wide")
9
+ inject_theme()
10
+
11
+ render_page_header(
12
+ "AI study companion",
13
+ "Study with real materials, not generic answers",
14
+ (
15
+ "Plexi keeps revision simple: browse the right files, preview them quickly, "
16
+ "and ask focused questions without losing the subject context."
17
+ ),
18
+ badges=[
19
+ "Inline file previews",
20
+ "Subject-scoped retrieval",
21
+ "Bring your own model",
22
+ ],
23
+ )
24
+
25
+ intro_col, action_col = st.columns(2, gap="large")
26
+
27
+ with intro_col:
28
+ st.markdown(
29
+ """
30
+ <section class="plexi-panel" style="min-height: 122px;">
31
+ <div class="plexi-sidecard-title">Built for focused revision</div>
32
+ <div class="plexi-muted">
33
+ Move from finding the right material to understanding it in one clean flow.
34
+ </div>
35
+ </section>
36
+ """,
37
+ unsafe_allow_html=True,
38
+ )
39
+
40
+ with action_col:
41
+ st.markdown(
42
+ """
43
+ <section class="plexi-callout" style="min-height: 122px;">
44
+ <div class="plexi-sidecard-title">Choose your starting point</div>
45
+ <div class="plexi-muted">
46
+ Open the hub to explore files, or jump straight into the assistant if you already
47
+ know what you want to study.
48
+ </div>
49
+ </section>
50
+ """,
51
+ unsafe_allow_html=True,
52
+ )
53
+
54
+ st.markdown(
55
+ """
56
+ <div class="plexi-cta-grid">
57
+ <a class="plexi-cta-button" href="/Study_Material_Hub" target="_self">Open Material Hub</a>
58
+ <a class="plexi-cta-button" href="/Plexi-Assistant" target="_self">Open Assistant</a>
59
+ </div>
60
+ """,
61
+ unsafe_allow_html=True,
62
+ )
63
+
64
+ st.markdown(
65
+ '<div class="plexi-section-label">How It Works</div>',
66
+ unsafe_allow_html=True,
67
+ )
68
+ step_cols = st.columns(3, gap="medium")
69
+ step_cards = [
70
+ (
71
+ "01",
72
+ "Choose your subject",
73
+ "Pick a semester and subject so the experience stays focused on one course.",
74
+ ),
75
+ (
76
+ "02",
77
+ "Browse the material",
78
+ "Open notes and PDFs in the hub and preview them without leaving the app.",
79
+ ),
80
+ (
81
+ "03",
82
+ "Study with Plexi",
83
+ "Ask for summaries, revision help, or simple explanations from the loaded subject.",
84
+ ),
85
+ ]
86
+ for column, (step, title, body) in zip(step_cols, step_cards):
87
+ with column:
88
+ st.markdown(
89
+ f"""
90
+ <section class="plexi-stat">
91
+ <div class="plexi-stat-label">{step}</div>
92
+ <div class="plexi-sidecard-title">{title}</div>
93
+ <div class="plexi-muted">{body}</div>
94
+ </section>
95
+ """,
96
+ unsafe_allow_html=True,
97
+ )
98
+
99
+ st.markdown(
100
+ '<div class="plexi-section-label">Contribute</div>',
101
+ unsafe_allow_html=True,
102
+ )
103
+ contribute_cols = st.columns([1.25, 0.75], gap="large")
104
+
105
+ with contribute_cols[0]:
106
+ st.markdown(
107
+ """
108
+ <section class="plexi-callout">
109
+ <div class="plexi-sidecard-title">Share something useful</div>
110
+ <div class="plexi-muted">
111
+ Have clean notes, slides, or question papers that can help others?
112
+ Submit them through the upload form and they can be reviewed and added
113
+ to the study library for everyone.
114
+ </div>
115
+ </section>
116
+ """,
117
+ unsafe_allow_html=True,
118
+ )
119
+
120
+ with contribute_cols[1]:
121
+ st.markdown(
122
+ """
123
+ <div class="plexi-cta-grid" style="margin-top:0; grid-template-columns:1fr;">
124
+ <a class="plexi-cta-button" href="https://github.com/KunalGupta25/plexi-materials/issues/new?template=upload-material.yml" target="_blank" rel="noopener noreferrer">Submit Materials</a>
125
+ </div>
126
+ """,
127
+ unsafe_allow_html=True,
128
+ )
129
+
130
+ render_sidebar()
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kunal Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Plexi
3
+ emoji: 📚
4
+ colorFrom: green
5
+ colorTo: teal
6
+ sdk: docker
7
+ app_port: 8501
8
+ pinned: false
9
+ ---
10
+
11
+ # Plexi
12
+
13
+ <p align="center">
14
+ <strong>Your AI-powered study companion for Parul University CS students.</strong>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://ko-fi.com/lazy_human">
19
+ <img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi" />
20
+ </a>
21
+ </p>
22
+
23
+ ---
24
+
25
+ ## What is Plexi?
26
+
27
+ Plexi is a free web app that gives Collage students easy access to study materials and an AI assistant — all in one place. No setup, no accounts, just open and start studying.
28
+
29
+ ---
30
+
31
+ ## What can you do?
32
+
33
+ ### Study Material Hub
34
+
35
+ Browse and download notes, slides, and other materials organized by **semester → subject → type**. PDFs open right in the browser — no extra apps needed.
36
+
37
+ - Filter by semester, subject, and file type
38
+ - In-Build PDF Viewer
39
+ - Download any file with one click
40
+
41
+ ### Plexi Assistant
42
+
43
+ Chat with an AI that **only answers using actual study materials from Database** — no hallucinations, no random internet answers. It works like Google's NotebookLM but specifically for your course content.
44
+
45
+ - Pick any OpenAI Compatible LLM provider — **Gemini, ChatGPT, Mistral, Groq, OpenRouter**, or even a local model(Ollama or LM Studio)
46
+ - Every answer includes **source citations** so you know exactly where the information came from
47
+ - Bring your own API key (free tiers available from most providers)
48
+
49
+ ### Contribute Materials
50
+
51
+ Have notes that could help others? Submit them through a simple form — they'll be reviewed and added for everyone automatically.
52
+
53
+ ---
54
+
55
+ ## How it works
56
+
57
+ 1. **Pick your scope** — Select a semester and subject
58
+ 2. **Browse or chat** — View files in the Hub, or ask the AI assistant questions grounded in your materials
59
+ 3. **Get cited answers** — Every response references the exact source file so you can verify and study further
60
+
61
+ ---
62
+
63
+ ## Built by
64
+
65
+ **Kunal Gupta** (LazyHuman)
66
+
67
+ - [lazyhideout.tech](https://lazyhideout.tech)
68
+ - [github.com/KunalGupta25](https://github.com/KunalGupta25)
69
+ - [ko-fi.com/lazy_human](https://ko-fi.com/lazy_human)
70
+
71
+ ---
72
+
73
+ ## License
74
+
75
+ This project is licensed under the [MIT License](LICENSE).
76
+
77
+ ---
78
+
79
+ > Made with ❤️ by Kunal aka LazyHuman
example.env ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Example environment variables for Plexi
2
+
3
+ # GitHub repo hosting study materials (owner/repo format)
4
+ # Defaults to KunalGupta25/plexi-materials if not set
5
+ # MATERIALS_REPO=KunalGupta25/plexi-materials
pages/Plexi-Assistant.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Plexi-Assistant.py - Plexi RAG Assistant (GitHub Index)
3
+ ======================================================
4
+ The Streamlit frontend. Retrieval uses the pre-built LlamaIndex
5
+ committed to the plexi-materials repo by GitHub Actions.
6
+
7
+ Flow per user message:
8
+ 1. On app start -> fetch pre-built index from GitHub (cached)
9
+ 2. On each message -> embed query locally, retrieve top-k chunks
10
+ 3. Build focused prompt with retrieved chunks -> call user's LLM
11
+ """
12
+
13
+ import requests
14
+ import streamlit as st
15
+ from utils import (
16
+ fetch_rag_index,
17
+ get_manifest,
18
+ inject_theme,
19
+ load_subject_context,
20
+ render_page_header,
21
+ render_panel,
22
+ render_sidebar,
23
+ render_stat_cards,
24
+ summarize_subject_catalog,
25
+ )
26
+
27
+ st.set_page_config(page_title="Plexi Assistant", layout="wide")
28
+ inject_theme()
29
+
30
+ TOP_K = 5
31
+ PROMPT_SUGGESTIONS = [
32
+ (
33
+ "Summarize this subject",
34
+ "Give me a clean revision summary of this subject using only the loaded materials.",
35
+ ),
36
+ (
37
+ "Important exam topics",
38
+ "List the most important exam topics covered in these materials.",
39
+ ),
40
+ (
41
+ "Explain like a beginner",
42
+ "Explain the most important concepts in simple terms using only the loaded materials.",
43
+ ),
44
+ (
45
+ "Make viva questions",
46
+ "Create 10 viva-style questions and short answers from the loaded materials.",
47
+ ),
48
+ ]
49
+
50
+ PROVIDERS = {
51
+ "Gemini (Google)": {
52
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
53
+ "models": [
54
+ "gemini-2.0-flash",
55
+ "gemini-2.0-flash-lite",
56
+ "gemini-1.5-flash",
57
+ "gemini-1.5-pro",
58
+ ],
59
+ "key_help": "Get a key at [Google AI Studio](https://aistudio.google.com/app/apikey)",
60
+ },
61
+ "OpenAI": {
62
+ "base_url": "https://api.openai.com/v1",
63
+ "models": ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1-nano"],
64
+ "key_help": "Get a key at [OpenAI Platform](https://platform.openai.com/api-keys)",
65
+ },
66
+ "Mistral": {
67
+ "base_url": "https://api.mistral.ai/v1",
68
+ "models": [
69
+ "mistral-small-latest",
70
+ "mistral-medium-latest",
71
+ "mistral-large-latest",
72
+ "open-mistral-nemo",
73
+ ],
74
+ "key_help": "Get a key at [Mistral Console](https://console.mistral.ai/api-keys)",
75
+ },
76
+ "Groq": {
77
+ "base_url": "https://api.groq.com/openai/v1",
78
+ "models": [
79
+ "llama-3.3-70b-versatile",
80
+ "llama-3.1-8b-instant",
81
+ "gemma2-9b-it",
82
+ "mixtral-8x7b-32768",
83
+ ],
84
+ "key_help": "Get a key at [Groq Console](https://console.groq.com/keys)",
85
+ },
86
+ "OpenRouter": {
87
+ "base_url": "https://openrouter.ai/api/v1",
88
+ "models": [
89
+ "google/gemini-2.0-flash-exp:free",
90
+ "meta-llama/llama-3.3-70b-instruct:free",
91
+ "mistralai/mistral-small-3.1-24b-instruct:free",
92
+ "qwen/qwen3-8b:free",
93
+ ],
94
+ "key_help": "Get a key at [OpenRouter](https://openrouter.ai/keys)",
95
+ },
96
+ "Custom (self-hosted)": {
97
+ "base_url": "",
98
+ "models": [],
99
+ "key_help": "For Ollama, LM Studio, or any OpenAI-compatible server",
100
+ },
101
+ }
102
+ PROVIDER_NAMES = list(PROVIDERS.keys())
103
+
104
+
105
+ def _matches_scope(node, semester: str, subject: str) -> bool:
106
+ """Return True when a retrieved node belongs to the active semester + subject."""
107
+ metadata = getattr(node.node, "metadata", {}) or {}
108
+ return metadata.get("semester") == semester and metadata.get("subject") == subject
109
+
110
+
111
+ def queue_prompt(prompt: str):
112
+ """Store a prompt and rerun so the chat input flow can process it."""
113
+ st.session_state["_pending_prompt"] = prompt
114
+ st.rerun()
115
+
116
+
117
+ def local_retrieve(index, query: str, semester: str, subject: str, top_k: int = TOP_K):
118
+ """Retrieve top-k relevant chunks scoped to the active semester + subject."""
119
+ if index is None:
120
+ return []
121
+ try:
122
+ retriever = index.as_retriever(similarity_top_k=max(top_k * 5, 10))
123
+ nodes = retriever.retrieve(query)
124
+ scoped_nodes = [
125
+ node for node in nodes if _matches_scope(node, semester, subject)
126
+ ]
127
+ return [
128
+ {
129
+ "text": node.node.get_content(),
130
+ "score": round(float(node.score), 4)
131
+ if node.score is not None
132
+ else None,
133
+ "filename": (getattr(node.node, "metadata", {}) or {}).get("filename"),
134
+ "subject": (getattr(node.node, "metadata", {}) or {}).get("subject"),
135
+ }
136
+ for node in scoped_nodes[:top_k]
137
+ ]
138
+ except Exception as err:
139
+ st.warning(f"Retrieval error: {err}")
140
+ return []
141
+
142
+
143
+ def format_context(chunks):
144
+ """Format retrieved chunks for the system prompt."""
145
+ if not chunks:
146
+ return "(No relevant context retrieved.)"
147
+ parts = []
148
+ for index, chunk in enumerate(chunks, start=1):
149
+ score = f" [relevance: {chunk['score']}]" if chunk.get("score") else ""
150
+ parts.append(f"--- Chunk {index}{score} ---\n{chunk['text']}\n")
151
+ return "\n".join(parts)
152
+
153
+
154
+ def _send_message(endpoint_url, api_key, model, system_prompt, history, user_prompt):
155
+ messages = [{"role": "system", "content": system_prompt}]
156
+ for message in history:
157
+ messages.append({"role": message["role"], "content": message["content"]})
158
+ messages.append({"role": "user", "content": user_prompt})
159
+
160
+ headers = {"Content-Type": "application/json"}
161
+ if api_key:
162
+ headers["Authorization"] = f"Bearer {api_key}"
163
+
164
+ response = requests.post(
165
+ f"{endpoint_url}/chat/completions",
166
+ headers=headers,
167
+ json={"model": model, "messages": messages, "temperature": 0.3},
168
+ timeout=120,
169
+ )
170
+ if response.status_code == 429:
171
+ detail = response.json().get("error", {}).get("message", "Rate limit exceeded.")
172
+ raise Exception(f"RATE_LIMITED: {detail}")
173
+ if response.status_code == 401:
174
+ raise Exception(
175
+ "AUTH_ERROR: Invalid API key. Please check your key and try again."
176
+ )
177
+ response.raise_for_status()
178
+ return response.json()["choices"][0]["message"]["content"]
179
+
180
+
181
+ def _is_configured():
182
+ return (
183
+ "cfg_provider" in st.session_state
184
+ and st.session_state.get("cfg_model")
185
+ and (
186
+ st.session_state.get("cfg_provider") == "Custom (self-hosted)"
187
+ or st.session_state.get("api_key")
188
+ )
189
+ )
190
+
191
+
192
+ def render_onboarding():
193
+ """Render the setup flow before chat becomes available."""
194
+ render_page_header(
195
+ "Plexi assistant",
196
+ "Ask course questions with grounded context",
197
+ (
198
+ "Choose a provider, bring your own API key, and Plexi will answer only "
199
+ "from the materials loaded for the subject you pick."
200
+ ),
201
+ badges=["Cited answers", "Scoped retrieval", "OpenAI-compatible"],
202
+ )
203
+
204
+ left_col, right_col = st.columns([1.1, 0.9], gap="large")
205
+
206
+ with left_col:
207
+ st.markdown(
208
+ """
209
+ <section class="plexi-panel">
210
+ <div class="plexi-sidecard-title">Set up your model endpoint</div>
211
+ <div class="plexi-muted">
212
+ Pick a hosted provider or connect a local OpenAI-compatible server.
213
+ </div>
214
+ </section>
215
+ """,
216
+ unsafe_allow_html=True,
217
+ )
218
+
219
+ provider_name = st.selectbox("Provider", PROVIDER_NAMES, key="ob_provider")
220
+ provider = PROVIDERS[provider_name]
221
+
222
+ if provider_name == "Custom (self-hosted)":
223
+ base_url = st.text_input("Base URL", value="http://localhost:11434/v1")
224
+ model_name = st.text_input(
225
+ "Model", placeholder="e.g. llama3, mistral, phi3"
226
+ )
227
+ else:
228
+ base_url = provider["base_url"]
229
+ model_options = provider["models"] + ["Custom"]
230
+ model_choice = st.selectbox("Model", model_options)
231
+ model_name = (
232
+ st.text_input("Custom model ID", placeholder="Enter model identifier")
233
+ if model_choice == "Custom"
234
+ else model_choice
235
+ )
236
+
237
+ needs_key = provider_name != "Custom (self-hosted)"
238
+ api_key = ""
239
+ if needs_key:
240
+ st.info(provider["key_help"])
241
+ api_key = st.text_input(
242
+ "API Key",
243
+ type="password",
244
+ placeholder="Paste your API key here",
245
+ )
246
+
247
+ can_start = bool(model_name and (not needs_key or api_key))
248
+ if st.button(
249
+ "Start Chatting",
250
+ type="primary",
251
+ disabled=not can_start,
252
+ use_container_width=True,
253
+ ):
254
+ st.session_state.cfg_provider = provider_name
255
+ st.session_state.cfg_base_url = base_url
256
+ st.session_state.cfg_model = model_name
257
+ if api_key:
258
+ st.session_state.api_key = api_key
259
+ st.session_state.pop("messages", None)
260
+ st.rerun()
261
+
262
+ with right_col:
263
+ render_panel(
264
+ "What Plexi does",
265
+ "Keeps answers grounded in the currently loaded course materials instead of drifting into generic knowledge.",
266
+ )
267
+ render_panel(
268
+ "Provider model",
269
+ "Bring your own endpoint. Use hosted providers or connect a local OpenAI-compatible server.",
270
+ )
271
+ render_panel(
272
+ "Best use case",
273
+ "Use Plexi for revision summaries, topic breakdowns, viva practice, and quick concept explanations.",
274
+ tone="callout",
275
+ )
276
+ st.markdown(
277
+ """
278
+ <section class="plexi-callout">
279
+ <div class="plexi-sidecard-title">Good prompts to start with</div>
280
+ <ul class="plexi-list">
281
+ <li>Summarize this subject for revision.</li>
282
+ <li>List important topics and cite the source files.</li>
283
+ <li>Explain a concept in simple terms using only the notes.</li>
284
+ </ul>
285
+ </section>
286
+ """,
287
+ unsafe_allow_html=True,
288
+ )
289
+
290
+
291
+ render_sidebar()
292
+
293
+ if not _is_configured():
294
+ render_onboarding()
295
+ st.stop()
296
+
297
+ provider_name = st.session_state.cfg_provider
298
+ base_url = st.session_state.cfg_base_url
299
+ model_name = st.session_state.cfg_model
300
+ api_key = st.session_state.get("api_key", "")
301
+
302
+ rag_index, rag_error = fetch_rag_index()
303
+ rag_active = rag_index is not None
304
+ mode_label = "RAG retrieval" if rag_active else "Full-context fallback"
305
+
306
+ try:
307
+ manifest = get_manifest()
308
+ except Exception as err:
309
+ st.error(f"Failed to load materials catalog: {err}")
310
+ st.stop()
311
+
312
+ if not manifest:
313
+ st.info("No study materials are available yet.")
314
+ st.stop()
315
+
316
+ with st.sidebar:
317
+ st.markdown(
318
+ '<div class="plexi-section-label">Study Scope</div>',
319
+ unsafe_allow_html=True,
320
+ )
321
+ semester_names = sorted(manifest.keys())
322
+ selected_semester = st.selectbox("Semester", semester_names, key="asst_semester")
323
+ subjects = sorted(manifest[selected_semester].keys())
324
+ selected_subject = st.selectbox("Subject", subjects, key="asst_subject")
325
+
326
+ scope_key = f"{selected_semester}|{selected_subject}"
327
+ if st.session_state.get("_scope_key") != scope_key:
328
+ st.session_state._scope_key = scope_key
329
+ st.session_state.pop("messages", None)
330
+
331
+
332
+ @st.cache_data(show_spinner="Loading study materials...", ttl=300)
333
+ def _get_subject_context(semester, subject):
334
+ return load_subject_context(manifest, semester, subject)
335
+
336
+
337
+ subject_text, source_list = _get_subject_context(selected_semester, selected_subject)
338
+ if not subject_text.strip():
339
+ st.warning("No readable text was found for this subject. Try another selection.")
340
+ st.stop()
341
+
342
+ subject_summary = summarize_subject_catalog(
343
+ manifest[selected_semester][selected_subject]
344
+ )
345
+
346
+ with st.sidebar:
347
+ st.markdown(
348
+ f"""
349
+ <section class="plexi-panel">
350
+ <div class="plexi-sidecard-title">{selected_subject}</div>
351
+ <div class="plexi-muted">{selected_semester}</div>
352
+ </section>
353
+ """,
354
+ unsafe_allow_html=True,
355
+ )
356
+ if rag_active:
357
+ st.success("RAG is active. Answers use subject-scoped retrieved chunks.")
358
+ elif rag_error:
359
+ st.warning(f"RAG is unavailable: {rag_error}")
360
+ else:
361
+ st.warning("RAG index is unavailable. Using full-context fallback mode.")
362
+
363
+ with st.expander(f"Loaded sources ({len(source_list)})", expanded=False):
364
+ for source in source_list:
365
+ st.caption(f"[{source['id']}] {source['name']} ({source['type']})")
366
+
367
+ with st.expander("Change LLM settings", expanded=False):
368
+ new_provider = st.selectbox(
369
+ "Provider",
370
+ PROVIDER_NAMES,
371
+ index=PROVIDER_NAMES.index(provider_name),
372
+ key="sb_provider",
373
+ )
374
+ selected_provider = PROVIDERS[new_provider]
375
+
376
+ if new_provider == "Custom (self-hosted)":
377
+ new_base_url = st.text_input("Base URL", value=base_url, key="sb_base_url")
378
+ new_model = st.text_input(
379
+ "Model",
380
+ value=model_name if provider_name == "Custom (self-hosted)" else "",
381
+ key="sb_model_custom",
382
+ placeholder="e.g. llama3, mistral, phi3",
383
+ )
384
+ else:
385
+ new_base_url = selected_provider["base_url"]
386
+ model_options = selected_provider["models"] + ["Custom"]
387
+ default_index = (
388
+ model_options.index(model_name) if model_name in model_options else 0
389
+ )
390
+ selected_model = st.selectbox(
391
+ "Model",
392
+ model_options,
393
+ index=default_index,
394
+ key="sb_model_select",
395
+ )
396
+ new_model = (
397
+ st.text_input("Custom model ID", key="sb_model_id")
398
+ if selected_model == "Custom"
399
+ else selected_model
400
+ )
401
+
402
+ new_needs_key = new_provider != "Custom (self-hosted)"
403
+ new_key = api_key
404
+ if new_needs_key:
405
+ new_key = st.text_input(
406
+ "API Key",
407
+ type="password",
408
+ value=api_key,
409
+ key="sb_api_key",
410
+ )
411
+
412
+ changed = (
413
+ new_provider != provider_name
414
+ or new_base_url != base_url
415
+ or new_model != model_name
416
+ or new_key != api_key
417
+ )
418
+ if changed and new_model:
419
+ if st.button("Apply Changes", use_container_width=True, type="primary"):
420
+ st.session_state.cfg_provider = new_provider
421
+ st.session_state.cfg_base_url = new_base_url
422
+ st.session_state.cfg_model = new_model
423
+ if new_key:
424
+ st.session_state.api_key = new_key
425
+ elif "api_key" in st.session_state:
426
+ del st.session_state.api_key
427
+ st.session_state.pop("messages", None)
428
+ st.rerun()
429
+
430
+ if st.button("New Chat", use_container_width=True):
431
+ st.session_state.pop("messages", None)
432
+ st.rerun()
433
+
434
+ render_page_header(
435
+ "Plexi assistant",
436
+ f"Ask anything from {selected_subject}",
437
+ (
438
+ "The assistant is currently grounded to the selected subject. It will stay inside "
439
+ "that scope and answer only from the loaded materials."
440
+ ),
441
+ badges=[selected_semester, selected_subject, provider_name, mode_label],
442
+ )
443
+
444
+ render_stat_cards(
445
+ [
446
+ {
447
+ "label": "Loaded sources",
448
+ "value": len(source_list),
449
+ "note": "Readable files currently available to cite in this chat.",
450
+ },
451
+ {
452
+ "label": "Subject files",
453
+ "value": subject_summary["file_count"],
454
+ "note": "Assets available for the selected subject in the catalog.",
455
+ },
456
+ {
457
+ "label": "Provider",
458
+ "value": provider_name,
459
+ "note": model_name,
460
+ },
461
+ {
462
+ "label": "Answer mode",
463
+ "value": "RAG" if rag_active else "Fallback",
464
+ "note": rag_error or "Top matching chunks are injected into the prompt.",
465
+ },
466
+ ]
467
+ )
468
+
469
+ st.markdown(
470
+ '<div class="plexi-section-label">Prompt Starters</div>',
471
+ unsafe_allow_html=True,
472
+ )
473
+ prompt_cols = st.columns(len(PROMPT_SUGGESTIONS), gap="medium")
474
+ for column, (label, prompt_text) in zip(prompt_cols, PROMPT_SUGGESTIONS):
475
+ with column:
476
+ if st.button(label, use_container_width=True, key=f"prompt_{label}"):
477
+ queue_prompt(prompt_text)
478
+
479
+ st.markdown(
480
+ """
481
+ <section class="plexi-panel">
482
+ <div class="plexi-sidecard-title">Study scope loaded</div>
483
+ <div class="plexi-muted">
484
+ Ask for summaries, examples, key differences, exam revision prompts, or viva-style
485
+ questions. Plexi will stay inside the loaded subject context.
486
+ </div>
487
+ </section>
488
+ """,
489
+ unsafe_allow_html=True,
490
+ )
491
+
492
+ source_index = "\n".join(
493
+ f" [{src['id']}] {src['name']} ({src['type']})" for src in source_list
494
+ )
495
+
496
+
497
+ def build_system_prompt(retrieved_chunks):
498
+ if rag_active and retrieved_chunks:
499
+ context_section = (
500
+ "## RETRIEVED CONTEXT (most relevant chunks for this query)\n"
501
+ f"{format_context(retrieved_chunks)}\n"
502
+ "## END OF RETRIEVED CONTEXT"
503
+ )
504
+ else:
505
+ context_section = f"## SOURCE MATERIALS\n{subject_text}\n## END OF MATERIALS"
506
+
507
+ return (
508
+ "Your name is Plexi. You are an academic assistant for Parul University CS students.\n\n"
509
+ "## STRICT GROUNDING RULES\n"
510
+ "1. Answer ONLY using information found in the provided context below.\n"
511
+ "2. Do NOT include inline citation markers like [Source 1] in the response.\n"
512
+ "3. If the answer is NOT in the context, say: 'This information is not covered in the loaded materials.'\n"
513
+ " Do NOT guess or use general knowledge.\n"
514
+ "4. Use clear structure: headings, bullet points, bold key terms.\n\n"
515
+ "## INTERACTION STYLE\n"
516
+ "- Greet users and list covered topics when greeted.\n"
517
+ "- If asked about your creator, say: Kunal Gupta (LazyHuman).\n"
518
+ "- Write natural, clean answers without a sources footer unless the user explicitly asks for sources.\n\n"
519
+ f"## SOURCE INDEX\n{source_index}\n\n"
520
+ f"{context_section}"
521
+ )
522
+
523
+
524
+ if "messages" not in st.session_state:
525
+ st.session_state.messages = [
526
+ {
527
+ "role": "assistant",
528
+ "content": (
529
+ f"Hi! I am loaded with **{selected_subject}** from **{selected_semester}**. "
530
+ f"Mode: *{mode_label}*. Ask me for summaries, explanations, or revision help."
531
+ ),
532
+ }
533
+ ]
534
+
535
+ for message in st.session_state.messages:
536
+ with st.chat_message(message["role"]):
537
+ st.markdown(message["content"])
538
+
539
+ pending_prompt = st.session_state.pop("_pending_prompt", None)
540
+ prompt = pending_prompt or st.chat_input("Ask about your loaded study materials")
541
+
542
+ if prompt:
543
+ if not pending_prompt:
544
+ st.session_state.messages.append({"role": "user", "content": prompt})
545
+ with st.chat_message("user"):
546
+ st.markdown(prompt)
547
+
548
+ with st.chat_message("assistant"):
549
+ with st.spinner("Thinking..."):
550
+ retrieved = (
551
+ local_retrieve(rag_index, prompt, selected_semester, selected_subject)
552
+ if rag_active
553
+ else []
554
+ )
555
+ system_prompt = build_system_prompt(retrieved)
556
+
557
+ history = [
558
+ message
559
+ for message in st.session_state.messages[1:-1]
560
+ if message["role"] in ("user", "assistant")
561
+ ]
562
+
563
+ try:
564
+ answer = _send_message(
565
+ base_url, api_key, model_name, system_prompt, history, prompt
566
+ )
567
+ except Exception as err:
568
+ err_text = str(err)
569
+ if "RATE_LIMITED" in err_text:
570
+ st.session_state["_pending_prompt"] = prompt
571
+ st.error("API rate limit reached. Wait a minute and press Retry.")
572
+ if st.button("Retry", type="primary"):
573
+ st.rerun()
574
+ st.stop()
575
+ if "AUTH_ERROR" in err_text:
576
+ st.error(err_text.split(": ", 1)[1])
577
+ if "api_key" in st.session_state:
578
+ del st.session_state.api_key
579
+ st.session_state.messages.pop()
580
+ st.stop()
581
+ raise
582
+
583
+ st.markdown(answer)
584
+
585
+ if retrieved:
586
+ with st.expander("Retrieved context chunks", expanded=False):
587
+ for index, chunk in enumerate(retrieved, start=1):
588
+ label = (
589
+ chunk.get("filename")
590
+ or chunk.get("subject")
591
+ or "Unknown source"
592
+ )
593
+ st.caption(
594
+ f"Chunk {index} - {label} - relevance: {chunk.get('score')}"
595
+ )
596
+ preview = chunk["text"][:400] + (
597
+ "..." if len(chunk["text"]) > 400 else ""
598
+ )
599
+ st.text(preview)
600
+
601
+ st.session_state.messages.append({"role": "assistant", "content": answer})
pages/Study_Material_Hub.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from html import escape
2
+
3
+ import streamlit as st
4
+ from streamlit_pdf_viewer import pdf_viewer
5
+ from utils import (
6
+ download_github_file,
7
+ get_manifest,
8
+ get_mime_type,
9
+ inject_theme,
10
+ render_page_header,
11
+ render_sidebar,
12
+ render_stat_cards,
13
+ summarize_manifest,
14
+ summarize_subject_catalog,
15
+ )
16
+
17
+ st.set_page_config(page_title="Study Materials Hub", layout="wide")
18
+ inject_theme()
19
+
20
+
21
+ def display_pdf(file_content):
22
+ """Display PDF using streamlit-pdf-viewer."""
23
+ pdf_viewer(file_content, width="100%", height=700)
24
+
25
+
26
+ def display_ppt(file_content, filename):
27
+ """Show PPT download button when inline preview is unavailable."""
28
+ st.info("PowerPoint files cannot be previewed inline yet. Use the download button.")
29
+ st.download_button(
30
+ label="Download PPT",
31
+ data=file_content,
32
+ file_name=filename,
33
+ mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
34
+ use_container_width=True,
35
+ type="primary",
36
+ )
37
+
38
+
39
+ try:
40
+ manifest = get_manifest()
41
+ except Exception as err:
42
+ st.error(f"Failed to load materials catalog: {err}")
43
+ st.stop()
44
+
45
+ semester_names = sorted(manifest.keys()) if manifest else []
46
+ catalog_summary = summarize_manifest(manifest) if manifest else None
47
+
48
+ if not semester_names:
49
+ st.info("No study materials are available yet. Check back later.")
50
+ st.stop()
51
+
52
+ selected_semester = None
53
+ selected_subject = None
54
+ selected_type = None
55
+ selected_file_name = None
56
+
57
+ with st.container():
58
+ render_page_header(
59
+ "Material hub",
60
+ "Browse the catalog without losing context",
61
+ (
62
+ "Move from semester to file in a single flow, preview PDFs in place, and "
63
+ "download the exact asset you want from the shared materials repository."
64
+ ),
65
+ badges=[
66
+ f"{catalog_summary['semester_count']} semesters"
67
+ if catalog_summary
68
+ else None,
69
+ f"{catalog_summary['file_count']} files" if catalog_summary else None,
70
+ "Inline PDF preview",
71
+ ],
72
+ )
73
+
74
+ st.markdown(
75
+ '<div class="plexi-section-label">Refine Your Selection</div>',
76
+ unsafe_allow_html=True,
77
+ )
78
+ st.markdown(
79
+ """
80
+ <section class="plexi-panel">
81
+ <div class="plexi-sidecard-title">Catalog filters</div>
82
+ <div class="plexi-muted">
83
+ Narrow the collection by semester, then drill into one subject and file.
84
+ </div>
85
+ </section>
86
+ """,
87
+ unsafe_allow_html=True,
88
+ )
89
+
90
+ filter_cols = st.columns(4, gap="medium")
91
+ with filter_cols[0]:
92
+ selected_semester = st.selectbox("Semester", semester_names, key="hub_semester")
93
+
94
+ subjects = sorted(manifest[selected_semester].keys())
95
+ with filter_cols[1]:
96
+ selected_subject = st.selectbox("Subject", subjects, key="hub_subject")
97
+
98
+ subject_data = manifest[selected_semester][selected_subject]
99
+ subject_summary = summarize_subject_catalog(subject_data)
100
+ types = subject_summary["types"]
101
+ with filter_cols[2]:
102
+ selected_type = st.selectbox("Material Type", types, key="hub_type")
103
+
104
+ files_list = subject_data[selected_type]
105
+ file_names = [file_entry["name"] for file_entry in files_list]
106
+ with filter_cols[3]:
107
+ selected_file_name = (
108
+ st.selectbox("File", file_names, key="hub_file") if file_names else None
109
+ )
110
+
111
+ selected_file_obj = (
112
+ next((item for item in files_list if item["name"] == selected_file_name), None)
113
+ if selected_file_name
114
+ else None
115
+ )
116
+
117
+ render_stat_cards(
118
+ [
119
+ {
120
+ "label": "Current Subject",
121
+ "value": selected_subject,
122
+ "note": f"{selected_semester} collection currently in focus.",
123
+ },
124
+ {
125
+ "label": "Available Files",
126
+ "value": subject_summary["file_count"],
127
+ "note": "All assets available for this subject across material types.",
128
+ },
129
+ {
130
+ "label": "Material Types",
131
+ "value": subject_summary["type_count"],
132
+ "note": ", ".join(subject_summary["types"]),
133
+ },
134
+ {
135
+ "label": "Current Bucket",
136
+ "value": len(files_list),
137
+ "note": f"Files available inside {selected_type}.",
138
+ },
139
+ ]
140
+ )
141
+
142
+ render_sidebar()
143
+
144
+ if not selected_file_obj:
145
+ st.info("No files were found for this combination yet.")
146
+ st.stop()
147
+
148
+ try:
149
+ file_content = download_github_file(selected_file_obj["download_url"])
150
+ file_mime = get_mime_type(selected_file_obj["name"])
151
+ except Exception as err:
152
+ st.error(f"Error loading file: {err}")
153
+ st.stop()
154
+
155
+ if not file_content:
156
+ st.error("The selected file could not be downloaded.")
157
+ st.stop()
158
+
159
+ st.markdown(
160
+ '<div class="plexi-section-label">Preview And Download</div>',
161
+ unsafe_allow_html=True,
162
+ )
163
+
164
+ preview_col, info_col = st.columns([1.7, 0.95], gap="large")
165
+
166
+ with preview_col:
167
+ st.markdown(
168
+ f"""
169
+ <section class="plexi-panel">
170
+ <div class="plexi-kicker">{selected_semester}</div>
171
+ <div class="plexi-sidecard-title">{selected_file_obj["name"]}</div>
172
+ <div class="plexi-muted">
173
+ {selected_subject} / {selected_type}
174
+ </div>
175
+ </section>
176
+ """,
177
+ unsafe_allow_html=True,
178
+ )
179
+
180
+ if file_mime == "application/pdf":
181
+ display_pdf(file_content)
182
+ elif file_mime in [
183
+ "application/vnd.ms-powerpoint",
184
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
185
+ ]:
186
+ display_ppt(file_content, selected_file_obj["name"])
187
+ else:
188
+ st.info(
189
+ "Preview is not available for this file type. Download it to inspect the content."
190
+ )
191
+
192
+ with info_col:
193
+ st.markdown(
194
+ """
195
+ <section class="plexi-callout">
196
+ <div class="plexi-sidecard-title">Selected file</div>
197
+ <div class="plexi-muted">
198
+ Download the current file or switch to another asset in the same bucket.
199
+ </div>
200
+ </section>
201
+ """,
202
+ unsafe_allow_html=True,
203
+ )
204
+ st.download_button(
205
+ label="Download File",
206
+ data=file_content,
207
+ file_name=selected_file_obj["name"],
208
+ mime=file_mime,
209
+ use_container_width=True,
210
+ type="primary",
211
+ )
212
+
213
+ st.markdown(
214
+ """
215
+ <section class="plexi-panel">
216
+ <div class="plexi-sidecard-title">File details</div>
217
+ </section>
218
+ """,
219
+ unsafe_allow_html=True,
220
+ )
221
+ st.markdown(
222
+ f"""
223
+ <section class="plexi-meta">
224
+ <div class="plexi-meta-row">
225
+ <div class="plexi-meta-key">Semester</div>
226
+ <div class="plexi-meta-value">{escape(selected_semester)}</div>
227
+ </div>
228
+ <div class="plexi-meta-row">
229
+ <div class="plexi-meta-key">Subject</div>
230
+ <div class="plexi-meta-value">{escape(selected_subject)}</div>
231
+ </div>
232
+ <div class="plexi-meta-row">
233
+ <div class="plexi-meta-key">Material Type</div>
234
+ <div class="plexi-meta-value">{escape(selected_type)}</div>
235
+ </div>
236
+ <div class="plexi-meta-row">
237
+ <div class="plexi-meta-key">Format</div>
238
+ <div class="plexi-meta-value">{escape(file_mime)}</div>
239
+ </div>
240
+ </section>
241
+ """,
242
+ unsafe_allow_html=True,
243
+ )
244
+
245
+ st.markdown(
246
+ """
247
+ <section class="plexi-panel">
248
+ <div class="plexi-sidecard-title">More in this bucket</div>
249
+ </section>
250
+ """,
251
+ unsafe_allow_html=True,
252
+ )
253
+ bucket_items = []
254
+ for file_name in file_names:
255
+ item_class = "current" if file_name == selected_file_obj["name"] else ""
256
+ label = "Current" if file_name == selected_file_obj["name"] else "Available"
257
+ bucket_items.append(
258
+ f'<li class="{item_class}">{escape(label)}: {escape(file_name)}</li>'
259
+ )
260
+ st.markdown(
261
+ f'<section class="plexi-meta"><ul class="plexi-filelist">{"".join(bucket_items)}</ul></section>',
262
+ unsafe_allow_html=True,
263
+ )
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ python-dotenv
3
+ requests
4
+ PyPDF2
5
+ streamlit-pdf-viewer
6
+ llama-index-core
7
+ llama-index-embeddings-huggingface
8
+ sentence-transformers
startup.sh ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #!/bin/bash
2
+ python -m streamlit run Home.py --server.port 8000 --server.address 0.0.0.0
utils.py ADDED
@@ -0,0 +1,950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import mimetypes
3
+ import os
4
+ import tempfile
5
+ from html import escape
6
+ from string import Template
7
+
8
+ import PyPDF2
9
+ import requests
10
+ import streamlit as st
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ # LlamaIndex imports for RAG retrieval
16
+ try:
17
+ from llama_index.core import Settings, StorageContext, load_index_from_storage
18
+ from llama_index.embeddings.huggingface import HuggingFaceEmbedding
19
+
20
+ LLAMA_INDEX_AVAILABLE = True
21
+ except ImportError:
22
+ LLAMA_INDEX_AVAILABLE = False
23
+
24
+ # GitHub repo that hosts study materials via Releases + manifest.json
25
+ # Format: "owner/repo"
26
+ MATERIALS_REPO = os.getenv("MATERIALS_REPO", "KunalGupta25/plexi-materials")
27
+ MANIFEST_BRANCH = "main"
28
+ THEME_MODE_STATE_KEY = "plexi_theme_mode"
29
+ THEME_MODE_WIDGET_KEY = "_plexi_theme_mode_widget"
30
+
31
+ LIGHT_PALETTE = {
32
+ "ink": "#16312c",
33
+ "muted": "#5b6c66",
34
+ "bg": "#f5f0e8",
35
+ "panel": "rgba(255, 252, 247, 0.88)",
36
+ "panel_strong": "#fffaf1",
37
+ "line": "rgba(22, 49, 44, 0.11)",
38
+ "accent": "#1d7a63",
39
+ "accent_soft": "#d7efe4",
40
+ "highlight": "#f4b860",
41
+ "shadow": "0 18px 60px rgba(30, 48, 43, 0.08)",
42
+ "app_background": """
43
+ radial-gradient(circle at top left, rgba(244, 184, 96, 0.18), transparent 28%),
44
+ radial-gradient(circle at top right, rgba(29, 122, 99, 0.14), transparent 30%),
45
+ linear-gradient(180deg, #fbf7ef 0%, #f4ecde 100%)
46
+ """,
47
+ "hero_background": """
48
+ linear-gradient(135deg, rgba(29, 122, 99, 0.08), rgba(255, 250, 241, 0.92)),
49
+ rgba(255, 252, 247, 0.88)
50
+ """,
51
+ "chip_background": "rgba(29, 122, 99, 0.08)",
52
+ "chip_border": "rgba(29, 122, 99, 0.12)",
53
+ "button_border": "rgba(29, 122, 99, 0.14)",
54
+ "button_surface": "#f8fbfa",
55
+ "button_hover": "#eef7f2",
56
+ "primary_button": "linear-gradient(135deg, #1d7a63, #245e74)",
57
+ "sidebar_background": """
58
+ linear-gradient(180deg, rgba(255, 251, 245, 0.98), rgba(246, 238, 224, 0.96))
59
+ """,
60
+ "expander_background": "rgba(255, 251, 245, 0.72)",
61
+ "meta_background": "rgba(255, 251, 245, 0.72)",
62
+ "divider": "linear-gradient(90deg, rgba(29, 122, 99, 0.25), transparent)",
63
+ "meta_row_border": "rgba(22, 49, 44, 0.08)",
64
+ "bottom_background": "#fbf7ef",
65
+ }
66
+
67
+ DARK_PALETTE = {
68
+ "ink": "#eef4ef",
69
+ "muted": "#b8c6c0",
70
+ "bg": "#0d1715",
71
+ "panel": "rgba(20, 31, 29, 0.9)",
72
+ "panel_strong": "#15211f",
73
+ "line": "rgba(196, 223, 211, 0.14)",
74
+ "accent": "#54c6a2",
75
+ "accent_soft": "#17392f",
76
+ "highlight": "#f0b564",
77
+ "shadow": "0 22px 70px rgba(0, 0, 0, 0.32)",
78
+ "app_background": """
79
+ radial-gradient(circle at top left, rgba(240, 181, 100, 0.12), transparent 28%),
80
+ radial-gradient(circle at top right, rgba(84, 198, 162, 0.12), transparent 32%),
81
+ linear-gradient(180deg, #0f1b19 0%, #09110f 100%)
82
+ """,
83
+ "hero_background": """
84
+ linear-gradient(135deg, rgba(84, 198, 162, 0.12), rgba(16, 28, 25, 0.92)),
85
+ rgba(20, 31, 29, 0.9)
86
+ """,
87
+ "chip_background": "rgba(84, 198, 162, 0.12)",
88
+ "chip_border": "rgba(84, 198, 162, 0.18)",
89
+ "button_border": "rgba(84, 198, 162, 0.18)",
90
+ "button_surface": "rgba(84, 198, 162, 0.14)",
91
+ "button_hover": "rgba(84, 198, 162, 0.22)",
92
+ "primary_button": "linear-gradient(135deg, #2ea483, #245e74)",
93
+ "sidebar_background": """
94
+ linear-gradient(180deg, rgba(17, 28, 26, 0.98), rgba(12, 20, 18, 0.97))
95
+ """,
96
+ "expander_background": "rgba(17, 28, 26, 0.84)",
97
+ "meta_background": "rgba(19, 31, 28, 0.84)",
98
+ "divider": "linear-gradient(90deg, rgba(84, 198, 162, 0.32), transparent)",
99
+ "meta_row_border": "rgba(196, 223, 211, 0.1)",
100
+ "bottom_background": "#09110f",
101
+ }
102
+
103
+
104
+ def get_theme_mode():
105
+ """Return the selected appearance mode."""
106
+ if THEME_MODE_STATE_KEY not in st.session_state:
107
+ st.session_state[THEME_MODE_STATE_KEY] = "system"
108
+ return st.session_state[THEME_MODE_STATE_KEY]
109
+
110
+
111
+ def sync_theme_mode():
112
+ """Persist the appearance selector value across page switches."""
113
+ st.session_state[THEME_MODE_STATE_KEY] = st.session_state.get(
114
+ THEME_MODE_WIDGET_KEY, "System"
115
+ ).lower()
116
+
117
+
118
+ def _css_vars_block(palette):
119
+ """Return CSS custom property definitions for a palette."""
120
+ return "\n".join(
121
+ [
122
+ f" --plexi-ink: {palette['ink']};",
123
+ f" --plexi-muted: {palette['muted']};",
124
+ f" --plexi-bg: {palette['bg']};",
125
+ f" --plexi-panel: {palette['panel']};",
126
+ f" --plexi-panel-strong: {palette['panel_strong']};",
127
+ f" --plexi-line: {palette['line']};",
128
+ f" --plexi-accent: {palette['accent']};",
129
+ f" --plexi-accent-soft: {palette['accent_soft']};",
130
+ f" --plexi-highlight: {palette['highlight']};",
131
+ f" --plexi-shadow: {palette['shadow']};",
132
+ f" --plexi-app-background: {palette['app_background']};",
133
+ f" --plexi-hero-background: {palette['hero_background']};",
134
+ f" --plexi-chip-background: {palette['chip_background']};",
135
+ f" --plexi-chip-border: {palette['chip_border']};",
136
+ f" --plexi-button-border: {palette['button_border']};",
137
+ f" --plexi-button-surface: {palette['button_surface']};",
138
+ f" --plexi-button-hover: {palette['button_hover']};",
139
+ f" --plexi-primary-button: {palette['primary_button']};",
140
+ f" --plexi-sidebar-background: {palette['sidebar_background']};",
141
+ f" --plexi-expander-background: {palette['expander_background']};",
142
+ f" --plexi-meta-background: {palette['meta_background']};",
143
+ f" --plexi-divider: {palette['divider']};",
144
+ f" --plexi-meta-row-border: {palette['meta_row_border']};",
145
+ f" --plexi-bottom-background: {palette['bottom_background']};",
146
+ ]
147
+ )
148
+
149
+
150
+ def inject_theme():
151
+ """Inject the shared visual language for the Streamlit app."""
152
+ theme_mode = get_theme_mode()
153
+ palette = DARK_PALETTE if theme_mode == "dark" else LIGHT_PALETTE
154
+ system_css = ""
155
+ color_scheme = "dark" if theme_mode == "dark" else "light"
156
+ if theme_mode == "system":
157
+ system_css = f"""
158
+ @media (prefers-color-scheme: dark) {{
159
+ :root {{
160
+ {_css_vars_block(DARK_PALETTE)}
161
+ }}
162
+
163
+ html {{
164
+ color-scheme: dark;
165
+ }}
166
+ }}
167
+ """
168
+ css = Template(
169
+ """
170
+ <style>
171
+ @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Space+Grotesk:wght@400;500;700&display=swap');
172
+
173
+ :root {
174
+ $palette_vars
175
+ }
176
+
177
+ html, body, [class*="css"] {
178
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
179
+ }
180
+
181
+ html {
182
+ color-scheme: $color_scheme;
183
+ }
184
+
185
+ .stApp {
186
+ background: var(--plexi-app-background);
187
+ color: var(--plexi-ink);
188
+ }
189
+
190
+ header[data-testid="stHeader"] {
191
+ background: transparent !important;
192
+ }
193
+
194
+ div[data-testid="stToolbar"] {
195
+ background: transparent !important;
196
+ }
197
+
198
+ div[data-testid="stAppViewContainer"] {
199
+ background: transparent;
200
+ }
201
+
202
+ .block-container {
203
+ padding-top: 2.2rem;
204
+ padding-bottom: 3rem;
205
+ }
206
+
207
+ h1, h2, h3 {
208
+ color: var(--plexi-ink);
209
+ }
210
+
211
+ h1, .plexi-title {
212
+ font-family: "DM Serif Display", Georgia, serif;
213
+ letter-spacing: -0.03em;
214
+ }
215
+
216
+ p, li, .stMarkdown, .stCaption, .stChatMessage {
217
+ color: var(--plexi-ink);
218
+ }
219
+
220
+ .plexi-hero,
221
+ .plexi-panel,
222
+ .plexi-stat,
223
+ .plexi-sidecard,
224
+ .plexi-callout {
225
+ background: var(--plexi-panel);
226
+ border: 1px solid var(--plexi-line);
227
+ border-radius: 24px;
228
+ box-shadow: var(--plexi-shadow);
229
+ }
230
+
231
+ .plexi-hero {
232
+ padding: 1.8rem 1.9rem;
233
+ margin-bottom: 1.1rem;
234
+ background: var(--plexi-hero-background);
235
+ }
236
+
237
+ .plexi-kicker {
238
+ text-transform: uppercase;
239
+ letter-spacing: 0.16em;
240
+ font-size: 0.72rem;
241
+ font-weight: 700;
242
+ color: var(--plexi-accent);
243
+ margin-bottom: 0.65rem;
244
+ }
245
+
246
+ .plexi-title {
247
+ font-size: clamp(2.2rem, 5vw, 4.2rem);
248
+ margin: 0;
249
+ line-height: 0.95;
250
+ }
251
+
252
+ .plexi-subtitle {
253
+ max-width: 48rem;
254
+ margin: 0.8rem 0 0;
255
+ color: var(--plexi-muted);
256
+ font-size: 1rem;
257
+ line-height: 1.65;
258
+ }
259
+
260
+ .plexi-chip-row {
261
+ display: flex;
262
+ gap: 0.55rem;
263
+ flex-wrap: wrap;
264
+ margin-top: 1rem;
265
+ }
266
+
267
+ .plexi-chip {
268
+ display: inline-flex;
269
+ align-items: center;
270
+ gap: 0.35rem;
271
+ padding: 0.45rem 0.8rem;
272
+ border-radius: 999px;
273
+ background: var(--plexi-chip-background);
274
+ border: 1px solid var(--plexi-chip-border);
275
+ font-size: 0.82rem;
276
+ color: var(--plexi-ink);
277
+ }
278
+
279
+ .plexi-panel,
280
+ .plexi-callout,
281
+ .plexi-sidecard {
282
+ padding: 1.15rem 1.2rem;
283
+ margin-bottom: 1rem;
284
+ }
285
+
286
+ .plexi-stat {
287
+ padding: 1rem 1.15rem;
288
+ min-height: 8.5rem;
289
+ overflow: hidden;
290
+ }
291
+
292
+ .plexi-stat-label {
293
+ color: var(--plexi-muted);
294
+ font-size: 0.82rem;
295
+ text-transform: uppercase;
296
+ letter-spacing: 0.08em;
297
+ }
298
+
299
+ .plexi-stat-value {
300
+ font-family: "DM Serif Display", Georgia, serif;
301
+ font-size: clamp(1.5rem, 2.1vw, 2.1rem);
302
+ line-height: 1.08;
303
+ margin: 0.35rem 0 0.4rem;
304
+ overflow-wrap: anywhere;
305
+ }
306
+
307
+ .plexi-stat-note,
308
+ .plexi-muted {
309
+ color: var(--plexi-muted);
310
+ font-size: 0.92rem;
311
+ line-height: 1.55;
312
+ }
313
+
314
+ .plexi-section-label {
315
+ margin: 1.6rem 0 0.8rem;
316
+ text-transform: uppercase;
317
+ letter-spacing: 0.12em;
318
+ color: var(--plexi-accent);
319
+ font-size: 0.74rem;
320
+ font-weight: 700;
321
+ }
322
+
323
+ .plexi-list {
324
+ margin: 0;
325
+ padding-left: 1rem;
326
+ color: var(--plexi-muted);
327
+ line-height: 1.7;
328
+ }
329
+
330
+ .plexi-cta-grid {
331
+ display: grid;
332
+ grid-template-columns: repeat(2, minmax(0, 1fr));
333
+ gap: 1rem;
334
+ margin: 1rem 0 1.4rem;
335
+ align-items: stretch;
336
+ }
337
+
338
+ .plexi-cta-button {
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: center;
342
+ width: 100%;
343
+ min-height: 3.6rem;
344
+ padding: 0.9rem 1.2rem;
345
+ border-radius: 999px;
346
+ text-decoration: none !important;
347
+ font-weight: 700;
348
+ font-size: 1.02rem;
349
+ color: #ffffff !important;
350
+ background: linear-gradient(135deg, #3bb192, #2b728a);
351
+ box-shadow: 0 16px 40px rgba(38, 109, 107, 0.22);
352
+ border: none;
353
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
354
+ }
355
+
356
+ .plexi-cta-button:hover {
357
+ color: #ffffff !important;
358
+ transform: translateY(-1px);
359
+ box-shadow: 0 18px 44px rgba(38, 109, 107, 0.28);
360
+ }
361
+
362
+ .plexi-prompt button,
363
+ .stButton > button,
364
+ .stDownloadButton > button,
365
+ .stLinkButton > a {
366
+ border-radius: 999px !important;
367
+ }
368
+
369
+ .stButton > button,
370
+ .stDownloadButton > button,
371
+ .stLinkButton > a {
372
+ border: 1px solid var(--plexi-button-border);
373
+ min-height: 2.85rem;
374
+ background: var(--plexi-button-surface);
375
+ color: var(--plexi-ink) !important;
376
+ box-shadow: none !important;
377
+ }
378
+
379
+ .stButton > button[kind="primary"],
380
+ .stDownloadButton > button[kind="primary"] {
381
+ background: var(--plexi-primary-button);
382
+ color: white;
383
+ border: none;
384
+ }
385
+
386
+ .stLinkButton > a {
387
+ display: flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ text-decoration: none !important;
391
+ }
392
+
393
+ .stLinkButton > a:hover,
394
+ .stButton > button:hover,
395
+ .stDownloadButton > button:hover {
396
+ border-color: var(--plexi-accent);
397
+ background: var(--plexi-button-hover);
398
+ color: var(--plexi-ink) !important;
399
+ }
400
+
401
+ .stButton > button:disabled,
402
+ .stDownloadButton > button:disabled,
403
+ .stLinkButton > a[disabled] {
404
+ opacity: 0.55;
405
+ color: var(--plexi-muted) !important;
406
+ }
407
+
408
+ .stTextInput input,
409
+ .stSelectbox [data-baseweb="select"] > div,
410
+ .stTextArea textarea,
411
+ .stChatInput textarea {
412
+ background: var(--plexi-panel-strong) !important;
413
+ color: var(--plexi-ink) !important;
414
+ border-color: var(--plexi-line) !important;
415
+ }
416
+
417
+ .stSelectbox [data-baseweb="select"] *,
418
+ .stTextInput input::placeholder,
419
+ .stTextArea textarea::placeholder,
420
+ .stChatInput textarea::placeholder {
421
+ color: var(--plexi-muted) !important;
422
+ }
423
+
424
+ div[data-baseweb="select"] svg,
425
+ div[data-baseweb="select"] path {
426
+ color: var(--plexi-accent) !important;
427
+ fill: var(--plexi-accent) !important;
428
+ }
429
+
430
+ .stChatInputContainer,
431
+ div[data-testid="stChatMessage"] {
432
+ border-radius: 22px;
433
+ }
434
+
435
+ div[data-testid="stBottomBlockContainer"],
436
+ div[data-testid="stBottomBlockContainer"] > div,
437
+ div[data-testid="stBottomBlockContainer"] > div > div,
438
+ div[data-testid="stChatInput"],
439
+ div[data-testid="stChatInput"] > div,
440
+ div[data-testid="stChatInput"] form,
441
+ div[data-testid="stChatInput"] form > div,
442
+ .stChatInputContainer {
443
+ background: var(--plexi-bottom-background) !important;
444
+ }
445
+
446
+ div[data-testid="stChatInput"] {
447
+ border-top: none !important;
448
+ padding-top: 0.5rem;
449
+ }
450
+
451
+ div[data-testid="stChatInput"] textarea,
452
+ div[data-testid="stChatInput"] section,
453
+ div[data-testid="stChatInput"] [data-baseweb="textarea"] {
454
+ background: var(--plexi-panel-strong) !important;
455
+ color: var(--plexi-ink) !important;
456
+ border-color: var(--plexi-line) !important;
457
+ }
458
+
459
+ div[data-testid="stChatInput"] button {
460
+ background: var(--plexi-panel-strong) !important;
461
+ color: var(--plexi-accent) !important;
462
+ border: 1px solid var(--plexi-button-border) !important;
463
+ }
464
+
465
+ div[data-testid="stChatInput"] button svg,
466
+ div[data-testid="stChatInput"] button path {
467
+ fill: currentColor !important;
468
+ }
469
+
470
+ div[data-testid="stSidebar"],
471
+ div[data-testid="stSidebar"] > div,
472
+ section[data-testid="stSidebar"] {
473
+ background: var(--plexi-sidebar-background);
474
+ border-right: 1px solid var(--plexi-line);
475
+ }
476
+
477
+ div[data-testid="stSidebar"] .block-container {
478
+ padding-top: 1.2rem;
479
+ }
480
+
481
+ div[data-testid="stSidebarNav"],
482
+ div[data-testid="stSidebarNav"] ul,
483
+ div[data-testid="stSidebarNav"] li,
484
+ div[data-testid="stSidebarUserContent"] {
485
+ background: transparent !important;
486
+ }
487
+
488
+ div[data-testid="stSidebarNav"] a,
489
+ div[data-testid="stSidebarNav"] span,
490
+ div[data-testid="stSidebarNav"] button {
491
+ color: var(--plexi-ink) !important;
492
+ }
493
+
494
+ div[data-testid="stSidebarNav"] a:hover {
495
+ background: rgba(255, 255, 255, 0.04);
496
+ }
497
+
498
+ div[data-testid="stExpander"] {
499
+ border-radius: 18px;
500
+ border-color: var(--plexi-line);
501
+ background: var(--plexi-expander-background);
502
+ }
503
+
504
+ div[data-baseweb="popover"],
505
+ div[data-baseweb="popover"] > div,
506
+ div[data-baseweb="popover"] > div > div,
507
+ div[data-baseweb="popover"] > div > div > div,
508
+ div[data-baseweb="menu"],
509
+ div[data-baseweb="menu"] > div,
510
+ div[role="listbox"],
511
+ ul[role="listbox"] {
512
+ background: var(--plexi-panel-strong) !important;
513
+ color: var(--plexi-ink) !important;
514
+ border: 1px solid var(--plexi-line) !important;
515
+ box-shadow: var(--plexi-shadow) !important;
516
+ }
517
+
518
+ div[data-baseweb="popover"] *,
519
+ div[data-baseweb="menu"] *,
520
+ div[role="listbox"] *,
521
+ ul[role="listbox"] * {
522
+ color: var(--plexi-ink) !important;
523
+ }
524
+
525
+ div[data-baseweb="popover"] ul,
526
+ div[data-baseweb="popover"] li,
527
+ div[data-baseweb="popover"] li > div,
528
+ div[data-baseweb="menu"] ul,
529
+ div[data-baseweb="menu"] li,
530
+ div[data-baseweb="menu"] li > div {
531
+ background: var(--plexi-panel-strong) !important;
532
+ }
533
+
534
+ li[role="option"],
535
+ li[role="option"] > div,
536
+ li[role="option"] * {
537
+ background: transparent !important;
538
+ color: var(--plexi-ink) !important;
539
+ }
540
+
541
+ li[role="option"]:hover,
542
+ li[role="option"]:hover > div,
543
+ li[role="option"]:hover *,
544
+ li[role="option"][aria-selected="true"] {
545
+ background: var(--plexi-accent-soft) !important;
546
+ color: var(--plexi-ink) !important;
547
+ }
548
+
549
+ li[role="option"][aria-selected="true"] > div,
550
+ li[role="option"][aria-selected="true"] * {
551
+ background: var(--plexi-accent-soft) !important;
552
+ color: var(--plexi-ink) !important;
553
+ }
554
+
555
+ .plexi-sidecard-title {
556
+ font-family: "DM Serif Display", Georgia, serif;
557
+ font-size: 1.25rem;
558
+ margin-bottom: 0.35rem;
559
+ }
560
+
561
+ .plexi-meta {
562
+ background: var(--plexi-meta-background);
563
+ border: 1px solid var(--plexi-line);
564
+ border-radius: 18px;
565
+ padding: 0.85rem 1rem;
566
+ margin-bottom: 1rem;
567
+ }
568
+
569
+ .plexi-meta-row {
570
+ display: flex;
571
+ justify-content: space-between;
572
+ gap: 1rem;
573
+ align-items: flex-start;
574
+ padding: 0.65rem 0;
575
+ border-bottom: 1px solid var(--plexi-meta-row-border);
576
+ }
577
+
578
+ .plexi-meta-row:last-child {
579
+ border-bottom: none;
580
+ padding-bottom: 0;
581
+ }
582
+
583
+ .plexi-meta-row:first-child {
584
+ padding-top: 0;
585
+ }
586
+
587
+ .plexi-meta-key {
588
+ color: var(--plexi-muted);
589
+ font-size: 0.78rem;
590
+ text-transform: uppercase;
591
+ letter-spacing: 0.08em;
592
+ flex: 0 0 38%;
593
+ }
594
+
595
+ .plexi-meta-value {
596
+ text-align: right;
597
+ color: var(--plexi-ink);
598
+ font-size: 0.96rem;
599
+ line-height: 1.5;
600
+ overflow-wrap: anywhere;
601
+ }
602
+
603
+ .plexi-filelist {
604
+ margin: 0;
605
+ padding-left: 1.1rem;
606
+ color: var(--plexi-muted);
607
+ line-height: 1.7;
608
+ }
609
+
610
+ .plexi-filelist li.current {
611
+ color: var(--plexi-ink);
612
+ font-weight: 600;
613
+ }
614
+
615
+ .plexi-divider {
616
+ height: 1px;
617
+ background: var(--plexi-divider);
618
+ margin: 1rem 0 1.1rem;
619
+ }
620
+
621
+ $system_css
622
+ @media (max-width: 900px) {
623
+ .block-container {
624
+ padding-top: 1.2rem;
625
+ }
626
+
627
+ .plexi-hero {
628
+ padding: 1.35rem 1.2rem;
629
+ border-radius: 20px;
630
+ }
631
+
632
+ .plexi-stat {
633
+ min-height: 0;
634
+ }
635
+
636
+ .plexi-meta-row {
637
+ display: block;
638
+ }
639
+
640
+ .plexi-meta-value {
641
+ text-align: left;
642
+ margin-top: 0.2rem;
643
+ }
644
+
645
+ .plexi-cta-grid {
646
+ grid-template-columns: 1fr;
647
+ }
648
+ }
649
+ </style>
650
+ """
651
+ ).substitute(
652
+ {
653
+ "palette_vars": _css_vars_block(palette),
654
+ "color_scheme": color_scheme,
655
+ "system_css": system_css,
656
+ }
657
+ )
658
+ st.markdown(css, unsafe_allow_html=True)
659
+
660
+
661
+ def summarize_manifest(manifest):
662
+ """Return top-level counts for the materials catalog."""
663
+ subject_total = sum(len(subjects) for subjects in manifest.values())
664
+ file_total = sum(
665
+ len(files)
666
+ for subjects in manifest.values()
667
+ for types in subjects.values()
668
+ for files in types.values()
669
+ )
670
+ material_types = sorted(
671
+ {
672
+ material_type
673
+ for subjects in manifest.values()
674
+ for types in subjects.values()
675
+ for material_type in types.keys()
676
+ }
677
+ )
678
+ return {
679
+ "semester_count": len(manifest),
680
+ "subject_count": subject_total,
681
+ "file_count": file_total,
682
+ "material_types": material_types,
683
+ }
684
+
685
+
686
+ def summarize_subject_catalog(subject_data):
687
+ """Return counts for one selected subject catalog."""
688
+ return {
689
+ "type_count": len(subject_data),
690
+ "file_count": sum(len(files) for files in subject_data.values()),
691
+ "types": sorted(subject_data.keys()),
692
+ }
693
+
694
+
695
+ def render_page_header(kicker, title, subtitle, badges=None):
696
+ """Render a shared hero block for each page."""
697
+ badge_html = ""
698
+ if badges:
699
+ badge_html = "".join(
700
+ f'<span class="plexi-chip">{escape(str(badge))}</span>'
701
+ for badge in badges
702
+ if badge
703
+ )
704
+ badge_html = f'<div class="plexi-chip-row">{badge_html}</div>'
705
+
706
+ st.markdown(
707
+ f"""
708
+ <section class="plexi-hero">
709
+ <div class="plexi-kicker">{escape(kicker)}</div>
710
+ <h1 class="plexi-title">{escape(title)}</h1>
711
+ <p class="plexi-subtitle">{escape(subtitle)}</p>
712
+ {badge_html}
713
+ </section>
714
+ """,
715
+ unsafe_allow_html=True,
716
+ )
717
+
718
+
719
+ def render_stat_cards(cards):
720
+ """Render compact metrics in a responsive grid."""
721
+ if not cards:
722
+ return
723
+
724
+ cols = st.columns(len(cards))
725
+ for col, card in zip(cols, cards):
726
+ label = escape(str(card.get("label", "")))
727
+ value = escape(str(card.get("value", "")))
728
+ note = escape(str(card.get("note", "")))
729
+ with col:
730
+ st.markdown(
731
+ f"""
732
+ <div class="plexi-stat">
733
+ <div class="plexi-stat-label">{label}</div>
734
+ <div class="plexi-stat-value">{value}</div>
735
+ <div class="plexi-stat-note">{note}</div>
736
+ </div>
737
+ """,
738
+ unsafe_allow_html=True,
739
+ )
740
+
741
+
742
+ def render_panel(title, body, tone="default"):
743
+ """Render a simple informational panel."""
744
+ panel_class = "plexi-callout" if tone == "callout" else "plexi-panel"
745
+ st.markdown(
746
+ f"""
747
+ <section class="{panel_class}">
748
+ <div class="plexi-sidecard-title">{escape(title)}</div>
749
+ <div class="plexi-muted">{escape(body)}</div>
750
+ </section>
751
+ """,
752
+ unsafe_allow_html=True,
753
+ )
754
+
755
+
756
+ def _manifest_url():
757
+ """Raw GitHub URL for manifest.json."""
758
+ return f"https://raw.githubusercontent.com/{MATERIALS_REPO}/{MANIFEST_BRANCH}/manifest.json"
759
+
760
+
761
+ @st.cache_data(ttl=300, show_spinner=False)
762
+ def get_manifest():
763
+ """Fetch the materials manifest from GitHub. Cached for 5 minutes."""
764
+ url = _manifest_url()
765
+ resp = requests.get(url, timeout=15)
766
+ resp.raise_for_status()
767
+ return resp.json()
768
+
769
+
770
+ def download_github_file(download_url, max_retries=3):
771
+ """Download a file from a GitHub Release asset URL with retry logic."""
772
+ for attempt in range(max_retries):
773
+ try:
774
+ resp = requests.get(download_url, timeout=60)
775
+ resp.raise_for_status()
776
+ return resp.content
777
+ except requests.RequestException as err:
778
+ print(f"Download error (attempt {attempt + 1}): {err}")
779
+ if attempt == max_retries - 1:
780
+ raise
781
+ return None
782
+
783
+
784
+ def get_mime_type(filename):
785
+ """Guess MIME type from filename extension."""
786
+ mime, _ = mimetypes.guess_type(filename)
787
+ return mime or "application/octet-stream"
788
+
789
+
790
+ def render_sidebar():
791
+ """Render the shared sidebar with branding and outbound links."""
792
+ with st.sidebar:
793
+ current_mode = get_theme_mode()
794
+ widget_value = current_mode.capitalize()
795
+ if st.session_state.get(THEME_MODE_WIDGET_KEY) != widget_value:
796
+ st.session_state[THEME_MODE_WIDGET_KEY] = widget_value
797
+ st.markdown(
798
+ """
799
+ <section class="plexi-sidecard">
800
+ <div class="plexi-kicker">Plexi</div>
801
+ <div class="plexi-sidecard-title">Grounded study assistant</div>
802
+ <div class="plexi-muted">
803
+ Browse materials, preview files, and ask questions backed by the
804
+ currently loaded course content.
805
+ </div>
806
+ </section>
807
+ """,
808
+ unsafe_allow_html=True,
809
+ )
810
+ st.markdown(
811
+ '<div class="plexi-section-label">Appearance</div>',
812
+ unsafe_allow_html=True,
813
+ )
814
+ st.selectbox(
815
+ "Theme",
816
+ ["System", "Light", "Dark"],
817
+ key=THEME_MODE_WIDGET_KEY,
818
+ on_change=sync_theme_mode,
819
+ help="System follows your device preference unless you override it here.",
820
+ )
821
+ st.caption("Built by **Kunal Gupta** (LazyHuman)")
822
+ cols = st.columns(3)
823
+ with cols[0]:
824
+ st.link_button("Web", "https://lazyhideout.tech", use_container_width=True)
825
+ with cols[1]:
826
+ st.link_button(
827
+ "GitHub", "https://github.com/kunalgupta25", use_container_width=True
828
+ )
829
+ with cols[2]:
830
+ st.link_button(
831
+ "Ko-fi", "https://ko-fi.com/lazy_human", use_container_width=True
832
+ )
833
+ st.markdown('<div class="plexi-divider"></div>', unsafe_allow_html=True)
834
+
835
+
836
+ def read_pdf_text(pdf_bytes):
837
+ """Extract text from PDF bytes with error handling."""
838
+ text = []
839
+ try:
840
+ reader = PyPDF2.PdfReader(io.BytesIO(pdf_bytes))
841
+ for page in reader.pages:
842
+ try:
843
+ page_text = page.extract_text()
844
+ if page_text:
845
+ filtered = page_text.encode("utf-16", "surrogatepass").decode(
846
+ "utf-16", "ignore"
847
+ )
848
+ text.append(filtered)
849
+ except Exception:
850
+ pass
851
+ return "\n".join(text)
852
+ except Exception:
853
+ return pdf_bytes.decode("utf-8", errors="ignore") if pdf_bytes else ""
854
+
855
+
856
+ def load_subject_context(manifest, semester, subject):
857
+ """Download and extract text from all files for a given semester + subject.
858
+
859
+ Returns (context_string, source_list) where:
860
+ - context_string: numbered source blocks for the system prompt
861
+ - source_list: list of dicts with 'id', 'name', 'type' for citation display
862
+ """
863
+ subject_data = manifest.get(semester, {}).get(subject, {})
864
+ parts = []
865
+ sources = []
866
+ source_id = 0
867
+
868
+ for file_type, file_list in subject_data.items():
869
+ for file_entry in file_list:
870
+ name = file_entry["name"]
871
+ mime = get_mime_type(name)
872
+
873
+ if not (mime.startswith("text/") or mime == "application/pdf"):
874
+ continue
875
+
876
+ try:
877
+ content = download_github_file(file_entry["download_url"])
878
+ if not content:
879
+ continue
880
+
881
+ if mime == "application/pdf":
882
+ text = read_pdf_text(content)
883
+ else:
884
+ text = content.decode("utf-8", errors="ignore")
885
+
886
+ if text.strip():
887
+ source_id += 1
888
+ sources.append({"id": source_id, "name": name, "type": file_type})
889
+ parts.append(
890
+ f"[Source {source_id}: {name} ({file_type})]\n{text}\n[End Source {source_id}]"
891
+ )
892
+ except Exception as err:
893
+ print(f"Error loading {name}: {err}")
894
+
895
+ return "\n\n".join(parts), sources
896
+
897
+
898
+ # RAG index loading from GitHub
899
+ # The index is pre-built by GitHub Actions (build_index.py) and
900
+ # committed to the materials repo. We just download and load it.
901
+
902
+ EMBED_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" # must match build_index.py
903
+ INDEX_FILES = [
904
+ "default__vector_store.json",
905
+ "docstore.json",
906
+ "graph_store.json",
907
+ "image__vector_store.json",
908
+ "index_store.json",
909
+ ]
910
+
911
+
912
+ @st.cache_resource(show_spinner="Loading RAG index...")
913
+ def fetch_rag_index():
914
+ """
915
+ Download the pre-built LlamaIndex from the materials repo and return
916
+ a ready-to-use VectorStoreIndex. Cached once per Streamlit session.
917
+
918
+ Returns (index, error_msg) - index is None if loading failed.
919
+ """
920
+ if not LLAMA_INDEX_AVAILABLE:
921
+ return (
922
+ None,
923
+ "LlamaIndex not installed - install llama-index-core and dependencies.",
924
+ )
925
+
926
+ index_base_url = (
927
+ f"https://raw.githubusercontent.com/{MATERIALS_REPO}/{MANIFEST_BRANCH}/index"
928
+ )
929
+
930
+ index_dir = tempfile.mkdtemp(prefix="plexi_index_")
931
+ try:
932
+ for filename in INDEX_FILES:
933
+ url = f"{index_base_url}/{filename}"
934
+ resp = requests.get(url, timeout=30)
935
+ resp.raise_for_status()
936
+ with open(os.path.join(index_dir, filename), "wb") as file_handle:
937
+ file_handle.write(resp.content)
938
+ except Exception as err:
939
+ return None, f"Failed to download index files: {err}"
940
+
941
+ try:
942
+ embed_model = HuggingFaceEmbedding(model_name=EMBED_MODEL_ID)
943
+ Settings.embed_model = embed_model
944
+ Settings.llm = None
945
+
946
+ storage_context = StorageContext.from_defaults(persist_dir=index_dir)
947
+ index = load_index_from_storage(storage_context)
948
+ return index, None
949
+ except Exception as err:
950
+ return None, f"Failed to load index: {err}"