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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env. Backend reads it on startup.

# LiteLLM gateway — same key/base_url as ~/Desktop/projects/browseruse-bench/.env
OPENAI_BASE_URL=https://litellm.local.lexmount.net/v1
OPENAI_API_KEY=

# Default model used for every text/vision call
EXAMCRAFT_MODEL=gpt-5.4

# gpt-image-2 endpoint
IMAGE_API_BASE=http://10.3.47.80:4002
IMAGE_API_KEY=sk-bf-02e467e5-abaa-474b-831e-e729b2bd1dee
IMAGE_MODEL=openai/gpt-image-2

# Cookie session signing secret. Generate with: python -c "import secrets;print(secrets.token_urlsafe(32))"
EXAMCRAFT_SESSION_SECRET=change-me-please

# Backend bind
EXAMCRAFT_HOST=127.0.0.1
EXAMCRAFT_PORT=8000

# CORS origin for the Next.js dev server
EXAMCRAFT_WEB_ORIGIN=http://localhost:3000
25 changes: 23 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down Expand Up @@ -216,3 +216,24 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# ExamCraft data + secrets
backend/data/
.env
.env.local

# Node / Next.js
node_modules/
.next/
out/
.turbo/
*.tsbuildinfo
next-env.d.ts
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# OS / editor
.DS_Store
.vscode/
.idea/
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.PHONY: dev backend web setup test lint

dev:
@command -v concurrently >/dev/null 2>&1 || npx --yes concurrently --version >/dev/null 2>&1 || true
@echo "Starting backend (:8000) and web (:3000) in parallel — Ctrl-C to stop both"
@npx --yes concurrently --kill-others --names backend,web --prefix-colors blue,magenta \
"cd backend && uv run examcraft-server" \
"cd web && npm run dev"

backend:
cd backend && uv run examcraft-server

web:
cd web && npm run dev

setup:
@echo "1. brew install poppler libreoffice"
@echo "2. cp .env.example .env && edit OPENAI_API_KEY + EXAMCRAFT_SESSION_SECRET"
cd backend && uv sync
cd web && npm install

test:
cd backend && uv run pytest -q

lint:
cd backend && uv run ruff check app tests
85 changes: 84 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,85 @@
# ExamCraft
AI design Exam generator

A personal local web app that turns teachers' sample exam papers into freshly
generated practice exams in the same style. Upload `.docx` / `.pdf` samples
into a question bank, the app analyzes their knowledge points and visual
style, and one click produces a brand-new exam — both as a printable spec
(questions + answers) and a stylized PNG rendered by `gpt-image-2`. Includes
a chat loop for fixing problems on the fly.

Built for one user, one Mac, one browser. Passwordless, single-tenant.

## Quick start

### 1. System dependencies (one time)

```sh
brew install poppler libreoffice
```

- `poppler` provides `pdftoppm`, used by `pdf2image` to rasterize PDFs.
- `libreoffice` provides `soffice`, used to convert `.docx` → PDF.

### 2. Environment

```sh
cp .env.example .env
# Edit .env: paste OPENAI_API_KEY (same key as browseruse-bench/.env) and
# rotate EXAMCRAFT_SESSION_SECRET.
```

### 3. Backend

```sh
cd backend
uv sync
uv run examcraft-server # http://127.0.0.1:8000
```

### 4. Frontend

```sh
cd web
npm install
npm run dev # http://localhost:3000
```

Or use the convenience target from the repo root:

```sh
make dev # backend + frontend in parallel
```

## Project layout

```
ExamCraft/
├── backend/ Python 3.10+, uv-managed (FastAPI + SQLAlchemy + litellm + pdf2image)
├── web/ Next.js 15 + TypeScript + Tailwind + shadcn/ui
├── Makefile dev / setup / test
└── .env.example shared env template (backend reads this; web has its own)
```

Generated artifacts (uploads, page images, SQLite DB, job step files) live
under `backend/data/` and are git-ignored.

## Architecture in one paragraph

Backend pipelines: ingestion converts each uploaded paper to per-page PNGs
(via `soffice` + `pdf2image`), then `gpt-5.4` (through litellm) extracts
knowledge points and style per page; an aggregation pass produces a
bank-level style-and-topic profile. Generation builds a structured exam
**spec** (the source of truth — questions, answers, knowledge points), then
descriptive English page-prompts, then fans out to `gpt-image-2` with
bounded concurrency and retry. Chat revision edits the spec and re-renders
just the affected pages. Progress streams to the frontend over SSE.

