Skip to content
Open
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: 0 additions & 22 deletions TEACHING_ROADMAP_IMPLEMENTATION.md

This file was deleted.

38 changes: 0 additions & 38 deletions UI_UX_TWEAKS.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""add guide chat sessions table

Revision ID: 20241124_add_guide_chat_sessions
Revises: aae1346a34e5
Create Date: 2025-11-24 19:20:00.000000
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "20241124_add_guide_chat_sessions"
down_revision = "aae1346a34e5"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"guide_chat_sessions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.String(length=255), nullable=False),
sa.Column("repo_full_name", sa.String(length=255), nullable=False),
sa.Column("stage_id", sa.String(length=255), nullable=True),
sa.Column("messages", sa.JSON(), nullable=False, server_default="[]"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
onupdate=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint(
"user_id",
"repo_full_name",
"stage_id",
name="uq_guide_chat_user_repo_stage",
),
)
op.create_index("ix_guide_chat_sessions_user", "guide_chat_sessions", ["user_id"])
op.create_index(
"ix_guide_chat_sessions_repo", "guide_chat_sessions", ["repo_full_name"]
)
op.create_index("ix_guide_chat_sessions_stage", "guide_chat_sessions", ["stage_id"])


def downgrade():
op.drop_index("ix_guide_chat_sessions_stage", table_name="guide_chat_sessions")
op.drop_index("ix_guide_chat_sessions_repo", table_name="guide_chat_sessions")
op.drop_index("ix_guide_chat_sessions_user", table_name="guide_chat_sessions")
op.drop_table("guide_chat_sessions")
116 changes: 107 additions & 9 deletions commitly-backend/app/api/roadmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
from app.core.database import get_db
from app.models.roadmap import (
CatalogPage,
ChatHistoryResponse,
ChatRequest,
ChatResponse,
RatingRequest,
RatingResponse,
RoadmapRequest,
RoadmapResponse,
SaveChatRequest,
UserRepoStateResponse,
)
from app.models.roadmap import GuideChatSession
from app.services.ai.chat import GeminiChatService
from app.services.roadmap_service import RoadmapService, build_roadmap_service

Expand All @@ -41,6 +43,15 @@ def get_user_id(claims: ClerkClaims) -> str:
return user_id


def get_chat_session(
session: Session, user_id: str, repo_full_name: str, stage_id: str | None
) -> GuideChatSession | None:
query = session.query(GuideChatSession).filter_by(
user_id=user_id, repo_full_name=repo_full_name, stage_id=stage_id
)
return query.first()


@router.get("/catalog", response_model=CatalogPage)
async def list_roadmaps(
page: int = Query(1, ge=1, description="Page number"),
Expand Down Expand Up @@ -251,25 +262,112 @@ async def record_roadmap_view(
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.post("/chat", response_model=ChatResponse)
@router.post("/chat")
async def chat_with_guide(
payload: ChatRequest,
session: Session = Depends(get_db),
current_user: ClerkClaims = Depends(require_clerk_auth),
) -> ChatResponse:
# current_user: ClerkClaims = Depends(require_clerk_auth),
) -> StreamingResponse:
"""
Chat with the AI guide about the repository or a specific stage.
"""
if not settings.gemini_api_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Gemini API key not configured.",
)

chat_service = GeminiChatService(
session=session,
api_key=settings.gemini_api_key,
model=settings.gemini_model,
)

response = await chat_service.chat(
repo_full_name=payload.repo_full_name,
message=payload.message,
stage_id=payload.stage_id,
# If messages are provided (from useChat), use them.
# Otherwise, construct a single message list from payload.message.
messages = payload.messages
if not messages:
if not payload.message:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either 'messages' or 'message' must be provided.",
)
messages = [{"role": "user", "content": payload.message}]

return StreamingResponse(
chat_service.chat_stream(
repo_full_name=payload.repo_full_name,
messages=messages,
stage_id=payload.stage_id,
),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Vercel-AI-Data-Stream": "v1",
},
)

return ChatResponse(response=response)

