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
55 changes: 51 additions & 4 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,17 +1,64 @@
.git
.github

# -----------------------
# Secrets -- must never enter an image layer.
# docker/Dockerfile.api does `COPY apps/api .`, so an unignored apps/api/.env
# would be baked into the image along with its API keys.
# -----------------------
.env
.env.*
**/.env
**/.env.*
!.env.example

# -----------------------
# Dependencies / virtualenvs
# Both spellings: the checked-out venv is `.venv`, and only `venv` was listed
# before, so a ~470MB Darwin-built venv was being sent to a Linux image.
# -----------------------
node_modules
**/node_modules
venv
.venv
**/venv
**/.venv

# -----------------------
# Build outputs
# -----------------------
apps/web/.next
apps/web/tsconfig.tsbuildinfo
apps/web/next-env.d.ts
apps/web/pnpm-lock.yaml
apps/web/pnpm-workspace.yaml
apps/api/venv
apps/api/.pytest_cache
apps/api/.ruff_cache
apps/api/.coverage
dist
build
**/__pycache__
**/*.py[cod]

# -----------------------
# Test / coverage artifacts
# -----------------------
**/.pytest_cache
**/.ruff_cache
**/.coverage
**/.coverage*
**/coverage.xml
**/htmlcov
**/test_output.txt

# -----------------------
# Data & local state
# -----------------------
apps/api/data
data
Documents
*.log

# -----------------------
# Docs (a 6.7MB PNG lives here; nothing in either image needs it)
# -----------------------
docs
*.md
!README.md
17 changes: 14 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
# ===========================================
# CodebaseQA Environment Configuration
# ===========================================
# Copy this file to .env and fill in your values
# Never commit .env to version control!

#
# WHERE THIS FILE GOES depends on how you run the app -- the two are different:
#
# Local dev: apps/api/.env
# config.py loads env_file=".env" relative to the working directory,
# and the README starts uvicorn from apps/api.
#
# Docker: docker/.env
# docker-compose.yml expands ${VAR} from the file next to itself.
# A .env at the repo root is NOT picked up for that interpolation.
#
# -----------------------
# LLM Providers (at least one required)
# -----------------------
OPENAI_API_KEY=sk
# Leave commented until you have a real key: a placeholder is still a non-empty
# value, so the app would believe it is configured and fail only on first use.
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...

# For local LLM (optional, no key needed)
Expand Down
22 changes: 16 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,22 @@ jobs:
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install pytest pytest-asyncio httpx pytest-mock pytest-cov ruff

# Only the linter is not a project dependency; everything the tests need
# (pytest, pytest-asyncio, pytest-cov, httpx) is declared in requirements.txt
# so a fresh clone can run the suite too. Pinned so an unrelated PR cannot
# go red the day ruff ships a new rule.
pip install ruff==0.16.2

- name: Lint with Ruff
run: |
ruff check src tests

- name: Run Tests
# `tests`, not `tests/unit tests/integration`: tests/test_parser.py sits at the
# tests/ root and was silently excluded, so CI never built a single tree-sitter
# parser and local runs (pyproject testpaths = ["tests"]) covered more than CI.
run: |
pytest tests/unit tests/integration --cov=src --cov-report=xml
pytest tests --cov=src --cov-report=xml

- name: Upload coverage reports
uses: codecov/codecov-action@v4
Expand All @@ -59,9 +66,12 @@ jobs:
node-version: '20'
cache: 'pnpm'

# --frozen-lockfile matches apps/web/vercel.json. With --no-frozen-lockfile CI
# silently repaired lockfile drift that the Vercel build then rejected, so a PR
# could go green and the production deploy fail on the same commit.
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm --filter web lint

Expand Down
14 changes: 12 additions & 2 deletions apps/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ sqlalchemy>=2.0.0
aiosqlite>=0.19.0

# Vector Store
chromadb>=0.4.0
# >=1.0: chroma_store.py references chromadb.errors.NotFoundError, absent in 0.4.x.
chromadb>=1.0.0

# LLM Providers
openai>=1.12.0
anthropic>=0.18.0
tiktoken>=0.6.0

# Code Parsing
tree-sitter>=0.21.0
# >=0.22: tree_sitter_parser.py uses Parser(language) and single-argument
# Language(capsule); both were introduced in 0.22.0, so the old >=0.21.0 floor
# permitted a version where importing the parser raises TypeError and the API
# cannot start at all.
tree-sitter>=0.22.0
tree-sitter-python>=0.21.0
tree-sitter-javascript>=0.21.0
tree-sitter-typescript>=0.21.0
Expand All @@ -44,7 +49,12 @@ cachetools>=5.3.0
structlog>=24.1.0
tenacity>=8.2.0
redis>=5.0.0
# Imported by main.py for the /openapi.yaml route; previously only present
# transitively, so a dependency bump could have 500'd that endpoint.
pyyaml>=6.0

# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.0
# pyproject.toml's addopts hardcodes --cov, so pytest cannot start without this.
pytest-cov>=4.1.0
16 changes: 13 additions & 3 deletions apps/api/src/api/routes/learning.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import logging
from typing import Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from src.core.demo_mode import assert_demo_repo_access
Expand All @@ -14,6 +15,7 @@
from src.services.learning_service import LearningService

router = APIRouter(tags=["learning"])
logger = logging.getLogger(__name__)


def _assert_learning_repo_access(service: LearningService, repo_id: str) -> None:
Expand Down Expand Up @@ -138,24 +140,32 @@ async def get_lesson(
raise HTTPException(status_code=500, detail=str(e))

class GenerateQuizRequest(BaseModel):
context_content: str
# Bounded like ChatMessageCreate.content (schemas.py): this string goes straight
# into an LLM prompt, so an unbounded body is an unmetered spend vector.
context_content: str = Field(..., max_length=20000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rejecting long lessons before truncation

When a generated lesson's content_markdown exceeds 20 KB, the frontend still sends the full lesson body to this endpoint (api.generateQuiz(..., content.content_markdown)), but LearningService.generate_quiz() only uses context_content[:2000] in the actual prompt. This new validation rejects those long lessons with a 422 before the server can use the safe 2 KB slice, so users can no longer generate quizzes for longer lessons even though no extra LLM spend would occur. Consider truncating before validation or having the client send the already-bounded excerpt.

Useful? React with 👍 / 👎.


@router.post("/{repo_id}/lessons/{lesson_id}/quiz")
async def generate_quiz(
repo_id: str,
lesson_id: str,
request: GenerateQuizRequest,
http_request: Request,
service: LearningService = Depends(get_learning_service)
):
"""Generate a quiz for a lesson."""
_assert_learning_repo_access(service, repo_id)
# This is an LLM call; it needs the same demo throttling as chat/lesson/graph.
await enforce_demo_soft_limit(http_request, "quiz")
try:
quiz = await service.generate_quiz(repo_id, lesson_id, request.context_content)
if not quiz:
raise HTTPException(status_code=500, detail="Failed to generate quiz")
return quiz
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
logger.exception("Quiz generation failed for repo=%s lesson=%s", repo_id, lesson_id)
raise HTTPException(status_code=500, detail="Failed to generate quiz") from e

@router.get("/{repo_id}/lessons/{lesson_id}/export/codetour", response_model=CodeTour)
async def export_codetour(
Expand Down
83 changes: 69 additions & 14 deletions apps/api/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,34 @@
Designed for easy self-hosting with sensible defaults.
"""

import json
from functools import lru_cache
from typing import List, Optional

from pydantic import field_validator
from pydantic import Field, ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

DEFAULT_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]

# Settings where a blank value in .env is always a mistake rather than an intent.
# pydantic-settings takes `FOO=` from the environment as the empty string, which
# silently replaces the declared default -- e.g. a blank LOCAL_EMBEDDING_MODEL
# turns into a request for the model named "".
_BLANK_MEANS_DEFAULT = (
"llm_provider",
"embedding_provider",
"openai_model",
"openai_embedding_model",
"anthropic_model",
"ollama_model",
"ollama_base_url",
"local_embedding_model",
"database_url",
"chroma_persist_dir",
"repos_dir",
"vector_db_type",
)


class Settings(BaseSettings):
"""
Expand All @@ -25,7 +47,12 @@ class Settings(BaseSettings):
port: int = 8000

# CORS
cors_origins: List[str] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# Stored as a raw string: pydantic-settings JSON-decodes complex types (List[str])
# inside EnvSettingsSource *before* field validators run, so a comma-separated
# CORS_ORIGINS would raise SettingsError at import and kill the process before
# uvicorn binds. Keeping it a str and parsing in the property accepts both the
# comma-separated form (docker-compose, .env.example) and a JSON array.
cors_origins_raw: Optional[str] = Field(default=None, validation_alias="CORS_ORIGINS")

# Database
database_url: str = "sqlite:///./data/codebaseqa.db"
Expand Down Expand Up @@ -58,10 +85,16 @@ class Settings(BaseSettings):
openai_embedding_rate_limit_max_backoff_seconds: float = 30.0
voyage_api_key: Optional[str] = None
voyage_model: str = "voyage-code-3"
local_embedding_model: str = "nomic-ai/nomic-embed-text-v1.5"
# Must be an Ollama *tag* (as in `ollama pull <tag>`), not a HuggingFace repo id.
# A HF id such as "nomic-ai/nomic-embed-text-v1.5" 404s on every request, and with
# fail_open every chunk then lands in the index as a zero vector.
local_embedding_model: str = "nomic-embed-text"
ollama_embedding_num_ctx: int = 2048 # Smaller context improves stability
ollama_embedding_max_chars: int = 3000 # Safety cap per chunk for Ollama
ollama_embedding_fail_open: bool = True # Continue indexing on occasional failures
# Ceiling on fail-open: abort the batch once this fraction of chunks has failed,
# so a total misconfiguration cannot silently produce an all-zero-vector index.
ollama_embedding_max_failure_ratio: float = 0.2

# GitHub
github_token: Optional[str] = None
Expand Down Expand Up @@ -172,26 +205,48 @@ class Settings(BaseSettings):
demo_graph_window_seconds: int = 60
demo_challenge_requests: int = 10
demo_challenge_window_seconds: int = 60
demo_quiz_requests: int = 8
demo_quiz_window_seconds: int = 60

# Learning V2 controls
learning_v2_enabled: bool = False
learning_cache_ttl_days: int = 7
learning_prompt_version: str = "learning_v2_1"

@field_validator("cors_origins", mode="before")
@field_validator(*_BLANK_MEANS_DEFAULT, mode="before")
@classmethod
def parse_cors_origins(cls, value):
def _blank_falls_back_to_default(cls, value, info: ValidationInfo):
"""Treat a blank env assignment (FOO=) as 'unset' rather than the empty string."""
if isinstance(value, str) and not value.strip():
field = cls.model_fields.get(info.field_name)
if field is not None and field.default is not None:
return field.default
return value

@property
def cors_origins(self) -> List[str]:
"""
Accept JSON lists or comma-separated strings for CORS_ORIGINS.
Allowed CORS origins. Accepts a JSON array or a comma-separated string.
Falls back to the localhost defaults when unset or blank.
"""
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped.startswith("["):
return value
return [origin.strip() for origin in stripped.split(",") if origin.strip()]
return value
raw = self.cors_origins_raw
if raw is None:
return list(DEFAULT_CORS_ORIGINS)

stripped = raw.strip()
if not stripped:
return []

if stripped.startswith("["):
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
return list(DEFAULT_CORS_ORIGINS)
if isinstance(parsed, list):
return [str(origin).strip() for origin in parsed if str(origin).strip()]
return list(DEFAULT_CORS_ORIGINS)

return [origin.strip() for origin in stripped.split(",") if origin.strip()]

model_config = SettingsConfigDict(
env_file=".env",
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/core/cache/chat_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _key_answer(
intent: str,
top_chunk_ids: List[str],
model: str,
history_digest: str = "none",
) -> str:
digest = _hash_payload(
{
Expand All @@ -109,6 +110,10 @@ def _key_answer(
"intent": intent,
"chunk_ids": top_chunk_ids[:12],
"model": model,
# The prompt includes conversation history, so the key must too --
# otherwise the same question in two different sessions collides and
# one session is served the other's history-conditioned answer.
"history": history_digest,
}
)
return f"chat:answer:{digest}"
Expand Down Expand Up @@ -167,8 +172,9 @@ async def get_answer(
intent: str,
top_chunk_ids: List[str],
model: str,
history_digest: str = "none",
) -> Optional[str]:
key = self._key_answer(repo_id, question, intent, top_chunk_ids, model)
key = self._key_answer(repo_id, question, intent, top_chunk_ids, model, history_digest)
value = await self._redis_get(key)
if value is None:
value = self._local_get(self._answer_cache, key)
Expand All @@ -186,8 +192,9 @@ async def set_answer(
top_chunk_ids: List[str],
model: str,
answer: str,
history_digest: str = "none",
) -> None:
key = self._key_answer(repo_id, question, intent, top_chunk_ids, model)
key = self._key_answer(repo_id, question, intent, top_chunk_ids, model, history_digest)
self._local_set(self._answer_cache, key, answer)
await self._redis_set(key, answer, settings.chat_answer_cache_ttl_seconds)

Expand Down
Loading
Loading