# Project export: Devil's Advocate

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: An AI-driven quest engine that turns static PDFs & lecture transcripts into dynamic, competitive learning pathways with exam review & practice questions for students to learn with full comprehension.
- Devpost: https://devpost.com/software/devil-s-advocate-u35k9m
- GitHub: https://github.com/arshyg/devils-advocate
- Video: https://www.youtube.com/embed/hpS6IAhB76s?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

We noticed a major flaw in current AI study tools: they make learning too easy. When a student asks a question, most AI's just hand over the answer, leading to "passive learning" where information goes in one ear and out the other. We wanted to create the "Anti-ChatGPT", a tool that doesn't just give you answers, but forces you to earn them through critical thinking.

### What it does

Devil’s Advocate is an AI-driven "Quest" engine that transforms static PDFs into a competitive learning pathway. Instead of a boring chat interface, users navigate a quest to achieve mastery. Socratic Mode: The AI acts as a guide/tutor, asking leading questions to help you arrive at the truth. Gaslight Mode: The AI acts as a skeptical examiner. Even if you are right, it might challenge you with a trick question or a common misconception to see if you actually understand the "why" or if you're just memorizing.

### How we built it

We also tried to built a full-stack RAG (Retrieval-Augmented Generation) pipeline: Frontend: A dynamic React interface that visualizes the chatbot for two separate personas "quest" rather than a standard chat bubble. Backend: A FastAPI server that orchestrates the logic. Brain: We used Llama 3.3 (via Groq) for ultra-fast inference speeds, allowing the "Devil" to respond instantly. Knowledge Base: We implemented FAISS and Sentence Transformers to turn 90+ page PDFs into searchable mathematical vectors. This allows the AI to perform a semantic search to verify the student's answer against the actual textbook material.

### Challenges we ran into

One of our biggest hurdles was the front-end to -backend synchronization. We initially struggled with asyncio runtime errors because our server was trying to run inside a pre-existing loop. We also had to solve the memory gap issue ensuring that the AI could remember which page of a 92-page PDF it was talking about without exceeding token limits.

### Accomplishments we're proud of

We are incredibly proud of our custom evaluation logic. Most AI tools just "chat," but we built a reliable verdict system that acts as a gatekeeper for the user's progress. We also managed to get our RAG pipeline running with high accuracy, ensuring that the AI can cite specific page numbers from the PDF to correct the user.

### What we learned

We learned that building an AI that knows the answer but refuses to give it to you is actually much harder than building a standard chatbot! We gained deep experience in vector databases, prompt engineering for "socratic" personas vs "gaslight mode" persona, and the complexities of handling multi-part file uploads in a FastAPI environment.

### What's next

We want to expand the "Quest" map into a fully gamified 3D environment where each node in the study path is a physical location. We also plan to add "Peer-to-Peer Battle Mode," where two students can use the same PDF to generate challenges for each other, with the Devil's Advocate acting as the referee.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 22 recognized source files, 43 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (31 of 31)

```
.gitignore
backend/__init__.py
backend/.env
backend/injest.py
backend/processed_data.json
backend/prompts/socratic_v1.txt
backend/random
backend/retriever.py
backend/test_logic.py
backend/tutor_service.py
frontend/.gitignore
frontend/c
frontend/eslint.config.js
frontend/index.html
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/src/App.css
frontend/src/components/ChatInput.jsx
frontend/src/components/ChatWindow.jsx
frontend/src/components/Landing.jsx
frontend/src/components/ModeSelect.jsx
frontend/src/components/ProgressMap.jsx
frontend/src/components/Sidebar.jsx
frontend/src/components/UploadPdf.jsx
frontend/src/index.css
frontend/src/main.jsx
frontend/src/services/api.js
frontend/tailwind.config.js
frontend/vite.config.js
README.md
```

### Dependencies

- frontend/package.json: @eslint/js@^9.39.1, @tailwindcss/postcss@^4.1.18, @types/react@^19.2.5, @types/react-dom@^19.2.3, @vitejs/plugin-react@^5.1.1, autoprefixer@^10.4.23, eslint@^9.39.1, eslint-plugin-react-hooks@^7.0.1, eslint-plugin-react-refresh@^0.4.24, globals@^16.5.0, lucide-react@^0.562.0, postcss@^8.5.6, react@^19.2.0, react-dom@^19.2.0, tailwindcss@^4.1.18, vite@^7.2.4

