Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Teacher AI Explainer — Environment Configuration
# Copy this file to .env and fill in your values.

# LLM provider (e.g. gemini, openai, anthropic, ollama)
LLM_PROVIDER=gemini

# Model name (e.g. gemini-2.5-pro, gpt-4o, claude-sonnet-4-20250514, llama3.1)
MODEL_NAME=gemini-2.5-pro

# API key for the provider (not needed for local Ollama)
LLM_API_KEY=

# Custom API base URL (optional)
# For Ollama Cloud: https://api.ollama.c loud
# For OpenAI-compatible local: http://localhost:11434/v1
LLM_URL=

# Request timeout in seconds (default: 120)
LLM_TIMEOUT=120

# Server port (default: 8000)
PORT=8000
10 changes: 0 additions & 10 deletions .gcloudignore

This file was deleted.

49 changes: 49 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Security audit — runs weekly (Sunday) and on PRs: dependency scan, SAST, secrets detection
name: Security Audit

on:
schedule:
- cron: "0 0 * * 0"
pull_request:
branches: [main]

jobs:
audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: requirements.txt

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: whiteboard-dashboard/package-lock.json

- name: Install Python audit tools
run: |
python -m pip install --upgrade pip
pip install pip-audit bandit trufflehog

- name: pip-audit (Python deps)
run: pip-audit -r requirements.txt
continue-on-error: true

- name: npm audit (Node deps)
run: npm audit
working-directory: whiteboard-dashboard
continue-on-error: true

- name: bandit (Python SAST)
run: bandit -r . -x tests/
continue-on-error: true

- name: truffleHog (Secrets)
run: trufflehog filesystem . --fail
continue-on-error: true
75 changes: 75 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# CI pipeline — runs on push/PR to main: lint, type check, test, build
name: CI