@router.get("/chat/history", response_model=ChatHistoryResponse)
async def get_chat_history(
repo_full_name: str,
stage_id: Optional[str] = None,
session: Session = Depends(get_db),
current_user: ClerkClaims = Depends(require_clerk_auth),
):
user_id = get_user_id(current_user)
try:
record = get_chat_session(session, user_id, repo_full_name, stage_id)
if not record:
return ChatHistoryResponse(
repo_full_name=repo_full_name, stage_id=stage_id, messages=[]
)
return ChatHistoryResponse(
repo_full_name=record.repo_full_name,
stage_id=record.stage_id,
messages=record.messages or [],
)
except Exception as exc: # pragma: no cover - defensive fallback
# If the table doesn't exist yet (migration pending), return empty history
import logging

logging.getLogger(__name__).warning(
"chat history fetch failed: %s", exc, exc_info=True
)
return ChatHistoryResponse(
repo_full_name=repo_full_name, stage_id=stage_id, messages=[]
)


@router.post("/chat/history", status_code=status.HTTP_204_NO_CONTENT)
async def save_chat_history(
payload: SaveChatRequest,
session: Session = Depends(get_db),
current_user: ClerkClaims = Depends(require_clerk_auth),
):
user_id = get_user_id(current_user)
try:
record = get_chat_session(
session, user_id, payload.repo_full_name, payload.stage_id
)

if record:
record.messages = payload.messages
else:
record = GuideChatSession(
user_id=user_id,
repo_full_name=payload.repo_full_name,
stage_id=payload.stage_id,
messages=payload.messages,
)
session.add(record)

session.commit()
except Exception as exc: # pragma: no cover
import logging

logging.getLogger(__name__).warning(
"chat history save failed: %s", exc, exc_info=True
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
7 changes: 3 additions & 4 deletions commitly-backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from functools import lru_cache
import json
from typing import Any, List, Optional, Union

from pydantic import Field, HttpUrl, field_validator
Expand Down Expand Up @@ -44,7 +45,7 @@ class Settings(BaseSettings):
)

# GitHub ingestion
github_api_base: HttpUrl = Field(
github_api_base: HttpUrl = Field( # type: ignore
"https://api.github.com", validation_alias="GITHUB_API_BASE"
)
github_token: Optional[str] = Field(default=None, validation_alias="GITHUB_TOKEN")
Expand Down Expand Up @@ -91,8 +92,6 @@ def _coerce_list(value: Any) -> List[str]:
return []
if cleaned.startswith("["):
try:
import json

data = json.loads(cleaned)
if isinstance(data, list):
return [
Expand Down Expand Up @@ -136,7 +135,7 @@ def parse_authorized_parties(cls, value: Any) -> List[str]:
@lru_cache
def get_settings() -> Settings:
"""Return a cached instance of the application settings."""
return Settings()
return Settings() # type: ignore


settings = get_settings()
46 changes: 45 additions & 1 deletion commitly-backend/app/models/roadmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,37 @@ class RoadmapRating(Base):
)


class GuideChatSession(Base):
"""Stores the latest guide chat history per user/repo/stage."""

__tablename__ = "guide_chat_sessions"
__table_args__ = (
UniqueConstraint(
"user_id",
"repo_full_name",
"stage_id",
name="uq_guide_chat_user_repo_stage",
),
)

id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
stage_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, index=True
)
messages: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)


class RoadmapViewTracker(Base):
"""Tracks views to prevent spam and implement anti-spam logic."""

Expand Down Expand Up @@ -304,10 +335,23 @@ class RatingResponse(BaseModel):


class ChatRequest(BaseModel):
message: str
message: Optional[str] = None
repo_full_name: str
stage_id: Optional[str] = None
messages: Optional[List[dict]] = None # For full chat history context


class ChatResponse(BaseModel):
response: str


class SaveChatRequest(BaseModel):
repo_full_name: str
stage_id: Optional[str] = None
messages: List[dict]


class ChatHistoryResponse(BaseModel):
repo_full_name: str
stage_id: Optional[str] = None
messages: List[dict]
Loading
Loading