### Recent commits (newest first)

- socratic works, gaslight wip, map not working
- last functioning draft (errors on circle pathway)
- working chatbot inccorect circle pathway
- first working version (upload_pdf)
- updating with new frontend stuff
- Ignore .DS_Store
- adding frontend files
- saving correct logic folder and injest (with errors)
- random
- i
- initial

## Key source files (fetched from GitHub, selected and truncated for size)

### frontend/package.json

```
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "lucide-react": "^0.562.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@tailwindcss/postcss": "^4.1.18",
    "@types/react": "^19.2.5",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "autoprefixer": "^10.4.23",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "postcss": "^8.5.6",
    "tailwindcss": "^4.1.18",
    "vite": "^7.2.4"
  }
}

```

### frontend/src/main.jsx

```javascript
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";

import Landing from "./components/Landing.jsx";
import UploadPdf from "./components/UploadPdf.jsx";
import ModeSelect from "./components/ModeSelect.jsx";
import ChatWindow from "./components/ChatWindow.jsx";

function App() {
  const [step, setStep] = React.useState("landing");
  const [file, setFile] = React.useState(null);
  const [mode, setMode] = React.useState("socratic");

  return (
    <>
      {step === "landing" && <Landing onStart={() => setStep("upload")} />}

      {step === "upload" && (
        <UploadPdf
          onUploadComplete={(f) => {
            setFile(f);
            setStep("mode");
          }}
        />
      )}

      {step === "mode" && (
        <ModeSelect
          onSelect={(m) => {
            console.log("MODE PICKED:", m);
            setMode(m);
            setStep("chat");
          }}
        />
      )}

      {step === "chat" && <ChatWindow mode={mode} fileName={file?.name || "Lecture.pdf"} />}
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

```

### frontend/vite.config.js

```javascript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
})

```

### frontend/index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>frontend</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

```

### frontend/tailwind.config.js

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      keyframes: {
        float: {
          "0%, 100%": { transform: "translateY(0)" },
          "50%": { transform: "translateY(-12px)" },
        },
      },
      animation: {
        float: "float 2.6s ease-in-out infinite",
      },
    },
  },
  plugins: [],
};

```

### frontend/eslint.config.js

```javascript
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{js,jsx}'],
    extends: [
      js.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        ecmaVersion: 'latest',
        ecmaFeatures: { jsx: true },
        sourceType: 'module',
      },
    },
    rules: {
      'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
    },
  },
])

```

### backend/test_logic.py

```python
import os
import sys
from dotenv import load_dotenv

# Ensure we can import from tutor_service.py
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from backend.tutor_service import generate_exam_question, evaluate_user_progress

# Load environment variables
load_dotenv()

def run_quest_simulation():
    # 1. Mock Data (This will eventually come from Person A/B's PDF parser)
    fake_context = [
        "Photosynthesis is the process by which green plants use sunlight to synthesize foods.",
        "It involves the green pigment chlorophyll.",
        "Photosynthesis essentially converts light energy into chemical energy.",
        "The process releases oxygen as a byproduct." 
    ]

    print("\n" + "="*50)
    print("🚀 DEVILS ADVOCATE: BACKEND QUEST TEST")
    print("="*50)

    # 2. Select Mode
    mode_choice = input("\nSelect Mode (1 for Socratic, 2 for Gaslight): ")
    current_mode = "gaslight" if mode_choice == "2" else "socratic"
    
    # 3. Generate the Exam Question
    print("\n[LLM] Thinking of a challenging question...")
    question = generate_exam_question(fake_context)
    
    print(f"\n🎯 THE QUEST: {question}")
    
    # 4. The Game Loop (Circle Pathway)
    circles_total = 3
    circles_cleared = 0
    
    while circles_cleared < circles_total:
        print(f"\n--- 🟡 CIRCLE {circles_cleared + 1} of {circles_total} ---")
        user_in = input("Your Answer: ")
        
        # New 3-way status check
        status, feedback = evaluate_user_progress(user_in, fake_context, mode=current_mode)
        
        if status == "complete":
            print(f"\n🌟 GRAND SLAM! {feedback}")
            circles_cleared = circles_total # Instant win
            break 
            
        elif status == "forward":
            circles_cleared += 1
            print(f"\n✅ NICE JOB: {feedback}")
            if circles_cleared < circles_total:
                print(f"✨ You moved to Circle {circles_cleared + 1}!")
        
        else: # status == "stay"
            print(f"\n❌ CHALLENGE: {feedback}")

if __name__ == "__main__":
    run_quest_simulation()
```