on:
push:
branches: [main, chore/typescriptmigration]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
backend:
name: Backend (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
defaults:
run:
working-directory: .

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff pytest
pip install -r requirements.txt

- name: Lint (ruff)
run: ruff check .

- name: Test (pytest)
run: pytest tests/ -v

frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: whiteboard-dashboard

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: whiteboard-dashboard/package-lock.json

- name: Install dependencies
run: npm ci

- name: Lint (eslint)
run: npm run lint

- name: Type check (tsc)
run: npx tsc --noEmit

- name: Test (vitest)
run: npx vitest run

- name: Build
run: npm run build
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/__pycache__
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
teacher-ai-explainer
59 changes: 59 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Teacher AI Explainer — Agent Guide

## Project structure

Two independent sub-projects, **not** a monorepo:

| Layer | Location | Entrypoint | Tech |
|-------|----------|------------|------|
| Backend | `/` (root) | `main.py` (legacy), `teacher_ai.py` (new) | FastAPI + WebSocket + LiteLLM |
| Backend package | `teacher_ai/` | `standalone.py` | LiteLLM, FastAPI |
| Frontend | `whiteboard-dashboard/` | `src/main.tsx` | React 19 + Vite + ReactFlow + d3-force |

## Commands

### Frontend (`whiteboard-dashboard/`)
```sh
npm run dev # Vite dev server (localhost:5173)
npm run build # Vite build → dist/
npm run lint # ESLint v9 (flat config)
npm run typecheck # tsc --noEmit
npm run preview # Vite preview
```

### Backend (root)
```sh
python teacher_ai.py # standalone mode (default, LiteLLM + WebSocket)
python teacher_ai.py --mode mcp # MCP mode (not yet implemented)
uvicorn main:app --reload # legacy dev server (Vertex AI)
pytest tests/ -v # run backend tests
ruff check . # lint backend
```

### CI/CD
GitHub Actions workflows in `.github/workflows/`:
- `ci.yml` — runs on push/PR to main: lint (ruff + eslint), type check (tsc), test (pytest + vitest), build (vite)
- `audit.yml` — weekly security audit (pip-audit, npm audit, bandit, truffleHog)

Run CI locally with `act` before pushing:
```sh
act -j backend --pull=false # Python lint + test
act -j frontend --pull=false # frontend lint + typecheck + test + build
```

## Critical gotchas

- **WebSocket URL is hardcoded** in `App.tsx:96` to a Cloud Run deployment. For local dev, change to `ws://localhost:8000/ws/reason`.
- **No `.env` files.** Backend requires `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` for legacy `main.py`, or `LLM_PROVIDER` + `LLM_API_KEY` for `teacher_ai.py`.
- **Tailwind v4** is in devDeps but has no config and is not imported in CSS — likely unused.
- **`.gitignore`** has Next.js entries (`/.next/`, `/out/`) — stale from template.
- **Root `package.json` is stale** — only exists for hoisted deps. Real frontend is in `whiteboard-dashboard/`.

## Architecture

- **Dual-mode entrypoint:** `teacher_ai.py --mode standalone|mcp` (default: standalone).
- **Standalone mode** (`teacher_ai/standalone.py`): FastAPI + WebSocket + LiteLLM. Calls any LLM provider via `LLM_PROVIDER` env var (e.g. `gemini/gemini-2.5-pro`, `openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514`, `ollama/llama3.1`). LiteLLM telemetry disabled by default.
- **Legacy mode** (`main.py`): FastAPI + Vertex AI Gemini. Requires GCP project/location env vars.
- Frontend: `FlowBoard` component manages ReactFlow graph + d3-force layout + WebSocket client + conversation history sidebar. Custom node type `mathNode` renders KaTeX via `react-markdown` + `remark-math` + `rehype-katex`.
- Node types: `Given`, `Objective`, `Principle`, `Derivation`, `Self-Correction`, `Alternative`, `Final Answer`.
- Probe feature: clicking `?` on a node sends `{"type":"probe","parent_id":"...","content":"..."}` to get an alternative explanation.
21 changes: 0 additions & 21 deletions Dockerfile

This file was deleted.

38 changes: 26 additions & 12 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,41 @@
# FastAPI server that connects Gemini (Vertex AI) to a real-time whiteboard via WebSocket.
# Receives STEM questions, streams reasoning nodes back as the model generates them.

import asyncio
import json
import os

from fastapi import FastAPI, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from google import genai
from google.genai import types
from fastapi.middleware.cors import CORSMiddleware
import os

app = FastAPI()

# Allow the Vite dev server and Firebase-hosted frontends to connect
origins = [
"http://localhost:5173",
"https://cs598-project-492002.web.app",
"https://cs598-project-492002.firebaseapp.com"
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # For a prototype, "*" is fine. For production, use your Vercel/Firebase URL.
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

project_id=os.environ.get("GOOGLE_CLOUD_PROJECT")
location_id=os.environ.get("GOOGLE_CLOUD_LOCATION","us-central1")
# Authenticate with Vertex AI using GCP project/location env vars
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT")
location_id = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")

if project_id and location_id:
client = genai.Client(vertexai=True,project=project_id,location=location_id)
client = genai.Client(vertexai=True, project=project_id, location=location_id)
else:
raise ValueError
raise ValueError("GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION must be set")

# Declare the function-calling tool that Gemini uses to add nodes to the graph
node_tool = types.Tool(
function_declarations=[
types.FunctionDeclaration(
Expand Down Expand Up @@ -69,7 +77,8 @@ async def websocket_endpoint(websocket: WebSocket):
while True:
raw = await websocket.receive_text()

# --- Route: probe vs regular query ---
# Determine if this is a probe request (asking for alternative explanation)
# or a regular question
try:
msg = json.loads(raw)
is_probe = msg.get("type") == "probe"
Expand All @@ -90,7 +99,7 @@ async def websocket_endpoint(websocket: WebSocket):
else:
student_query = raw

# --- Fresh chat session per query/probe ---
# Start a fresh Gemini chat session for each query
chat = client.chats.create(
model='gemini-2.5-pro',
config=types.GenerateContentConfig(
Expand All @@ -102,6 +111,8 @@ async def websocket_endpoint(websocket: WebSocket):

response = chat.send_message(student_query)

# Loop through Gemini's function calls, sending each node to the frontend
# with a 1.2s delay so the graph builds incrementally
while True:
found_tool_call = False

Expand All @@ -110,8 +121,7 @@ async def websocket_endpoint(websocket: WebSocket):
found_tool_call = True
node_data = dict(part.function_call.args)

# For probes, force the correct parent_id and type
# in case the model ignores the instruction
# Override probe responses to ensure correct structure
if is_probe:
node_data["node_type"] = "Alternative"
node_data["parent_id"] = parent_id
Expand All @@ -120,10 +130,14 @@ async def websocket_endpoint(websocket: WebSocket):
await websocket.send_json(node_data)
print(f"Node sent: {node_data.get('label')} [{node_data.get('node_type')}]")

# Tell Gemini the node was rendered so it can continue
response = chat.send_message(
types.Part.from_function_response(
name="add_reasoning_node",
response={"status": "success", "message": "Node rendered on whiteboard"}
response={
"status": "success",
"message": "Node rendered on whiteboard"
}
)
)

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/teacher-ai-v2/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-27
Loading
Loading