LazyHuman10 commited on
Commit ·
fbe7a99
0
Parent(s):
Prepare Hugging Face Space deployment
Browse files- .devcontainer/devcontainer.json +33 -0
- .gitattributes +0 -0
- .github/workflows/main_plexi.yml +76 -0
- .github/workflows/wake-up-streamlit.yml +61 -0
- .gitignore +27 -0
- .streamlit/config.toml +6 -0
- Dockerfile +10 -0
- Home.py +130 -0
- LICENSE +21 -0
- README.md +79 -0
- example.env +5 -0
- pages/Plexi-Assistant.py +601 -0
- pages/Study_Material_Hub.py +263 -0
- requirements.txt +8 -0
- startup.sh +2 -0
- utils.py +950 -0
.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}"
|