### backend/retriever.py

```python
import faiss
import numpy as np
import pickle
import os
import subprocess
import sys

# Ensure sentence-transformers is installed
try:
    from sentence_transformers import SentenceTransformer
except ImportError:
    subprocess.check_call([sys.executable, "-m", "pip", "install", "sentence-transformers"])
    from sentence_transformers import SentenceTransformer

class StudyRetriever:
    def __init__(self, index_file="study.index", metadata_file="metadata.pkl"):
        # The 'Brain' that turns text into numbers
        self.model = SentenceTransformer('all-MiniLM-L6-v2')
        self.index_file = index_file
        self.metadata_file = metadata_file
        
        self.index = None
        self.metadata = [] # Holds our text + page numbers

    def add_to_index(self, doc_chunks):
        """
        doc_chunks: list of dicts [{"text": "...", "page": 1}, ...]
        """
        self.metadata = doc_chunks
        
        # Convert all text chunks into math vectors
        just_text = [item['text'] for item in doc_chunks]
        embeddings = self.model.encode(just_text).astype('float32')
        
        # Initialize FAISS index
        self.index = faiss.IndexFlatL2(embeddings.shape[1])
        self.index.add(embeddings)
        
        # Save so we don't have to re-process every time
        self.save()

    def search(self, query, k=3):
        """Finds the most relevant chunks for a question."""
        if self.index is None:
            # Try to load if it's not in memory
            self.load()
            if self.index is None: return []

        query_vector = self.model.encode([query]).astype('float32')
        distances, indices = self.index.search(query_vector, k)
        
        results = []
        for idx in indices[0]:
            if idx != -1 and idx < len(self.metadata):
                results.append(self.metadata[idx])
        return results

    def save(self):
        if self.index:
            faiss.write_index(self.index, self.index_file)
            with open(self.metadata_file, "wb") as f:
                pickle.dump(self.metadata, f)

    def load(self):
        if os.path.exists(self.index_file) and os.path.exists(self.metadata_file):
            self.index = faiss.read_index(self.index_file)
            with open(self.metadata_file, "rb") as f:
                self.metadata = pickle.load(f)
            print("📂 Loaded existing search index from disk.")
```

### backend/tutor_service.py

