A student asks: "When is homework 3 due?"
A good course assistant answers:
Homework 3 is due Friday, October 18th at 11:59 PM.
Sources:
[1] CS101 Syllabus, Section 4.2 "Assignment Deadlines"
[2] CS101 FAQ, "Homework Submission Policy"
A great course assistant also:
- ✅ Only answers questions about the student's enrolled course
- ✅ Never reveals other students' grades or submissions
- ✅ Cites the exact source for every answer
- ✅ Runs locally — student data never leaves the institution
This article shows you how to build this system.
Architecture Overview
Course Materials Structure
A course assistant needs four types of content:
| Content Type | Examples | Update Frequency | Access |
|---|---|---|---|
| Lecture Notes | Slides, transcripts, summaries | Weekly | All students |
| Assignments | HW descriptions, rubrics, examples | Per assignment | Students (after release) |
| FAQs | Common questions, answers | As needed | All students |
| Policies | Syllabus, grading, late policy | Per semester | All students |
Directory Structure
courses/
├── cs101/
│ ├── lectures/
│ │ ├── week01_intro.md
│ │ ├── week02_variables.md
│ │ └── week03_loops.md
│ ├── assignments/
│ │ ├── hw01_description.md
│ │ ├── hw01_rubric.md
│ │ └── hw02_description.md
│ ├── faqs/
│ │ ├── submission_policy.md
│ │ ├── grading_questions.md
│ │ └── office_hours.md
│ └── policies/
│ ├── syllabus.md
│ ├── late_policy.md
│ └── academic_integrity.md
├── cs102/
│ └── ...
1. Document Ingestion with Metadata
from dataclasses import dataclass
from typing import List, Optional
import os
@dataclass
class CourseDocument:
content: str
metadata: dict
def ingest_course_materials(
course_dir: str,
course_id: str,
access_level: str = "student"
) -> List[CourseDocument]:
"""
Ingest all course materials with access metadata.
Args:
course_dir: Path to course directory
course_id: Course identifier (e.g., "cs101")
access_level: Default access level
"""
documents = []
for root, dirs, files in os.walk(course_dir):
for file in files:
if file.endswith((".md", ".txt")):
filepath = os.path.join(root, file)
# Determine content type from path
content_type = "general"
if "lectures" in filepath:
content_type = "lecture"
elif "assignments" in filepath:
content_type = "assignment"
elif "faqs" in filepath:
content_type = "faq"
elif "policies" in filepath:
content_type = "policy"
# Read content
with open(filepath, "r") as f:
content = f.read()
# Add metadata
metadata = {
"course_id": course_id,
"content_type": content_type,
"filename": file,
"filepath": filepath,
"access_level": access_level,
"semester": "Fall 2026"
}
documents.append(CourseDocument(
content=content,
metadata=metadata
))
return documents
# Ingest CS101 materials
docs = ingest_course_materials(
course_dir="./courses/cs101",
course_id="cs101"
)
print(f"Ingested {len(docs)} documents")
2. Section-Aware Chunking
from langchain.text_splitter import RecursiveCharacterTextSplitter
def chunk_course_document(
doc: CourseDocument,
chunk_size: int = 1000,
chunk_overlap: int = 200
) -> List[dict]:
"""Chunk a course document while preserving metadata."""
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", ". ", " "]
)
chunks = text_splitter.split_text(doc.content)
return [
{
"content": chunk,
"metadata": {
**doc.metadata,
"chunk_id": i,
"total_chunks": len(chunks)
}
}
for i, chunk in enumerate(chunks)
]
# Chunk all documents
all_chunks = []
for doc in docs:
chunks = chunk_course_document(doc)
all_chunks.extend(chunks)
print(f"Created {len(all_chunks)} chunks from {len(docs)} documents")
3. Access Control Implementation
Access control ensures students only see their own course materials.
from enum import Enum
from typing import Set
class Role(Enum):
STUDENT = "student"
TA = "ta"
INSTRUCTOR = "instructor"
ADMIN = "admin"
class AccessController:
"""Manages access control for course materials."""
def __init__(self):
self.user_courses = {} # user_id -> set of course_ids
self.user_roles = {} # user_id -> role
def register_user(
self,
user_id: str,
role: Role,
course_ids: Set[str]
):
"""Register a user with their role and courses."""
self.user_courses[user_id] = course_ids
self.user_roles[user_id] = role
def get_accessible_courses(self, user_id: str) -> Set[str]:
"""Get courses a user can access."""
role = self.user_roles.get(user_id, Role.STUDENT)
courses = self.user_courses.get(user_id, set())
if role == Role.ADMIN:
# Admin can access all courses
return {"cs101", "cs102", "cs103"} # All courses
return courses
def filter_chunks(
self,
user_id: str,
chunks: List[dict]
) -> List[dict]:
"""Filter chunks based on user access."""
accessible_courses = self.get_accessible_courses(user_id)
return [
chunk for chunk in chunks
if chunk["metadata"]["course_id"] in accessible_courses
]
# Example usage
ac = AccessController()
# Register users
ac.register_user("student_alice", Role.STUDENT, {"cs101"})
ac.register_user("student_bob", Role.STUDENT, {"cs102"})
ac.register_user("ta_charlie", Role.TA, {"cs101", "cs102"})
ac.register_user("instructor_diana", Role.INSTRUCTOR, {"cs101", "cs102", "cs103"})
# Filter chunks for Alice
alice_chunks = ac.filter_chunks("student_alice", all_chunks)
print(f"Alice can access {len(alice_chunks)} chunks")
# Filter chunks for Charlie (TA)
charlie_chunks = ac.filter_chunks("ta_charlie", all_chunks)
print(f"Charlie can access {len(charlie_chunks)} chunks")
Access Control Matrix
| Role | Own Course | Other Courses | Grades | Admin |
|---|---|---|---|---|
| Student | ✅ Read | ❌ No access | ✅ Own only | ❌ No access |
| TA | ✅ Read + Grade | ❌ No access | ✅ Assigned students | ❌ No access |
| Instructor | ✅ Full access | ✅ Their courses | ✅ All students | ❌ No access |
| Admin | ✅ Full access | ✅ All courses | ✅ All students | ✅ Full access |
4. Retrieval with Access Control
import chromadb
from sentence_transformers import SentenceTransformer
class CourseAssistant:
def __init__(self):
self.embedding_model = SentenceTransformer("nomic-embed-text")
self.client = chromadb.Client()
self.collection = self.client.create_collection("course_materials")
self.access_controller = AccessController()
self.llm = ollama
def ingest_course(self, course_dir: str, course_id: str):
"""Ingest course materials."""
docs = ingest_course_materials(course_dir, course_id)
for doc in docs:
chunks = chunk_course_document(doc)
for chunk in chunks:
embedding = self.embedding_model.encode(chunk["content"])
self.collection.add(
documents=[chunk["content"]],
embeddings=[embedding.tolist()],
metadatas=[chunk["metadata"]],
ids=[f"{course_id}_{chunk['metadata']['chunk_id']}"]
)
def query(
self,
user_id: str,
question: str,
course_id: str,
k: int = 5
) -> dict:
"""
Query with access control.
Args:
user_id: User asking the question
question: The question
course_id: Course to search in
k: Number of results
"""
# Check access
accessible = self.access_controller.get_accessible_courses(user_id)
if course_id not in accessible:
return {
"answer": "You don't have access to this course.",
"sources": [],
"error": "Access denied"
}
# Search with course filter
query_embedding = self.embedding_model.encode([question])
results = self.collection.query(
query_embeddings=query_embedding.tolist(),
n_results=k,
where={"course_id": course_id} # Filter by course
)
# Build context with citations
context_parts = []
sources = []
for i, (doc, meta) in enumerate(
zip(results["documents"][0], results["metadatas"][0]),
1
):
citation = f"[{i}] {meta['filename']}, {meta['content_type']}"
context_parts.append(f"Source {i} ({citation}):\n{doc}")
sources.append({
"filename": meta["filename"],
"type": meta["content_type"],
"citation": citation
})
context = "\n\n".join(context_parts)
# Generate answer
prompt = f"""Answer the question using ONLY the provided context.
Cite sources using [Source X] format.
If the context doesn't contain the answer, say "I don't have that information in the course materials."
Course: {course_id}
Context:
{context}
Question: {question}
Answer with citations:"""
response = self.llm.chat(
model="llama3.1:8b",
messages=[{"role": "user", "content": prompt}]
)
return {
"answer": response["message"]["content"],
"sources": sources
}
5. Citation Formatting
Good citations help students verify answers and find more information.
def format_course_citation(chunk: dict) -> str:
"""Format a chunk as a course citation."""
meta = chunk["metadata"]
# Format based on content type
if meta["content_type"] == "lecture":
return f"[{meta['filename'].replace('.md', '')}]"
elif meta["content_type"] == "assignment":
return f"[{meta['filename'].replace('.md', '')}]"
elif meta["content_type"] == "faq":
return f"[FAQ: {meta['filename'].replace('.md', '')}]"
elif meta["content_type"] == "policy":
return f"[Policy: {meta['filename'].replace('.md', '')}]"
else:
return f"[{meta['filename']}]"
# Example citations
citations = [
format_course_citation({"metadata": {"filename": "week03_loops.md", "content_type": "lecture"}}),
format_course_citation({"metadata": {"filename": "hw01_description.md", "content_type": "assignment"}}),
format_course_citation({"metadata": {"filename": "submission_policy.md", "content_type": "faq"}}),
format_course_citation({"metadata": {"filename": "late_policy.md", "content_type": "policy"}}),
]
# Output:
# [week03_loops]
# [hw01_description]
# [FAQ: submission_policy]
# [Policy: late_policy]
Complete Working Example
#!/usr/bin/env python3
"""
Private Course Assistant with RAG
Complete working example with access control and citations.
"""
import os
import chromadb
from sentence_transformers import SentenceTransformer
from dataclasses import dataclass
from typing import List, Set
from enum import Enum
# Create synthetic course materials
def create_sample_course():
"""Create sample CS101 course materials."""
os.makedirs("courses/cs101/lectures", exist_ok=True)
os.makedirs("courses/cs101/assignments", exist_ok=True)
os.makedirs("courses/cs101/faqs", exist_ok=True)
os.makedirs("courses/cs101/policies", exist_ok=True)
# Lecture notes
with open("courses/cs101/lectures/week01_intro.md", "w") as f:
f.write("""# Week 1: Introduction to Python
## Learning Objectives
- Understand Python basics
- Set up development environment
- Write your first program
## Key Concepts
Python is a high-level, interpreted programming language.
It emphasizes code readability with significant indentation.
## Hello World
```python
print("Hello, World!")
```
## Variables
```python
name = "Alice"
age = 20
gpa = 3.8
```
""")
# Assignments
with open("courses/cs101/assignments/hw01_description.md", "w") as f:
f.write("""# Homework 1: Python Basics
## Due Date
Friday, September 6th at 11:59 PM
## Submission
Submit via Canvas. Late submissions lose 10% per day.
## Requirements
1. Write a program that calculates the area of a circle
2. Create a function that converts temperature
3. Build a simple calculator
## Grading
- Correctness: 60%
- Code style: 20%
- Documentation: 20%
""")
# FAQs
with open("courses/cs101/faqs/submission_policy.md", "w") as f:
f.write="""# Submission Policy FAQ
## Q: How do I submit homework?
A: Submit via Canvas. Click "Submit Assignment" on the homework page.
## Q: Can I submit late?
A: Yes, but late submissions lose 10% per day. No submissions after 3 days.
## Q: Can I work with others?
A: You may discuss concepts, but code must be your own. See academic integrity policy.
## Q: What file format should I use?
A: Submit .py files. Name them: firstname_lastname_hw01.py
""")
# Policies
with open("courses/cs101/policies/late_policy.md", "w") as f:
f.write="""# Late Submission Policy
## Standard Policy
- Assignments due at 11:59 PM on the due date
- 10% deduction per day late
- No submissions accepted after 3 days
## Extensions
Extensions granted only for:
- Medical emergencies (with documentation)
- University-approved absences
- Contact instructor at least 24 hours before deadline
## Grade Impact
Late submissions may affect participation grade.
""")
# Run the example
if __name__ == "__main__":
# Create sample course
create_sample_course()
# Initialize components
embedding_model = SentenceTransformer("nomic-embed-text")
client = chromadb.Client()
collection = client.create_collection("course_materials")
# Ingest course materials
for root, dirs, files in os.walk("courses/cs101"):
for file in files:
if file.endswith(".md"):
filepath = os.path.join(root, file)
with open(filepath, "r") as f:
content = f.read()
# Determine content type
content_type = "general"
if "lectures" in filepath:
content_type = "lecture"
elif "assignments" in filepath:
content_type = "assignment"
elif "faqs" in filepath:
content_type = "faq"
elif "policies" in filepath:
content_type = "policy"
embedding = embedding_model.encode(content)
collection.add(
documents=[content],
embeddings=[embedding.tolist()],
ids=[filepath],
metadatas=[{
"course_id": "cs101",
"content_type": content_type,
"filename": file
}]
)
print(f"Ingested: {file}")
# Query
query = "When is homework 1 due?"
query_embedding = embedding_model.encode([query])
results = collection.query(
query_embeddings=query_embedding.tolist(),
n_results=3,
where={"course_id": "cs101"}
)
print("\n=== Results ===")
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
print(f"\n📄 {meta['filename']} ({meta['content_type']})")
print(f" {doc[:200]}...")
Run It
# Save as course_assistant.py
python course_assistant.py
# Output:
# Ingested: week01_intro.md
# Ingested: hw01_description.md
# Ingested: submission_policy.md
# Ingested: late_policy.md
#
# === Results ===
# 📄 hw01_description.md (assignment)
# # Homework 1: Python Basics
# ## Due Date
# Friday, September 6th at 11:59 PM...
# 📄 late_policy.md (policy)
# # Late Submission Policy
# ## Standard Policy
# - Assignments due at 11:59 PM...
Privacy Considerations
| Concern | Risk | Mitigation |
|---|---|---|
| Student data | Grades, submissions exposed | Access control, local processing |
| Course materials | Unauthorized access | Role-based filtering |
| API calls | Data sent to external services | Use local LLM (Ollama) |
| Logs | Query history exposed | Minimal logging, no PII |
Try It Yourself
Build your own course assistant with these BestWordz resources:
Conclusion
A private course assistant needs four key components:
- Course Materials: Lectures, assignments, FAQs, policies
- Access Control: Students see only their course
- Citations: Every answer linked to source
- Privacy: Local processing, no external APIs
The workflow is:
Course Materials → Ingest with Metadata → Vector Store
↓
Student Query → Auth Check → Filter by Access → Search → Answer + Citations
For production systems, consider:
- Web interface (FastAPI + frontend)
- User authentication (OAuth, SSO)
- Analytics (popular questions, gaps)
- Feedback loop (correct/incorrect answers)
- Multi-course support