This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Install deps:
uv sync(Python >= 3.13, managed withuv; always useuv run/uv add, not pip) - Run the app:
./run.sh, orcd backend && uv run uvicorn app:app --reload --port 8000- Web UI: http://localhost:8000 — API docs: http://localhost:8000/docs
- Requires
ANTHROPIC_API_KEYin a root.envfile (see.env.example).
There is no linter or formatter configured.
- Automated tests (pytest):
cd backend && uv run pytest tests. They are hermetic: a fake Anthropic client and a temp ChromaDB (seebackend/tests/conftest.py), so they never touch the network orbackend/chroma_db. - Live smoke checks (NOT pytest):
cd backend && uv run python live_checks.py [db|search|course|lesson|config|endpoint ...](no args runs all). They validate the real setup: the actual DB (on a temp copy, never modified),ANTHROPIC_API_KEY, and the configured LLM endpoint (endpointmakes one real ~10-token API call). Run this when the app misbehaves, e.g. on "Query failed", or after changing config, ingestion, or the LLM server. Exit code is 1 if any check fails.
The server must be started from backend/: app.py uses relative paths (../docs, ../frontend) and ChromaDB persists to ./chroma_db (i.e. backend/chroma_db).
Course-materials RAG chatbot: FastAPI backend + vanilla JS frontend (frontend/, served as static files by the backend at /). Backend modules in backend/ import each other as top-level modules (no package), so run from that directory.
POST /api/query(app.py) →RAGSystem.query()(rag_system.py), creating a session if none was sent.AIGenerator.generate_response()(ai_generator.py) calls Claude with the system prompt, conversation history (embedded in the system prompt as text), and tool definitions fromToolManager.- Claude decides whether to call the
search_course_contenttool (CourseSearchToolinsearch_tools.py). General-knowledge questions are answered without searching. - If
stop_reason == "tool_use",_handle_tool_executionruns the tools and makes one follow-up API call without tools — so only a single round of tool use is supported per query. - Sources are passed back out-of-band:
CourseSearchToolstoreslast_sourceson itself duringexecute;RAGSystemreads them viaToolManager.get_last_sources()and then callsreset_sources(). SessionManager(in-memory, lost on restart) keeps the lastMAX_HISTORYexchanges per session.
GET /api/courses returns course count/titles from the catalog.
Two ChromaDB collections, both embedded with all-MiniLM-L6-v2 via sentence-transformers:
course_catalog— one doc per course, ID = course title; lesson list stored as a JSON string in metadata. Used to fuzzy-resolve a user-supplied course name to an exact title (_resolve_course_namedoes a 1-result semantic search).course_content— text chunks withcourse_title,lesson_number,chunk_indexmetadata; IDs are{title_with_underscores}_{chunk_index}.
VectorStore.search() resolves the course name, builds a where filter (course and/or lesson), then queries course_content.
On startup, app.py loads every .txt/.pdf/.docx in docs/ via RAGSystem.add_course_folder(clear_existing=False). Courses whose title already exists in the catalog are skipped, so editing a doc under an existing title won't re-ingest it — delete backend/chroma_db (or pass clear_existing=True) to rebuild. (Note: files are read as plain text; PDF/DOCX aren't actually parsed.)
Expected document format:
Course Title: <title>
Course Link: <url>
Course Instructor: <name>
Lesson 0: <lesson title>
Lesson Link: <url>
<lesson content...>
Lesson 1: ...
Text is split into sentence-based chunks (CHUNK_SIZE 800 chars, CHUNK_OVERLAP 100). Quirk: chunk context prefixes are inconsistent — for most lessons only the first chunk gets "Lesson N content: ", while every chunk of the final lesson gets "Course <title> Lesson N content: ".
All tunables (model, embedding model, chunk sizes, MAX_RESULTS, MAX_HISTORY, CHROMA_PATH) live in the Config dataclass in backend/config.py. Claude response length is capped by max_tokens: 800 in AIGenerator, and the assistant's behavior (one search per query, no meta-commentary) is governed by AIGenerator.SYSTEM_PROMPT.