```python
import os
from dotenv import load_dotenv
from groq import Groq

# This finds the absolute path to your 'backend' folder
# so it can find the .env file no matter where you run the script from
current_dir = os.path.dirname(os.path.abspath(__file__))
env_path = os.path.join(current_dir, ".env")
load_dotenv(dotenv_path=env_path)

# Initialize the Groq client
client = Groq(api_key=os.getenv("GROQ_API_KEY"))

# generate the exam question/ quest 
def generate_exam_question(context_chunks):
    """Generates a high-level exam style question from the PDF data."""
    context_text = "\n".join(context_chunks)
    
    prompt = f"""
    CONTEXT FROM PDF: {context_text}
    TASK: Generate ONE easy-medium difficulty but doable, exam-style open-ended question based on just the information given in the context. 
    The question should test understanding, not just memorization. Pretend you are a university professor creating a final exam question.
    RETURN ONLY THE QUESTION.
    """
    
    completion = client.chat.completions.create(
        model="llama-3.1-8b-instant",
        messages=[{"role": "user", "content": prompt}]
    )
    return completion.choices[0].message.content

def generate_socratic_challenge(user_message, context_chunks, mode="socratic"):
    context_text = "\n".join(context_chunks)
    
    #two different "personalities"
    if mode == "gaslight":
        system_instruction = f"""
        ROLE: You are a Socratic Tutor for a UCSC student.
        CONTEXT: {context_text}
        RULES:
        1. Always question the user's understanding.
        2. If the user is right, ask 'Why?' or for evidence.
        3. If the user is wrong, ask a question pointing to the contradiction.
        4. Use a supportive but challenging UCSC TA tone.
        5. Occasionally make the user doubt their own understanding.
        """
    else:  # default to supportive mode
        system_instruction = f"""
        ROLE: You are a Socratic Tutor for a UCSC student.
        CONTEXT: {context_text}
        RULES:
        1. Never give a direct answer.
        2. If the user is right, ask 'Why?' or for evidence.
        3. If the user is wrong, ask a question pointing to the contradiction.
        4. Use a supportive but challenging UCSC TA tone.
        """

    completion = client.chat.completions.create(
        model="llama-3.1-8b-instant",
        messages=[
            {
                "role": "system",
                "content": system_instruction
            },
            {
                "role": "user",
                "content": user_message
            }
        ],
        temperature=0.8, #slightly higher temp for more "personality"
        #max_tokens=1024,
    )
    
    return completion.choices[0].message.content

#decides when the student has mastered the topic and can move on
def evaluate_user_progress(user_answer, context_chunks, mode="socratic"):
    """
    Returns: (bool: is_correct, str: feedback)
    """
    context_text = "\n".join(context_chunks)
    
    system_prompt = f"""
    CONTEXT: {context_text}
    MODE: {mode.upper()}
    TASK: Evaluate the student's answer. 

    VERDICTS:
    1. If the answer is 100% correct and demonstrates that the student understands the topic:
    Start your response with 'VERDICT: QUEST_COMPLETE'.
    2. If they are on the right track but missing details or partially incorrect: 
    Start with 'VERDICT: MOVE_FORWARD'.
    3. If they are incorrect or show misunderstanding:
    Start with 'VERDICT: STAY'.
    
    In GASLIGHT mode, even if they are right, try to trick them ONE TIME before giving the CORRECT verdict.
    """

    completion = client.chat.completions.create(
        model="llama-3.1-8b-instant",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_answer}
        ]
    )
    
    raw_response = completion.choices[0].message.content
    #is_correct = "VERDICT: CORRECT" in raw_response
    # Clean the response for the user

    #determine the verdict (can the student move forward or not)
    status = "stay"
    if "VERDICT: QUEST_COMPLETE" in raw_response:
        status = "complete"
    elif "VERDICT: MOVE_FORWARD" in raw_response:
        status = "forward"

    feedback = raw_response.replace("VERDICT: QUEST_COMPLETE", "").replace("VERDICT: MOVE_FORWARD" ,"").replace("VERDICT:STAY", "").strip()

    return status, feedback

def calculate_mastery_score(user_history):
    completion = client.chat.completions.create(
        model="llama-3.1-8b-instant", # Using a smaller, faster model for scoring
        messages=[
            {
                "role": "user", 
                "content": f"Review this history and return ONLY a number 0-100 for student understanding: {user_history}"
            }
        ]
    )
    return completion.choices[0].message.content.strip()
```

### backend/injest.py