The spec JSON, not the PNG, is canonical. The PNG is a stylized companion.

## Plan & memory

The full implementation plan lives at
`~/.claude/plans/buzzing-sparking-raven.md`.

Project memory (Claude Code) lives at
`~/.claude/projects/-Users-avatar-Desktop-projects-ExamCraft/memory/`.
1 change: 1 addition & 0 deletions backend/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
33 changes: 33 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ExamCraft backend

FastAPI + SQLAlchemy (aiosqlite) + litellm + pdf2image, uv-managed.

```sh
uv sync
uv run examcraft-server
```

The server reads `../.env` (one directory up — single env file shared with
the web side).

## Layout

```
app/
├── main.py FastAPI app factory + lifespan
├── cli.py `examcraft-server` entry point
├── config.py pydantic-settings, loads ../.env
├── db.py async engine + session, WAL mode
├── models.py SQLAlchemy ORM
├── auth.py passwordless cookie session (HMAC via itsdangerous)
├── api/ routers (auth, banks, samples, generations, chat)
├── services/ llm.py, image_gen.py, docrender.py, ingestion.py, generation.py, revision.py
├── jobs.py in-process JobRegistry + asyncio.create_task
└── sse.py per-job pub/sub for progress events
```

## Tests

```sh
uv run pytest -q
```
3 changes: 3 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""ExamCraft backend."""

__version__ = "0.1.0"
Empty file added backend/app/api/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import (
USERNAME_RE,
clear_session_cookie,
current_user,
issue_session_cookie,
)
from app.db import get_session
from app.models import User

router = APIRouter(prefix="/api/auth", tags=["auth"])


class LoginIn(BaseModel):
username: str = Field(..., min_length=1, max_length=32)


class UserOut(BaseModel):
id: str
username: str


@router.post("/login", response_model=UserOut)
async def login(
body: LoginIn,
response: Response,
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserOut:
if not USERNAME_RE.match(body.username):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid username")
user = (
await session.execute(select(User).where(User.username == body.username))
).scalar_one_or_none()
if user is None:
user = User(username=body.username)
session.add(user)
await session.commit()
await session.refresh(user)
issue_session_cookie(response, user.id)
return UserOut(id=user.id, username=user.username)


@router.post("/logout")
async def logout(response: Response) -> dict[str, bool]:
clear_session_cookie(response)
return {"ok": True}


@router.get("/me", response_model=UserOut)
async def me(user: Annotated[User, Depends(current_user)]) -> UserOut:
return UserOut(id=user.id, username=user.username)
97 changes: 97 additions & 0 deletions backend/app/api/banks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import current_user
from app.db import get_session
from app.models import Bank, User

router = APIRouter(prefix="/api/banks", tags=["banks"])


class BankCreateIn(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
description: str | None = Field(None, max_length=500)


class BankOut(BaseModel):
id: str
name: str
description: str | None
analysis_status: str
created_at: str

@classmethod
def from_model(cls, b: Bank) -> BankOut:
return cls(
id=b.id,
name=b.name,
description=b.description,
analysis_status=b.analysis_status,
created_at=b.created_at.isoformat() if b.created_at else "",
)


async def _load_owned_bank(
bank_id: str, user: User, session: AsyncSession
) -> Bank:
bank = (
await session.execute(
select(Bank).where(Bank.id == bank_id, Bank.user_id == user.id)
)
).scalar_one_or_none()
if bank is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bank not found")
return bank


@router.get("", response_model=list[BankOut])
async def list_banks(
user: Annotated[User, Depends(current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> list[BankOut]:
rows = (
await session.execute(
select(Bank).where(Bank.user_id == user.id).order_by(desc(Bank.created_at))
)
).scalars().all()
return [BankOut.from_model(b) for b in rows]


@router.post("", response_model=BankOut, status_code=status.HTTP_201_CREATED)
async def create_bank(
body: BankCreateIn,
user: Annotated[User, Depends(current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> BankOut:
bank = Bank(user_id=user.id, name=body.name, description=body.description)
session.add(bank)
await session.commit()
await session.refresh(bank)
return BankOut.from_model(bank)


@router.get("/{bank_id}", response_model=BankOut)
async def get_bank(
bank_id: str,
user: Annotated[User, Depends(current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> BankOut:
bank = await _load_owned_bank(bank_id, user, session)
return BankOut.from_model(bank)


@router.delete("/{bank_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bank(
bank_id: str,
user: Annotated[User, Depends(current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
bank = await _load_owned_bank(bank_id, user, session)
await session.delete(bank)
await session.commit()
Loading