```python
import subprocess
import sys
import json
import os
import asyncio
import nest_asyncio

# --- 1. PATH CONFIGURATION ---
# Ensures local imports like tutor_service work regardless of where the script is invoked
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
    sys.path.insert(0, current_dir)

# --- 2. AUTO-INSTALLER ---
libraries = ["nest-asyncio", "pymupdf", "fastapi", "uvicorn", "python-multipart", "sentence-transformers", "faiss-cpu", "requests", "python-dotenv", "groq"]
for lib in libraries:
    try:
        import_name = lib.replace("-", "_")
        if lib == "faiss-cpu": import_name = "faiss"
        if lib == "pymupdf": import_name = "fitz"
        __import__(import_name)
    except ImportError:
        print(f"📦 Installing {lib}...")
        subprocess.check_call([sys.executable, "-m", "pip", "install", lib])

# Patch for async environments
nest_asyncio.apply()

import fitz  # PyMuPDF
import faiss
import numpy as np
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from groq import Groq
from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv

# Import the logic from tutor_service.py
try:
    import tutor_service
    from tutor_service import generate_exam_question, evaluate_user_progress
    print("✅ SUCCESS: Linked tutor_service.py")
except ImportError as e:
    print(f"❌ ERROR: tutor_service.py not found or has errors: {e}")
    # Fallback dummies to prevent crash
    def generate_exam_question(ctx): return "Tutor logic missing."
    def evaluate_user_progress(ans, ctx, mode): return "stay", "Error: tutor_service.py missing."

load_dotenv()

# --- 3. CONFIGURATION ---
GROQ_KEY = os.getenv("GROQ_API_KEY") or "gsk_YOUR_KEY_HERE"
client = Groq(api_key=GROQ_KEY)

# --- 4. THE BRAIN (Retrieval Logic) ---
class StudyRetriever:
    def __init__(self):
        print("🧠 Loading AI Embedding Model... (Wait 10-20 seconds)")
        self.model = SentenceTransformer('all-MiniLM-L6-v2')
        self.index = None
        self.metadata = []

    def add_to_index(self, doc_chunks):
        self.metadata = doc_chunks
        just_text = [item['text'] for item in doc_chunks]
        embeddings = self.model.encode(just_text).astype('float32')
        self.index = faiss.IndexFlatL2(embeddings.shape[1])
        self.index.add(embeddings)
        print(f"✅ Success! Indexed {len(doc_chunks)} chunks into RAM.")

    def search(self, query, k=5):
        if self.index is None or not self.metadata:
            return []
        query_vector = self.model.encode([query]).astype('float32')
        distances, indices = self.index.search(query_vector, k)
        return [self.metadata[idx] for idx in indices[0] if idx != -1 and idx < len(self.metadata)]

# --- 5. THE API ---
app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

retriever = StudyRetriever()

@app.post("/process_pdf")
async def process_pdf(file: UploadFile = File(...)):
    """Parses PDF and indexes text for retrieval."""
    try:
        pdf_content = await file.read()
        doc = fitz.open(stream=pdf_content, filetype="pdf")
        
        all_chunks = []
        for page_num, page in enumerate(doc):
            text = page.get_text("text")
            paragraphs = text.split('\n\n')
            for p in paragraphs:
                clean_text = p.strip()
                if len(clean_text) > 50:
                    all_chunks.append({
                        "text": clean_text, 
                        "page": page_num + 1,
                        "source": file.filename
                    })
        
        retriever.add_to_index(all_chunks)
        return {"status": "success", "message": f"Parsed & Indexed {len(all_chunks)} chunks!"}
    except Exception as e:
        return {"status": "error", "message": str(e)}

@app.post("/start-quest")
async def start_quest(mode: str = "socratic"):
    """Uses tutor_service to generate the first question based on PDF context."""
    if not retriever.metadata:
        return {"question": "Please upload a PDF first to begin the mastery quest!"}
    
    context_list = [d['text'] for d in retriever.metadata[:10]]
    
    try:
        question = generate_exam_question(context_list)
        return {"question": question}
    except Exception as e:
        return {"question": f"Tutor Error: {str(e)}"}

@app.post("/submit-answer")
async def submit_answer(user_answer: str = Form(...), mode: str = Form("socratic")):
    """Evaluates user answers and maps VERDICTS to UI status (map dots)."""
    if not retriever.metadata:
        return {"feedback": "No context available.", "status": "stay"}

    relevant_docs = retriever.search(user_answer, k=3)
    context_list = [d['text'] for d in relevant_docs]

    try:
        # 1. Get raw logic output from tutor_service
        status_from_logic, raw_feedback = evaluate_user_progress(user_answer, context_list, mode=mode)
        
        # 2. Map Verdicts to UI Status
        ui_status = "stay" # default
        clean_feedback = raw_feedback

        if "VERDICT: QUEST_COMPLETE" in raw_feedback:
            ui_status = "complete"
            clean_feedback = raw_feedback.replace("VERDICT: QUEST_COMPLETE", "").strip()
        elif "VERDICT: MOVE_FORWARD" in raw_feedback:
            ui_status = "forward"
            clean_feedback = raw_feedback.replace("VERDICT: MOVE_FORWARD", "").strip()
        elif "VERDICT: STAY" in raw_feedback:
            ui_status = "stay"
            clean_feedback = raw_feedback.replace("VERDICT: STAY", "").strip()
        else:
            # Fallback if no explicit verdict string is found
            ui_status = status_from_logic

        return {
            "feedback": clean_feedback,
            "status": ui_status,
            "page_hints": [d['page'] for d in relevant_docs]
        }
    exc
[truncated — 390 more characters]
```

[10 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]