From 77619b50b9dd5e1e8e002d1198c239f829317472 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Fri, 25 Jul 2025 18:32:57 +0530 Subject: [PATCH 01/22] added migration --- ...d35eff62c_add_openai_conversation_table.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py diff --git a/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py b/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py new file mode 100644 index 00000000..abbaf7b6 --- /dev/null +++ b/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py @@ -0,0 +1,87 @@ +"""add_openai_conversation_table + +Revision ID: e9dd35eff62c +Revises: e8ee93526b37 +Create Date: 2025-07-25 18:26:38.132146 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = "e9dd35eff62c" +down_revision = "e8ee93526b37" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "openai_conversation", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "ancestor_response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column( + "previous_response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("user_question", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("response", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("model", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("assistant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("project_id", sa.Integer(), nullable=False), + sa.Column("organization_id", sa.Integer(), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + ) + op.create_index( + op.f("ix_openai_conversation_ancestor_response_id"), + "openai_conversation", + ["ancestor_response_id"], + unique=False, + ) + op.create_index( + op.f("ix_openai_conversation_previous_response_id"), + "openai_conversation", + ["previous_response_id"], + unique=False, + ) + op.create_index( + op.f("ix_openai_conversation_response_id"), + "openai_conversation", + ["response_id"], + unique=False, + ) + op.create_foreign_key( + None, "openai_conversation", "project", ["project_id"], ["id"] + ) + op.create_foreign_key( + None, "openai_conversation", "organization", ["organization_id"], ["id"] + ) + + +def downgrade(): + op.drop_constraint(None, "openai_conversation", type_="foreignkey") + op.drop_constraint(None, "openai_conversation", type_="foreignkey") + op.drop_index( + op.f("ix_openai_conversation_response_id"), table_name="openai_conversation" + ) + op.drop_index( + op.f("ix_openai_conversation_previous_response_id"), + table_name="openai_conversation", + ) + op.drop_index( + op.f("ix_openai_conversation_ancestor_response_id"), + table_name="openai_conversation", + ) + op.drop_table("openai_conversation") From aeebc1f6a849f46d40df718171260b506e555591 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Sat, 26 Jul 2025 12:01:00 +0530 Subject: [PATCH 02/22] first stab at CRUD --- backend/app/api/main.py | 2 + backend/app/api/routes/openai_conversation.py | 228 ++++++++++ backend/app/crud/__init__.py | 12 + backend/app/crud/openai_conversation.py | 250 +++++++++++ backend/app/models/__init__.py | 7 + backend/app/models/openai_conversation.py | 78 ++++ backend/app/models/organization.py | 4 + backend/app/models/project.py | 3 + .../api/routes/test_openai_conversation.py | 403 +++++++++++++++++ .../tests/crud/test_openai_conversation.py | 406 ++++++++++++++++++ backend/app/tests/utils/conversation.py | 80 ++++ backend/app/tests/utils/utils.py | 26 +- 12 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/openai_conversation.py create mode 100644 backend/app/crud/openai_conversation.py create mode 100644 backend/app/models/openai_conversation.py create mode 100644 backend/app/tests/api/routes/test_openai_conversation.py create mode 100644 backend/app/tests/crud/test_openai_conversation.py create mode 100644 backend/app/tests/utils/conversation.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 7db3c3d5..df0b1016 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -7,6 +7,7 @@ documents, login, organization, + openai_conversation, project, project_user, responses, @@ -27,6 +28,7 @@ api_router.include_router(documents.router) api_router.include_router(login.router) api_router.include_router(onboarding.router) +api_router.include_router(openai_conversation.router) api_router.include_router(organization.router) api_router.include_router(project.router) api_router.include_router(project_user.router) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py new file mode 100644 index 00000000..ee553d79 --- /dev/null +++ b/backend/app/api/routes/openai_conversation.py @@ -0,0 +1,228 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, HTTPException, Query +from sqlmodel import Session + +from app.api.deps import get_db, get_current_user_org_project +from app.crud import ( + get_conversation_by_id, + get_conversation_by_response_id, + get_conversations_by_project, + get_conversations_by_assistant, + get_conversation_thread, + create_conversation, + update_conversation, + delete_conversation, + upsert_conversation, +) +from app.models import ( + UserProjectOrg, + OpenAIConversationCreate, + OpenAIConversationUpdate, + OpenAIConversation, +) +from app.utils import APIResponse + +router = APIRouter(prefix="/openai-conversation", tags=["OpenAI Conversations"]) + + +@router.post("/", response_model=APIResponse[OpenAIConversation], status_code=201) +def create_conversation_route( + conversation_in: OpenAIConversationCreate, + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Create a new OpenAI conversation in the database. + """ + conversation = create_conversation( + session=session, + conversation=conversation_in, + project_id=current_user.project_id, + organization_id=current_user.organization_id, + ) + return APIResponse.success_response(conversation) + + +@router.post("/upsert", response_model=APIResponse[OpenAIConversation], status_code=201) +def upsert_conversation_route( + conversation_in: OpenAIConversationCreate, + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Create a new conversation or update existing one if response_id already exists. + """ + conversation = upsert_conversation( + session=session, + conversation=conversation_in, + project_id=current_user.project_id, + organization_id=current_user.organization_id, + ) + return APIResponse.success_response(conversation) + + +@router.patch("/{conversation_id}", response_model=APIResponse[OpenAIConversation]) +def update_conversation_route( + conversation_id: Annotated[int, Path(description="Conversation ID to update")], + conversation_update: OpenAIConversationUpdate, + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Update an existing conversation with provided fields. + """ + updated_conversation = update_conversation( + session=session, + conversation_id=conversation_id, + project_id=current_user.project_id, + conversation_update=conversation_update, + ) + + if not updated_conversation: + raise HTTPException( + status_code=404, detail=f"Conversation with ID {conversation_id} not found." + ) + + return APIResponse.success_response(updated_conversation) + + +@router.get( + "/{conversation_id}", + response_model=APIResponse[OpenAIConversation], + summary="Get a single conversation by its ID", +) +def get_conversation_route( + conversation_id: int = Path(..., description="The conversation ID to fetch"), + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Fetch a single conversation by its ID. + """ + conversation = get_conversation_by_id( + session, conversation_id, current_user.project_id + ) + if not conversation: + raise HTTPException( + status_code=404, detail=f"Conversation with ID {conversation_id} not found." + ) + return APIResponse.success_response(conversation) + + +@router.get( + "/response/{response_id}", + response_model=APIResponse[OpenAIConversation], + summary="Get a conversation by its OpenAI response ID", +) +def get_conversation_by_response_id_route( + response_id: str = Path(..., description="The OpenAI response ID to fetch"), + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Fetch a conversation by its OpenAI response ID. + """ + conversation = get_conversation_by_response_id( + session, response_id, current_user.project_id + ) + if not conversation: + raise HTTPException( + status_code=404, + detail=f"Conversation with response ID {response_id} not found.", + ) + return APIResponse.success_response(conversation) + + +@router.get( + "/thread/{response_id}", + response_model=APIResponse[list[OpenAIConversation]], + summary="Get the full conversation thread starting from a response ID", +) +def get_conversation_thread_route( + response_id: str = Path( + ..., description="The response ID to start the thread from" + ), + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Get the full conversation thread starting from a given response ID. + This includes all ancestor and previous responses in the conversation chain. + """ + thread_conversations = get_conversation_thread( + session=session, + response_id=response_id, + project_id=current_user.project_id, + ) + return APIResponse.success_response(thread_conversations) + + +@router.get( + "/", + response_model=APIResponse[list[OpenAIConversation]], + summary="List all conversations in the current project", +) +def list_conversations_route( + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), + skip: int = Query(0, ge=0, description="How many items to skip"), + limit: int = Query(100, ge=1, le=100, description="Maximum items to return"), +): + """ + List all conversations in the current project. + """ + conversations = get_conversations_by_project( + session=session, project_id=current_user.project_id, skip=skip, limit=limit + ) + return APIResponse.success_response(conversations) + + +@router.get( + "/assistant/{assistant_id}", + response_model=APIResponse[list[OpenAIConversation]], + summary="List all conversations for a specific assistant", +) +def list_conversations_by_assistant_route( + assistant_id: str = Path(..., description="The assistant ID to filter by"), + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), + skip: int = Query(0, ge=0, description="How many items to skip"), + limit: int = Query(100, ge=1, le=100, description="Maximum items to return"), +): + """ + List all conversations for a specific assistant in the current project. + """ + conversations = get_conversations_by_assistant( + session=session, + assistant_id=assistant_id, + project_id=current_user.project_id, + skip=skip, + limit=limit, + ) + return APIResponse.success_response(conversations) + + +@router.delete("/{conversation_id}", response_model=APIResponse) +def delete_conversation_route( + conversation_id: Annotated[int, Path(description="Conversation ID to delete")], + session: Session = Depends(get_db), + current_user: UserProjectOrg = Depends(get_current_user_org_project), +): + """ + Soft delete a conversation by marking it as deleted. + """ + deleted_conversation = delete_conversation( + session=session, + conversation_id=conversation_id, + project_id=current_user.project_id, + ) + + if not deleted_conversation: + raise HTTPException( + status_code=404, detail=f"Conversation with ID {conversation_id} not found." + ) + + return APIResponse.success_response( + data={"message": "Conversation deleted successfully."} + ) diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 49b09f56..a2cd5f3c 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -54,3 +54,15 @@ get_assistants_by_project, delete_assistant, ) + +from .openai_conversation import ( + get_conversation_by_id, + get_conversation_by_response_id, + get_conversations_by_project, + get_conversations_by_assistant, + get_conversation_thread, + create_conversation, + update_conversation, + delete_conversation, + upsert_conversation, +) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py new file mode 100644 index 00000000..e59ef057 --- /dev/null +++ b/backend/app/crud/openai_conversation.py @@ -0,0 +1,250 @@ +import logging +from typing import Optional, List + +from sqlmodel import Session, and_, select + +from app.models import ( + OpenAIConversation, + OpenAIConversationCreate, + OpenAIConversationUpdate, +) +from app.core.util import now + +logger = logging.getLogger(__name__) + + +def get_conversation_by_id( + session: Session, conversation_id: int, project_id: int +) -> Optional[OpenAIConversation]: + """Get a conversation by its ID and project ID.""" + statement = select(OpenAIConversation).where( + and_( + OpenAIConversation.id == conversation_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + ) + return session.exec(statement).first() + + +def get_conversation_by_response_id( + session: Session, response_id: str, project_id: int +) -> Optional[OpenAIConversation]: + """Get a conversation by its OpenAI response ID and project ID.""" + statement = select(OpenAIConversation).where( + and_( + OpenAIConversation.response_id == response_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + ) + return session.exec(statement).first() + + +def get_conversations_by_project( + session: Session, + project_id: int, + skip: int = 0, + limit: int = 100, +) -> List[OpenAIConversation]: + """ + Return all conversations for a given project, with optional pagination. + """ + statement = ( + select(OpenAIConversation) + .where( + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + .order_by(OpenAIConversation.inserted_at.desc()) + .offset(skip) + .limit(limit) + ) + results = session.exec(statement).all() + return results + + +def get_conversations_by_assistant( + session: Session, + assistant_id: str, + project_id: int, + skip: int = 0, + limit: int = 100, +) -> List[OpenAIConversation]: + """ + Return all conversations for a given assistant and project, with optional pagination. + """ + statement = ( + select(OpenAIConversation) + .where( + OpenAIConversation.assistant_id == assistant_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + .order_by(OpenAIConversation.inserted_at.desc()) + .offset(skip) + .limit(limit) + ) + results = session.exec(statement).all() + return results + + +def get_conversation_thread( + session: Session, + response_id: str, + project_id: int, +) -> List[OpenAIConversation]: + """ + Get the full conversation thread starting from a given response ID. + This includes all ancestor and previous responses in the conversation chain. + """ + # First, find the root of the conversation thread + root_response_id = response_id + current_conversation = get_conversation_by_response_id( + session, response_id, project_id + ) + + if not current_conversation: + return [] + + # Find the root of the conversation thread + while current_conversation.ancestor_response_id: + root_conversation = get_conversation_by_response_id( + session, current_conversation.ancestor_response_id, project_id + ) + if not root_conversation: + break + root_response_id = current_conversation.ancestor_response_id + current_conversation = root_conversation + + # Now get all conversations in the thread + thread_conversations = [] + current_response_id = root_response_id + + while current_response_id: + conversation = get_conversation_by_response_id( + session, current_response_id, project_id + ) + if not conversation: + break + thread_conversations.append(conversation) + current_response_id = conversation.previous_response_id + + return thread_conversations + + +def create_conversation( + session: Session, + conversation: OpenAIConversationCreate, + project_id: int, + organization_id: int, +) -> OpenAIConversation: + """ + Create a new conversation in the database. + """ + db_conversation = OpenAIConversation( + **conversation.model_dump(), + project_id=project_id, + organization_id=organization_id, + ) + session.add(db_conversation) + session.commit() + session.refresh(db_conversation) + + logger.info( + f"Created conversation with response_id={db_conversation.response_id}, " + f"assistant_id={db_conversation.assistant_id}, project_id={project_id}" + ) + + return db_conversation + + +def update_conversation( + session: Session, + conversation_id: int, + project_id: int, + conversation_update: OpenAIConversationUpdate, +) -> Optional[OpenAIConversation]: + """ + Update an existing conversation. + """ + db_conversation = get_conversation_by_id(session, conversation_id, project_id) + if not db_conversation: + return None + + update_data = conversation_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_conversation, field, value) + + db_conversation.updated_at = now() + session.add(db_conversation) + session.commit() + session.refresh(db_conversation) + + logger.info( + f"Updated conversation with id={conversation_id}, " + f"response_id={db_conversation.response_id}, project_id={project_id}" + ) + + return db_conversation + + +def delete_conversation( + session: Session, + conversation_id: int, + project_id: int, +) -> Optional[OpenAIConversation]: + """ + Soft delete a conversation by marking it as deleted. + """ + db_conversation = get_conversation_by_id(session, conversation_id, project_id) + if not db_conversation: + return None + + db_conversation.is_deleted = True + db_conversation.deleted_at = now() + session.add(db_conversation) + session.commit() + session.refresh(db_conversation) + + logger.info( + f"Deleted conversation with id={conversation_id}, " + f"response_id={db_conversation.response_id}, project_id={project_id}" + ) + + return db_conversation + + +def upsert_conversation( + session: Session, + conversation: OpenAIConversationCreate, + project_id: int, + organization_id: int, +) -> OpenAIConversation: + """ + Create a new conversation or update existing one if response_id already exists. + """ + existing_conversation = get_conversation_by_response_id( + session, conversation.response_id, project_id + ) + + if existing_conversation: + # Update existing conversation + update_data = conversation.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_conversation, field, value) + + existing_conversation.updated_at = now() + session.add(existing_conversation) + session.commit() + session.refresh(existing_conversation) + + logger.info( + f"Updated existing conversation with response_id={conversation.response_id}, " + f"project_id={project_id}" + ) + + return existing_conversation + else: + # Create new conversation + return create_conversation(session, conversation, project_id, organization_id) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2c4c87e0..0c8d7bff 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -55,3 +55,10 @@ from .threads import OpenAI_Thread, OpenAIThreadBase, OpenAIThreadCreate from .assistants import Assistant, AssistantBase, AssistantCreate, AssistantUpdate + +from .openai_conversation import ( + OpenAIConversation, + OpenAIConversationBase, + OpenAIConversationCreate, + OpenAIConversationUpdate, +) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py new file mode 100644 index 00000000..5d82f11a --- /dev/null +++ b/backend/app/models/openai_conversation.py @@ -0,0 +1,78 @@ +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, Relationship, SQLModel + +from app.core.util import now + + +class OpenAIConversationBase(SQLModel): + response_id: str = Field(index=True, description="OpenAI response ID") + ancestor_response_id: Optional[str] = Field( + default=None, + index=True, + description="Ancestor response ID for conversation threading", + ) + previous_response_id: Optional[str] = Field( + default=None, index=True, description="Previous response ID in the conversation" + ) + user_question: str = Field(description="User's question/input") + response: Optional[str] = Field(default=None, description="AI response") + model: str = Field(description="Model used for the response") + assistant_id: str = Field(description="Assistant ID used for the response") + project_id: int = Field( + foreign_key="project.id", nullable=False, ondelete="CASCADE" + ) + organization_id: int = Field( + foreign_key="organization.id", nullable=False, ondelete="CASCADE" + ) + + +class OpenAIConversation(OpenAIConversationBase, table=True): + __tablename__ = "openai_conversation" + + id: int = Field(default=None, primary_key=True) + inserted_at: datetime = Field(default_factory=now, nullable=False) + updated_at: datetime = Field(default_factory=now, nullable=False) + is_deleted: bool = Field(default=False, nullable=False) + deleted_at: Optional[datetime] = Field(default=None, nullable=True) + + # Relationships + project: "Project" = Relationship(back_populates="openai_conversations") + organization: "Organization" = Relationship(back_populates="openai_conversations") + + +class OpenAIConversationCreate(SQLModel): + response_id: str = Field(description="OpenAI response ID") + ancestor_response_id: Optional[str] = Field( + default=None, description="Ancestor response ID for conversation threading" + ) + previous_response_id: Optional[str] = Field( + default=None, description="Previous response ID in the conversation" + ) + user_question: str = Field(description="User's question/input", min_length=1) + response: Optional[str] = Field(default=None, description="AI response") + model: str = Field(description="Model used for the response", min_length=1) + assistant_id: str = Field( + description="Assistant ID used for the response", min_length=1 + ) + + +class OpenAIConversationUpdate(SQLModel): + response_id: Optional[str] = Field(default=None, description="OpenAI response ID") + ancestor_response_id: Optional[str] = Field( + default=None, description="Ancestor response ID for conversation threading" + ) + previous_response_id: Optional[str] = Field( + default=None, description="Previous response ID in the conversation" + ) + user_question: Optional[str] = Field( + default=None, description="User's question/input", min_length=1 + ) + response: Optional[str] = Field(default=None, description="AI response") + model: Optional[str] = Field( + default=None, description="Model used for the response", min_length=1 + ) + assistant_id: Optional[str] = Field( + default=None, description="Assistant ID used for the response", min_length=1 + ) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py index 90eed18b..e854b11e 100644 --- a/backend/app/models/organization.py +++ b/backend/app/models/organization.py @@ -11,6 +11,7 @@ from .api_key import APIKey from .assistants import Assistant from .collection import Collection + from .openai_conversation import OpenAIConversation # Shared properties for an Organization @@ -52,6 +53,9 @@ class Organization(OrganizationBase, table=True): collections: list["Collection"] = Relationship( back_populates="organization", cascade_delete=True ) + openai_conversations: list["OpenAIConversation"] = Relationship( + back_populates="organization", cascade_delete=True + ) # Properties to return via API diff --git a/backend/app/models/project.py b/backend/app/models/project.py index de2ceb3c..442b740a 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -49,6 +49,9 @@ class Project(ProjectBase, table=True): collections: list["Collection"] = Relationship( back_populates="project", cascade_delete=True ) + openai_conversations: list["OpenAIConversation"] = Relationship( + back_populates="project", cascade_delete=True + ) # Properties to return via API diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py new file mode 100644 index 00000000..42f6ae4f --- /dev/null +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -0,0 +1,403 @@ +import pytest +from uuid import uuid4 +from sqlmodel import Session +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.tests.utils.conversation import get_conversation + + +@pytest.fixture +def conversation_create_payload(): + return { + "response_id": f"resp_{uuid4()}", + "ancestor_response_id": None, + "previous_response_id": None, + "user_question": "What is the capital of France?", + "response": "The capital of France is Paris.", + "model": "gpt-4o", + "assistant_id": f"asst_{uuid4()}", + } + + +@pytest.fixture +def conversation_update_payload(): + return { + "response": "The capital of France is Paris, which is a beautiful city.", + "model": "gpt-4o-mini", + } + + +def test_create_conversation_success( + client: TestClient, + conversation_create_payload: dict, + user_api_key_header: dict, +): + """Test successful conversation creation.""" + response = client.post( + "/api/v1/openai-conversation", + json=conversation_create_payload, + headers=user_api_key_header, + ) + + assert response.status_code == 201 + response_data = response.json() + assert response_data["success"] is True + assert ( + response_data["data"]["response_id"] + == conversation_create_payload["response_id"] + ) + assert ( + response_data["data"]["user_question"] + == conversation_create_payload["user_question"] + ) + assert response_data["data"]["response"] == conversation_create_payload["response"] + assert response_data["data"]["model"] == conversation_create_payload["model"] + assert ( + response_data["data"]["assistant_id"] + == conversation_create_payload["assistant_id"] + ) + + +def test_create_conversation_invalid_data( + client: TestClient, + user_api_key_header: dict, +): + """Test conversation creation with invalid data.""" + invalid_payload = { + "response_id": "", # Empty response_id + "user_question": "", # Empty user_question + "model": "", # Empty model + "assistant_id": "", # Empty assistant_id + } + + response = client.post( + "/api/v1/openai-conversation", + json=invalid_payload, + headers=user_api_key_header, + ) + + assert response.status_code == 422 + + +def test_upsert_conversation_success( + client: TestClient, + conversation_create_payload: dict, + user_api_key_header: dict, +): + """Test successful conversation upsert.""" + response = client.post( + "/api/v1/openai-conversation/upsert", + json=conversation_create_payload, + headers=user_api_key_header, + ) + + assert response.status_code == 201 + response_data = response.json() + assert response_data["success"] is True + assert ( + response_data["data"]["response_id"] + == conversation_create_payload["response_id"] + ) + + +def test_upsert_conversation_update_existing( + client: TestClient, + conversation_create_payload: dict, + user_api_key_header: dict, +): + """Test upsert conversation updates existing conversation.""" + # First create a conversation + response1 = client.post( + "/api/v1/openai-conversation/upsert", + json=conversation_create_payload, + headers=user_api_key_header, + ) + assert response1.status_code == 201 + + # Update the payload and upsert again + conversation_create_payload["response"] = "Updated response" + response2 = client.post( + "/api/v1/openai-conversation/upsert", + json=conversation_create_payload, + headers=user_api_key_header, + ) + + assert response2.status_code == 201 + response_data = response2.json() + assert response_data["success"] is True + assert response_data["data"]["response"] == "Updated response" + + +def test_update_conversation_success( + client: TestClient, + db: Session, + conversation_update_payload: dict, + user_api_key_header: dict, +): + """Test successful conversation update.""" + # Get the project ID from the user's API key + from app.tests.utils.utils import get_user_from_api_key + + api_key = get_user_from_api_key(db, user_api_key_header) + + # Create a conversation in the same project as the API key + conversation = get_conversation(db, project_id=api_key.project_id) + conversation_id = conversation.id + + response = client.patch( + f"/api/v1/openai-conversation/{conversation_id}", + json=conversation_update_payload, + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert response_data["data"]["response"] == conversation_update_payload["response"] + assert response_data["data"]["model"] == conversation_update_payload["model"] + + +def test_update_conversation_not_found( + client: TestClient, + conversation_update_payload: dict, + user_api_key_header: dict, +): + """Test conversation update with non-existent ID.""" + response = client.patch( + "/api/v1/openai-conversation/99999", + json=conversation_update_payload, + headers=user_api_key_header, + ) + + assert response.status_code == 404 + response_data = response.json() + assert "not found" in response_data["error"] + + +def test_get_conversation_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation retrieval by ID.""" + # Get the project ID from the user's API key + from app.tests.utils.utils import get_user_from_api_key + + api_key = get_user_from_api_key(db, user_api_key_header) + + # Create a conversation in the same project as the API key + conversation = get_conversation(db, project_id=api_key.project_id) + conversation_id = conversation.id + + response = client.get( + f"/api/v1/openai-conversation/{conversation_id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert response_data["data"]["id"] == conversation_id + assert response_data["data"]["response_id"] == conversation.response_id + + +def test_get_conversation_not_found( + client: TestClient, + user_api_key_header: dict, +): + """Test conversation retrieval with non-existent ID.""" + response = client.get( + "/api/v1/openai-conversation/99999", + headers=user_api_key_header, + ) + + assert response.status_code == 404 + response_data = response.json() + assert "not found" in response_data["error"] + + +def test_get_conversation_by_response_id_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation retrieval by response ID.""" + # Get the project ID from the user's API key + from app.tests.utils.utils import get_user_from_api_key + + api_key = get_user_from_api_key(db, user_api_key_header) + + # Create a conversation in the same project as the API key + conversation = get_conversation(db, project_id=api_key.project_id) + response_id = conversation.response_id + + response = client.get( + f"/api/v1/openai-conversation/response/{response_id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert response_data["data"]["response_id"] == response_id + + +def test_get_conversation_by_response_id_not_found( + client: TestClient, + user_api_key_header: dict, +): + """Test conversation retrieval with non-existent response ID.""" + response = client.get( + "/api/v1/openai-conversation/response/non_existent_response_id", + headers=user_api_key_header, + ) + + assert response.status_code == 404 + response_data = response.json() + assert "not found" in response_data["error"] + + +def test_get_conversation_thread_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation thread retrieval.""" + conversation = get_conversation(db) + response_id = conversation.response_id + + response = client.get( + f"/api/v1/openai-conversation/thread/{response_id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert isinstance(response_data["data"], list) + + +def test_list_conversations_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation listing.""" + # Get the project ID from the user's API key + from app.tests.utils.utils import get_user_from_api_key + + api_key = get_user_from_api_key(db, user_api_key_header) + + # Create a conversation in the same project as the API key + get_conversation(db, project_id=api_key.project_id) + + response = client.get( + "/api/v1/openai-conversation", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert isinstance(response_data["data"], list) + assert len(response_data["data"]) > 0 + + +def test_list_conversations_with_pagination( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test conversation listing with pagination.""" + # Create multiple conversations + for _ in range(3): + get_conversation(db) + + response = client.get( + "/api/v1/openai-conversation?skip=1&limit=2", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert isinstance(response_data["data"], list) + assert len(response_data["data"]) <= 2 + + +def test_list_conversations_invalid_pagination( + client: TestClient, + user_api_key_header: dict, +): + """Test conversation listing with invalid pagination parameters.""" + response = client.get( + "/api/v1/openai-conversation?skip=-1&limit=0", + headers=user_api_key_header, + ) + + assert response.status_code == 422 + + +def test_list_conversations_by_assistant_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation listing by assistant.""" + conversation = get_conversation(db) + assistant_id = conversation.assistant_id + + response = client.get( + f"/api/v1/openai-conversation/assistant/{assistant_id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert isinstance(response_data["data"], list) + # All returned conversations should have the same assistant_id + for conv in response_data["data"]: + assert conv["assistant_id"] == assistant_id + + +def test_delete_conversation_success( + client: TestClient, + db: Session, + user_api_key_header: dict, +): + """Test successful conversation deletion.""" + # Get the project ID from the user's API key + from app.tests.utils.utils import get_user_from_api_key + + api_key = get_user_from_api_key(db, user_api_key_header) + + # Create a conversation in the same project as the API key + conversation = get_conversation(db, project_id=api_key.project_id) + conversation_id = conversation.id + + response = client.delete( + f"/api/v1/openai-conversation/{conversation_id}", + headers=user_api_key_header, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert "deleted successfully" in response_data["data"]["message"] + + +def test_delete_conversation_not_found( + client: TestClient, + user_api_key_header: dict, +): + """Test conversation deletion with non-existent ID.""" + response = client.delete( + "/api/v1/openai-conversation/99999", + headers=user_api_key_header, + ) + + assert response.status_code == 404 + response_data = response.json() + assert "not found" in response_data["error"] diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py new file mode 100644 index 00000000..3e2c2554 --- /dev/null +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -0,0 +1,406 @@ +import pytest +from uuid import uuid4 +from sqlmodel import Session + +from app.crud.openai_conversation import ( + get_conversation_by_id, + get_conversation_by_response_id, + get_conversations_by_project, + get_conversations_by_assistant, + get_conversation_thread, + create_conversation, + update_conversation, + delete_conversation, + upsert_conversation, +) +from app.models import OpenAIConversationCreate, OpenAIConversationUpdate +from app.tests.utils.conversation import get_conversation +from app.tests.utils.utils import get_project, get_organization + + +@pytest.fixture +def conversation_create_data(): + return OpenAIConversationCreate( + response_id=f"resp_{uuid4()}", + ancestor_response_id=None, + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=f"asst_{uuid4()}", + ) + + +@pytest.fixture +def conversation_update_data(): + return OpenAIConversationUpdate( + response="The capital of France is Paris, which is a beautiful city.", + model="gpt-4o-mini", + ) + + +def test_create_conversation_success( + db: Session, conversation_create_data: OpenAIConversationCreate +): + """Test successful conversation creation.""" + project = get_project(db) + organization = get_organization(db) + + conversation = create_conversation( + session=db, + conversation=conversation_create_data, + project_id=project.id, + organization_id=organization.id, + ) + + assert conversation is not None + assert conversation.response_id == conversation_create_data.response_id + assert conversation.user_question == conversation_create_data.user_question + assert conversation.response == conversation_create_data.response + assert conversation.model == conversation_create_data.model + assert conversation.assistant_id == conversation_create_data.assistant_id + assert conversation.project_id == project.id + assert conversation.organization_id == organization.id + assert conversation.is_deleted is False + assert conversation.deleted_at is None + + +def test_get_conversation_by_id_success(db: Session): + """Test successful conversation retrieval by ID.""" + conversation = get_conversation(db) + project = get_project(db) + + retrieved_conversation = get_conversation_by_id( + session=db, + conversation_id=conversation.id, + project_id=project.id, + ) + + assert retrieved_conversation is not None + assert retrieved_conversation.id == conversation.id + assert retrieved_conversation.response_id == conversation.response_id + + +def test_get_conversation_by_id_not_found(db: Session): + """Test conversation retrieval by non-existent ID.""" + project = get_project(db) + + retrieved_conversation = get_conversation_by_id( + session=db, + conversation_id=99999, + project_id=project.id, + ) + + assert retrieved_conversation is None + + +def test_get_conversation_by_response_id_success(db: Session): + """Test successful conversation retrieval by response ID.""" + conversation = get_conversation(db) + project = get_project(db) + + retrieved_conversation = get_conversation_by_response_id( + session=db, + response_id=conversation.response_id, + project_id=project.id, + ) + + assert retrieved_conversation is not None + assert retrieved_conversation.response_id == conversation.response_id + assert retrieved_conversation.id == conversation.id + + +def test_get_conversation_by_response_id_not_found(db: Session): + """Test conversation retrieval by non-existent response ID.""" + project = get_project(db) + + retrieved_conversation = get_conversation_by_response_id( + session=db, + response_id="non_existent_response_id", + project_id=project.id, + ) + + assert retrieved_conversation is None + + +def test_get_conversations_by_project_success(db: Session): + """Test successful conversation listing by project.""" + project = get_project(db) + organization = get_organization(db) + + # Create multiple conversations directly + from app.models import OpenAIConversationCreate + from app.crud.openai_conversation import create_conversation + from uuid import uuid4 + + for i in range(3): + conversation_data = OpenAIConversationCreate( + response_id=f"resp_{uuid4()}", + ancestor_response_id=None, + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=f"asst_{uuid4()}", + ) + create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) + + conversations = get_conversations_by_project( + session=db, + project_id=project.id, + skip=0, + limit=10, + ) + + assert len(conversations) >= 3 + for conversation in conversations: + assert conversation.project_id == project.id + assert conversation.is_deleted is False + + +def test_get_conversations_by_project_with_pagination(db: Session): + """Test conversation listing by project with pagination.""" + # Create multiple conversations + for _ in range(5): + get_conversation(db) + + project = get_project(db) + + conversations = get_conversations_by_project( + session=db, + project_id=project.id, + skip=2, + limit=2, + ) + + assert len(conversations) <= 2 + + +def test_get_conversations_by_assistant_success(db: Session): + """Test successful conversation listing by assistant.""" + conversation = get_conversation(db) + project = get_project(db) + + conversations = get_conversations_by_assistant( + session=db, + assistant_id=conversation.assistant_id, + project_id=project.id, + skip=0, + limit=10, + ) + + assert len(conversations) >= 1 + for conv in conversations: + assert conv.assistant_id == conversation.assistant_id + assert conv.project_id == project.id + assert conv.is_deleted is False + + +def test_get_conversations_by_assistant_not_found(db: Session): + """Test conversation listing by non-existent assistant.""" + project = get_project(db) + + conversations = get_conversations_by_assistant( + session=db, + assistant_id="non_existent_assistant_id", + project_id=project.id, + skip=0, + limit=10, + ) + + assert len(conversations) == 0 + + +def test_get_conversation_thread_success(db: Session): + """Test successful conversation thread retrieval.""" + conversation = get_conversation(db) + project = get_project(db) + + thread_conversations = get_conversation_thread( + session=db, + response_id=conversation.response_id, + project_id=project.id, + ) + + assert isinstance(thread_conversations, list) + assert len(thread_conversations) >= 1 + assert thread_conversations[0].response_id == conversation.response_id + + +def test_get_conversation_thread_not_found(db: Session): + """Test conversation thread retrieval with non-existent response ID.""" + project = get_project(db) + + thread_conversations = get_conversation_thread( + session=db, + response_id="non_existent_response_id", + project_id=project.id, + ) + + assert isinstance(thread_conversations, list) + assert len(thread_conversations) == 0 + + +def test_update_conversation_success( + db: Session, conversation_update_data: OpenAIConversationUpdate +): + """Test successful conversation update.""" + conversation = get_conversation(db) + project = get_project(db) + + updated_conversation = update_conversation( + session=db, + conversation_id=conversation.id, + project_id=project.id, + conversation_update=conversation_update_data, + ) + + assert updated_conversation is not None + assert updated_conversation.response == conversation_update_data.response + assert updated_conversation.model == conversation_update_data.model + assert updated_conversation.id == conversation.id + + +def test_update_conversation_not_found( + db: Session, conversation_update_data: OpenAIConversationUpdate +): + """Test conversation update with non-existent ID.""" + project = get_project(db) + + updated_conversation = update_conversation( + session=db, + conversation_id=99999, + project_id=project.id, + conversation_update=conversation_update_data, + ) + + assert updated_conversation is None + + +def test_delete_conversation_success(db: Session): + """Test successful conversation deletion.""" + conversation = get_conversation(db) + project = get_project(db) + + deleted_conversation = delete_conversation( + session=db, + conversation_id=conversation.id, + project_id=project.id, + ) + + assert deleted_conversation is not None + assert deleted_conversation.is_deleted is True + assert deleted_conversation.deleted_at is not None + assert deleted_conversation.id == conversation.id + + +def test_delete_conversation_not_found(db: Session): + """Test conversation deletion with non-existent ID.""" + project = get_project(db) + + deleted_conversation = delete_conversation( + session=db, + conversation_id=99999, + project_id=project.id, + ) + + assert deleted_conversation is None + + +def test_upsert_conversation_create_new( + db: Session, conversation_create_data: OpenAIConversationCreate +): + """Test upsert conversation creates new conversation.""" + project = get_project(db) + organization = get_organization(db) + + conversation = upsert_conversation( + session=db, + conversation=conversation_create_data, + project_id=project.id, + organization_id=organization.id, + ) + + assert conversation is not None + assert conversation.response_id == conversation_create_data.response_id + assert conversation.user_question == conversation_create_data.user_question + + +def test_upsert_conversation_update_existing( + db: Session, conversation_create_data: OpenAIConversationCreate +): + """Test upsert conversation updates existing conversation.""" + project = get_project(db) + organization = get_organization(db) + + # First create a conversation + conversation1 = upsert_conversation( + session=db, + conversation=conversation_create_data, + project_id=project.id, + organization_id=organization.id, + ) + + # Update the data and upsert again + conversation_create_data.response = "Updated response" + conversation_create_data.model = "gpt-4o-mini" + + conversation2 = upsert_conversation( + session=db, + conversation=conversation_create_data, + project_id=project.id, + organization_id=organization.id, + ) + + assert conversation2 is not None + assert conversation2.id == conversation1.id # Same conversation + assert conversation2.response == "Updated response" + assert conversation2.model == "gpt-4o-mini" + assert conversation2.response_id == conversation1.response_id + + +def test_conversation_soft_delete_behavior(db: Session): + """Test that soft deleted conversations are not returned by queries.""" + conversation = get_conversation(db) + project = get_project(db) + + # Delete the conversation + delete_conversation( + session=db, + conversation_id=conversation.id, + project_id=project.id, + ) + + # Try to retrieve it by ID + retrieved_conversation = get_conversation_by_id( + session=db, + conversation_id=conversation.id, + project_id=project.id, + ) + + assert retrieved_conversation is None + + # Try to retrieve it by response ID + retrieved_conversation = get_conversation_by_response_id( + session=db, + response_id=conversation.response_id, + project_id=project.id, + ) + + assert retrieved_conversation is None + + # Check that it's not in the project list + conversations = get_conversations_by_project( + session=db, + project_id=project.id, + skip=0, + limit=10, + ) + + conversation_ids = [conv.id for conv in conversations] + assert conversation.id not in conversation_ids diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py new file mode 100644 index 00000000..e8363ccd --- /dev/null +++ b/backend/app/tests/utils/conversation.py @@ -0,0 +1,80 @@ +from uuid import uuid4 +from sqlmodel import Session, select + +from app.models import OpenAIConversation, OpenAIConversationCreate +from app.crud.openai_conversation import create_conversation + + +def get_conversation( + session: Session, response_id: str | None = None, project_id: int | None = None +) -> OpenAIConversation: + """ + Retrieve an active conversation from the database. + + If a response_id is provided, fetch the active conversation with that response_id. + If a project_id is provided, fetch a conversation from that specific project. + If no response_id or project_id is provided, fetch any random conversation. + """ + if response_id: + statement = ( + select(OpenAIConversation) + .where( + OpenAIConversation.response_id == response_id, + OpenAIConversation.is_deleted == False, + ) + .limit(1) + ) + elif project_id: + statement = ( + select(OpenAIConversation) + .where( + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + .limit(1) + ) + else: + statement = ( + select(OpenAIConversation) + .where(OpenAIConversation.is_deleted == False) + .limit(1) + ) + + conversation = session.exec(statement).first() + + if not conversation: + # Create a new conversation if none exists + from app.tests.utils.utils import get_project, get_organization + + if project_id: + # Get the specific project + from app.models import Project + + project = session.exec( + select(Project).where(Project.id == project_id) + ).first() + if not project: + raise ValueError(f"Project with ID {project_id} not found") + else: + project = get_project(session) + + organization = get_organization(session) + + conversation_data = OpenAIConversationCreate( + response_id=f"resp_{uuid4()}", + ancestor_response_id=None, + previous_response_id=None, + user_question="Test question", + response="Test response", + model="gpt-4o", + assistant_id=f"asst_{uuid4()}", + ) + + conversation = create_conversation( + session=session, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) + + return conversation diff --git a/backend/app/tests/utils/utils.py b/backend/app/tests/utils/utils.py index ae4a7bee..9fb5311f 100644 --- a/backend/app/tests/utils/utils.py +++ b/backend/app/tests/utils/utils.py @@ -12,7 +12,7 @@ from app.core.config import settings from app.crud.user import get_user_by_email from app.crud.api_key import get_api_key_by_value, get_api_key_by_user_id -from app.models import APIKeyPublic, Project, Assistant +from app.models import APIKeyPublic, Project, Assistant, Organization T = TypeVar("T") @@ -113,6 +113,30 @@ def get_assistant(session: Session, name: str | None = None) -> Assistant: return assistant +def get_organization(session: Session, name: str | None = None) -> Organization: + """ + Retrieve an active organization from the database. + + If an organization name is provided, fetch the active organization with that name. + If no name is provided, fetch any random organization. + """ + if name: + statement = ( + select(Organization) + .where(Organization.name == name, Organization.is_active) + .limit(1) + ) + else: + statement = select(Organization).where(Organization.is_active).limit(1) + + organization = session.exec(statement).first() + + if not organization: + raise ValueError("No active organizations found") + + return organization + + class SequentialUuidGenerator: def __init__(self, start=0): self.start = start From a2b001cbb87f2282abf4c3a69dc4d3172ae0186b Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Sat, 26 Jul 2025 12:35:57 +0530 Subject: [PATCH 03/22] added ancestor id fetching --- backend/app/api/routes/openai_conversation.py | 102 ++----- backend/app/crud/__init__.py | 5 +- backend/app/crud/openai_conversation.py | 185 +++---------- backend/app/models/__init__.py | 1 - backend/app/models/openai_conversation.py | 20 -- .../api/routes/test_openai_conversation.py | 183 ++++--------- .../tests/crud/test_openai_conversation.py | 250 +++++------------- 7 files changed, 160 insertions(+), 586 deletions(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index ee553d79..10491999 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -7,18 +7,14 @@ from app.crud import ( get_conversation_by_id, get_conversation_by_response_id, + get_conversation_by_ancestor_id, get_conversations_by_project, - get_conversations_by_assistant, - get_conversation_thread, create_conversation, - update_conversation, delete_conversation, - upsert_conversation, ) from app.models import ( UserProjectOrg, OpenAIConversationCreate, - OpenAIConversationUpdate, OpenAIConversation, ) from app.utils import APIResponse @@ -44,49 +40,6 @@ def create_conversation_route( return APIResponse.success_response(conversation) -@router.post("/upsert", response_model=APIResponse[OpenAIConversation], status_code=201) -def upsert_conversation_route( - conversation_in: OpenAIConversationCreate, - session: Session = Depends(get_db), - current_user: UserProjectOrg = Depends(get_current_user_org_project), -): - """ - Create a new conversation or update existing one if response_id already exists. - """ - conversation = upsert_conversation( - session=session, - conversation=conversation_in, - project_id=current_user.project_id, - organization_id=current_user.organization_id, - ) - return APIResponse.success_response(conversation) - - -@router.patch("/{conversation_id}", response_model=APIResponse[OpenAIConversation]) -def update_conversation_route( - conversation_id: Annotated[int, Path(description="Conversation ID to update")], - conversation_update: OpenAIConversationUpdate, - session: Session = Depends(get_db), - current_user: UserProjectOrg = Depends(get_current_user_org_project), -): - """ - Update an existing conversation with provided fields. - """ - updated_conversation = update_conversation( - session=session, - conversation_id=conversation_id, - project_id=current_user.project_id, - conversation_update=conversation_update, - ) - - if not updated_conversation: - raise HTTPException( - status_code=404, detail=f"Conversation with ID {conversation_id} not found." - ) - - return APIResponse.success_response(updated_conversation) - - @router.get( "/{conversation_id}", response_model=APIResponse[OpenAIConversation], @@ -135,27 +88,29 @@ def get_conversation_by_response_id_route( @router.get( - "/thread/{response_id}", - response_model=APIResponse[list[OpenAIConversation]], - summary="Get the full conversation thread starting from a response ID", + "/ancestor/{ancestor_response_id}", + response_model=APIResponse[OpenAIConversation], + summary="Get a conversation by its ancestor response ID", ) -def get_conversation_thread_route( - response_id: str = Path( - ..., description="The response ID to start the thread from" +def get_conversation_by_ancestor_id_route( + ancestor_response_id: str = Path( + ..., description="The ancestor response ID to fetch" ), session: Session = Depends(get_db), current_user: UserProjectOrg = Depends(get_current_user_org_project), ): """ - Get the full conversation thread starting from a given response ID. - This includes all ancestor and previous responses in the conversation chain. + Fetch a conversation by its ancestor response ID. """ - thread_conversations = get_conversation_thread( - session=session, - response_id=response_id, - project_id=current_user.project_id, + conversation = get_conversation_by_ancestor_id( + session, ancestor_response_id, current_user.project_id ) - return APIResponse.success_response(thread_conversations) + if not conversation: + raise HTTPException( + status_code=404, + detail=f"Conversation with ancestor response ID {ancestor_response_id} not found.", + ) + return APIResponse.success_response(conversation) @router.get( @@ -178,31 +133,6 @@ def list_conversations_route( return APIResponse.success_response(conversations) -@router.get( - "/assistant/{assistant_id}", - response_model=APIResponse[list[OpenAIConversation]], - summary="List all conversations for a specific assistant", -) -def list_conversations_by_assistant_route( - assistant_id: str = Path(..., description="The assistant ID to filter by"), - session: Session = Depends(get_db), - current_user: UserProjectOrg = Depends(get_current_user_org_project), - skip: int = Query(0, ge=0, description="How many items to skip"), - limit: int = Query(100, ge=1, le=100, description="Maximum items to return"), -): - """ - List all conversations for a specific assistant in the current project. - """ - conversations = get_conversations_by_assistant( - session=session, - assistant_id=assistant_id, - project_id=current_user.project_id, - skip=skip, - limit=limit, - ) - return APIResponse.success_response(conversations) - - @router.delete("/{conversation_id}", response_model=APIResponse) def delete_conversation_route( conversation_id: Annotated[int, Path(description="Conversation ID to delete")], diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index a2cd5f3c..fe5855e9 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -58,11 +58,8 @@ from .openai_conversation import ( get_conversation_by_id, get_conversation_by_response_id, + get_conversation_by_ancestor_id, get_conversations_by_project, - get_conversations_by_assistant, - get_conversation_thread, create_conversation, - update_conversation, delete_conversation, - upsert_conversation, ) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index e59ef057..ad072132 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -1,13 +1,7 @@ import logging -from typing import Optional, List - -from sqlmodel import Session, and_, select - -from app.models import ( - OpenAIConversation, - OpenAIConversationCreate, - OpenAIConversationUpdate, -) +from typing import List, Optional +from sqlmodel import Session, select +from app.models import OpenAIConversation, OpenAIConversationCreate from app.core.util import now logger = logging.getLogger(__name__) @@ -16,68 +10,60 @@ def get_conversation_by_id( session: Session, conversation_id: int, project_id: int ) -> Optional[OpenAIConversation]: - """Get a conversation by its ID and project ID.""" + """ + Return a conversation by its ID and project. + """ statement = select(OpenAIConversation).where( - and_( - OpenAIConversation.id == conversation_id, - OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, - ) + OpenAIConversation.id == conversation_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, ) - return session.exec(statement).first() + result = session.exec(statement).first() + return result def get_conversation_by_response_id( session: Session, response_id: str, project_id: int ) -> Optional[OpenAIConversation]: - """Get a conversation by its OpenAI response ID and project ID.""" + """ + Return a conversation by its OpenAI response ID and project. + """ statement = select(OpenAIConversation).where( - and_( - OpenAIConversation.response_id == response_id, - OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, - ) + OpenAIConversation.response_id == response_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, ) - return session.exec(statement).first() + result = session.exec(statement).first() + return result -def get_conversations_by_project( - session: Session, - project_id: int, - skip: int = 0, - limit: int = 100, -) -> List[OpenAIConversation]: +def get_conversation_by_ancestor_id( + session: Session, ancestor_response_id: str, project_id: int +) -> Optional[OpenAIConversation]: """ - Return all conversations for a given project, with optional pagination. + Return a conversation by its ancestor response ID and project. """ - statement = ( - select(OpenAIConversation) - .where( - OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, - ) - .order_by(OpenAIConversation.inserted_at.desc()) - .offset(skip) - .limit(limit) + statement = select(OpenAIConversation).where( + OpenAIConversation.ancestor_response_id == ancestor_response_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, ) - results = session.exec(statement).all() - return results + result = session.exec(statement).first() + return result -def get_conversations_by_assistant( +def get_conversations_by_project( session: Session, - assistant_id: str, project_id: int, skip: int = 0, limit: int = 100, ) -> List[OpenAIConversation]: """ - Return all conversations for a given assistant and project, with optional pagination. + Return all conversations for a given project, with optional pagination. """ statement = ( select(OpenAIConversation) .where( - OpenAIConversation.assistant_id == assistant_id, OpenAIConversation.project_id == project_id, OpenAIConversation.is_deleted == False, ) @@ -89,50 +75,6 @@ def get_conversations_by_assistant( return results -def get_conversation_thread( - session: Session, - response_id: str, - project_id: int, -) -> List[OpenAIConversation]: - """ - Get the full conversation thread starting from a given response ID. - This includes all ancestor and previous responses in the conversation chain. - """ - # First, find the root of the conversation thread - root_response_id = response_id - current_conversation = get_conversation_by_response_id( - session, response_id, project_id - ) - - if not current_conversation: - return [] - - # Find the root of the conversation thread - while current_conversation.ancestor_response_id: - root_conversation = get_conversation_by_response_id( - session, current_conversation.ancestor_response_id, project_id - ) - if not root_conversation: - break - root_response_id = current_conversation.ancestor_response_id - current_conversation = root_conversation - - # Now get all conversations in the thread - thread_conversations = [] - current_response_id = root_response_id - - while current_response_id: - conversation = get_conversation_by_response_id( - session, current_response_id, project_id - ) - if not conversation: - break - thread_conversations.append(conversation) - current_response_id = conversation.previous_response_id - - return thread_conversations - - def create_conversation( session: Session, conversation: OpenAIConversationCreate, @@ -159,36 +101,6 @@ def create_conversation( return db_conversation -def update_conversation( - session: Session, - conversation_id: int, - project_id: int, - conversation_update: OpenAIConversationUpdate, -) -> Optional[OpenAIConversation]: - """ - Update an existing conversation. - """ - db_conversation = get_conversation_by_id(session, conversation_id, project_id) - if not db_conversation: - return None - - update_data = conversation_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(db_conversation, field, value) - - db_conversation.updated_at = now() - session.add(db_conversation) - session.commit() - session.refresh(db_conversation) - - logger.info( - f"Updated conversation with id={conversation_id}, " - f"response_id={db_conversation.response_id}, project_id={project_id}" - ) - - return db_conversation - - def delete_conversation( session: Session, conversation_id: int, @@ -213,38 +125,3 @@ def delete_conversation( ) return db_conversation - - -def upsert_conversation( - session: Session, - conversation: OpenAIConversationCreate, - project_id: int, - organization_id: int, -) -> OpenAIConversation: - """ - Create a new conversation or update existing one if response_id already exists. - """ - existing_conversation = get_conversation_by_response_id( - session, conversation.response_id, project_id - ) - - if existing_conversation: - # Update existing conversation - update_data = conversation.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(existing_conversation, field, value) - - existing_conversation.updated_at = now() - session.add(existing_conversation) - session.commit() - session.refresh(existing_conversation) - - logger.info( - f"Updated existing conversation with response_id={conversation.response_id}, " - f"project_id={project_id}" - ) - - return existing_conversation - else: - # Create new conversation - return create_conversation(session, conversation, project_id, organization_id) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0c8d7bff..d1222474 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -60,5 +60,4 @@ OpenAIConversation, OpenAIConversationBase, OpenAIConversationCreate, - OpenAIConversationUpdate, ) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index 5d82f11a..b9e90a41 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -56,23 +56,3 @@ class OpenAIConversationCreate(SQLModel): assistant_id: str = Field( description="Assistant ID used for the response", min_length=1 ) - - -class OpenAIConversationUpdate(SQLModel): - response_id: Optional[str] = Field(default=None, description="OpenAI response ID") - ancestor_response_id: Optional[str] = Field( - default=None, description="Ancestor response ID for conversation threading" - ) - previous_response_id: Optional[str] = Field( - default=None, description="Previous response ID in the conversation" - ) - user_question: Optional[str] = Field( - default=None, description="User's question/input", min_length=1 - ) - response: Optional[str] = Field(default=None, description="AI response") - model: Optional[str] = Field( - default=None, description="Model used for the response", min_length=1 - ) - assistant_id: Optional[str] = Field( - default=None, description="Assistant ID used for the response", min_length=1 - ) diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 42f6ae4f..fcd8d587 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -20,14 +20,6 @@ def conversation_create_payload(): } -@pytest.fixture -def conversation_update_payload(): - return { - "response": "The capital of France is Paris, which is a beautiful city.", - "model": "gpt-4o-mini", - } - - def test_create_conversation_success( client: TestClient, conversation_create_payload: dict, @@ -80,62 +72,12 @@ def test_create_conversation_invalid_data( assert response.status_code == 422 -def test_upsert_conversation_success( - client: TestClient, - conversation_create_payload: dict, - user_api_key_header: dict, -): - """Test successful conversation upsert.""" - response = client.post( - "/api/v1/openai-conversation/upsert", - json=conversation_create_payload, - headers=user_api_key_header, - ) - - assert response.status_code == 201 - response_data = response.json() - assert response_data["success"] is True - assert ( - response_data["data"]["response_id"] - == conversation_create_payload["response_id"] - ) - - -def test_upsert_conversation_update_existing( - client: TestClient, - conversation_create_payload: dict, - user_api_key_header: dict, -): - """Test upsert conversation updates existing conversation.""" - # First create a conversation - response1 = client.post( - "/api/v1/openai-conversation/upsert", - json=conversation_create_payload, - headers=user_api_key_header, - ) - assert response1.status_code == 201 - - # Update the payload and upsert again - conversation_create_payload["response"] = "Updated response" - response2 = client.post( - "/api/v1/openai-conversation/upsert", - json=conversation_create_payload, - headers=user_api_key_header, - ) - - assert response2.status_code == 201 - response_data = response2.json() - assert response_data["success"] is True - assert response_data["data"]["response"] == "Updated response" - - -def test_update_conversation_success( +def test_get_conversation_success( client: TestClient, db: Session, - conversation_update_payload: dict, user_api_key_header: dict, ): - """Test successful conversation update.""" + """Test successful conversation retrieval.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key @@ -145,28 +87,25 @@ def test_update_conversation_success( conversation = get_conversation(db, project_id=api_key.project_id) conversation_id = conversation.id - response = client.patch( + response = client.get( f"/api/v1/openai-conversation/{conversation_id}", - json=conversation_update_payload, headers=user_api_key_header, ) assert response.status_code == 200 response_data = response.json() assert response_data["success"] is True - assert response_data["data"]["response"] == conversation_update_payload["response"] - assert response_data["data"]["model"] == conversation_update_payload["model"] + assert response_data["data"]["id"] == conversation_id + assert response_data["data"]["response_id"] == conversation.response_id -def test_update_conversation_not_found( +def test_get_conversation_not_found( client: TestClient, - conversation_update_payload: dict, user_api_key_header: dict, ): - """Test conversation update with non-existent ID.""" - response = client.patch( + """Test conversation retrieval with non-existent ID.""" + response = client.get( "/api/v1/openai-conversation/99999", - json=conversation_update_payload, headers=user_api_key_header, ) @@ -175,12 +114,12 @@ def test_update_conversation_not_found( assert "not found" in response_data["error"] -def test_get_conversation_success( +def test_get_conversation_by_response_id_success( client: TestClient, db: Session, user_api_key_header: dict, ): - """Test successful conversation retrieval by ID.""" + """Test successful conversation retrieval by response ID.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key @@ -188,27 +127,27 @@ def test_get_conversation_success( # Create a conversation in the same project as the API key conversation = get_conversation(db, project_id=api_key.project_id) - conversation_id = conversation.id + response_id = conversation.response_id response = client.get( - f"/api/v1/openai-conversation/{conversation_id}", + f"/api/v1/openai-conversation/response/{response_id}", headers=user_api_key_header, ) assert response.status_code == 200 response_data = response.json() assert response_data["success"] is True - assert response_data["data"]["id"] == conversation_id - assert response_data["data"]["response_id"] == conversation.response_id + assert response_data["data"]["response_id"] == response_id + assert response_data["data"]["id"] == conversation.id -def test_get_conversation_not_found( +def test_get_conversation_by_response_id_not_found( client: TestClient, user_api_key_header: dict, ): - """Test conversation retrieval with non-existent ID.""" + """Test conversation retrieval with non-existent response ID.""" response = client.get( - "/api/v1/openai-conversation/99999", + "/api/v1/openai-conversation/response/nonexistent_response_id", headers=user_api_key_header, ) @@ -217,39 +156,57 @@ def test_get_conversation_not_found( assert "not found" in response_data["error"] -def test_get_conversation_by_response_id_success( +def test_get_conversation_by_ancestor_id_success( client: TestClient, db: Session, user_api_key_header: dict, ): - """Test successful conversation retrieval by response ID.""" + """Test successful conversation retrieval by ancestor ID.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key + from app.crud.openai_conversation import create_conversation + from app.models import OpenAIConversationCreate api_key = get_user_from_api_key(db, user_api_key_header) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=api_key.project_id) - response_id = conversation.response_id + # Create a conversation with an ancestor in the same project as the API key + ancestor_response_id = f"resp_{uuid4()}" + conversation_data = OpenAIConversationCreate( + response_id=f"resp_{uuid4()}", + ancestor_response_id=ancestor_response_id, + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=f"asst_{uuid4()}", + ) + + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=api_key.project_id, + organization_id=api_key.organization_id, + ) response = client.get( - f"/api/v1/openai-conversation/response/{response_id}", + f"/api/v1/openai-conversation/ancestor/{ancestor_response_id}", headers=user_api_key_header, ) assert response.status_code == 200 response_data = response.json() assert response_data["success"] is True - assert response_data["data"]["response_id"] == response_id + assert response_data["data"]["ancestor_response_id"] == ancestor_response_id + assert response_data["data"]["id"] == conversation.id -def test_get_conversation_by_response_id_not_found( +def test_get_conversation_by_ancestor_id_not_found( client: TestClient, user_api_key_header: dict, ): - """Test conversation retrieval with non-existent response ID.""" + """Test conversation retrieval with non-existent ancestor ID.""" response = client.get( - "/api/v1/openai-conversation/response/non_existent_response_id", + "/api/v1/openai-conversation/ancestor/nonexistent_ancestor_id", headers=user_api_key_header, ) @@ -258,26 +215,6 @@ def test_get_conversation_by_response_id_not_found( assert "not found" in response_data["error"] -def test_get_conversation_thread_success( - client: TestClient, - db: Session, - user_api_key_header: dict, -): - """Test successful conversation thread retrieval.""" - conversation = get_conversation(db) - response_id = conversation.response_id - - response = client.get( - f"/api/v1/openai-conversation/thread/{response_id}", - headers=user_api_key_header, - ) - - assert response.status_code == 200 - response_data = response.json() - assert response_data["success"] is True - assert isinstance(response_data["data"], list) - - def test_list_conversations_success( client: TestClient, db: Session, @@ -339,29 +276,6 @@ def test_list_conversations_invalid_pagination( assert response.status_code == 422 -def test_list_conversations_by_assistant_success( - client: TestClient, - db: Session, - user_api_key_header: dict, -): - """Test successful conversation listing by assistant.""" - conversation = get_conversation(db) - assistant_id = conversation.assistant_id - - response = client.get( - f"/api/v1/openai-conversation/assistant/{assistant_id}", - headers=user_api_key_header, - ) - - assert response.status_code == 200 - response_data = response.json() - assert response_data["success"] is True - assert isinstance(response_data["data"], list) - # All returned conversations should have the same assistant_id - for conv in response_data["data"]: - assert conv["assistant_id"] == assistant_id - - def test_delete_conversation_success( client: TestClient, db: Session, @@ -387,6 +301,13 @@ def test_delete_conversation_success( assert response_data["success"] is True assert "deleted successfully" in response_data["data"]["message"] + # Verify the conversation is marked as deleted + response = client.get( + f"/api/v1/openai-conversation/{conversation_id}", + headers=user_api_key_header, + ) + assert response.status_code == 404 + def test_delete_conversation_not_found( client: TestClient, diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 3e2c2554..1d4a3245 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -5,15 +5,12 @@ from app.crud.openai_conversation import ( get_conversation_by_id, get_conversation_by_response_id, + get_conversation_by_ancestor_id, get_conversations_by_project, - get_conversations_by_assistant, - get_conversation_thread, create_conversation, - update_conversation, delete_conversation, - upsert_conversation, ) -from app.models import OpenAIConversationCreate, OpenAIConversationUpdate +from app.models import OpenAIConversationCreate from app.tests.utils.conversation import get_conversation from app.tests.utils.utils import get_project, get_organization @@ -31,14 +28,6 @@ def conversation_create_data(): ) -@pytest.fixture -def conversation_update_data(): - return OpenAIConversationUpdate( - response="The capital of France is Paris, which is a beautiful city.", - model="gpt-4o-mini", - ) - - def test_create_conversation_success( db: Session, conversation_create_data: OpenAIConversationCreate ): @@ -106,8 +95,8 @@ def test_get_conversation_by_response_id_success(db: Session): ) assert retrieved_conversation is not None - assert retrieved_conversation.response_id == conversation.response_id assert retrieved_conversation.id == conversation.id + assert retrieved_conversation.response_id == conversation.response_id def test_get_conversation_by_response_id_not_found(db: Session): @@ -116,7 +105,55 @@ def test_get_conversation_by_response_id_not_found(db: Session): retrieved_conversation = get_conversation_by_response_id( session=db, - response_id="non_existent_response_id", + response_id="nonexistent_response_id", + project_id=project.id, + ) + + assert retrieved_conversation is None + + +def test_get_conversation_by_ancestor_id_success(db: Session): + """Test successful conversation retrieval by ancestor ID.""" + project = get_project(db) + organization = get_organization(db) + + # Create a conversation with an ancestor + ancestor_response_id = f"resp_{uuid4()}" + conversation_data = OpenAIConversationCreate( + response_id=f"resp_{uuid4()}", + ancestor_response_id=ancestor_response_id, + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=f"asst_{uuid4()}", + ) + + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) + + retrieved_conversation = get_conversation_by_ancestor_id( + session=db, + ancestor_response_id=ancestor_response_id, + project_id=project.id, + ) + + assert retrieved_conversation is not None + assert retrieved_conversation.id == conversation.id + assert retrieved_conversation.ancestor_response_id == ancestor_response_id + + +def test_get_conversation_by_ancestor_id_not_found(db: Session): + """Test conversation retrieval by non-existent ancestor ID.""" + project = get_project(db) + + retrieved_conversation = get_conversation_by_ancestor_id( + session=db, + ancestor_response_id="nonexistent_ancestor_id", project_id=project.id, ) @@ -129,10 +166,6 @@ def test_get_conversations_by_project_success(db: Session): organization = get_organization(db) # Create multiple conversations directly - from app.models import OpenAIConversationCreate - from app.crud.openai_conversation import create_conversation - from uuid import uuid4 - for i in range(3): conversation_data = OpenAIConversationCreate( response_id=f"resp_{uuid4()}", @@ -153,8 +186,6 @@ def test_get_conversations_by_project_success(db: Session): conversations = get_conversations_by_project( session=db, project_id=project.id, - skip=0, - limit=10, ) assert len(conversations) >= 3 @@ -165,123 +196,22 @@ def test_get_conversations_by_project_success(db: Session): def test_get_conversations_by_project_with_pagination(db: Session): """Test conversation listing by project with pagination.""" + project = get_project(db) + # Create multiple conversations for _ in range(5): - get_conversation(db) - - project = get_project(db) + get_conversation(db, project_id=project.id) conversations = get_conversations_by_project( session=db, project_id=project.id, - skip=2, + skip=1, limit=2, ) assert len(conversations) <= 2 -def test_get_conversations_by_assistant_success(db: Session): - """Test successful conversation listing by assistant.""" - conversation = get_conversation(db) - project = get_project(db) - - conversations = get_conversations_by_assistant( - session=db, - assistant_id=conversation.assistant_id, - project_id=project.id, - skip=0, - limit=10, - ) - - assert len(conversations) >= 1 - for conv in conversations: - assert conv.assistant_id == conversation.assistant_id - assert conv.project_id == project.id - assert conv.is_deleted is False - - -def test_get_conversations_by_assistant_not_found(db: Session): - """Test conversation listing by non-existent assistant.""" - project = get_project(db) - - conversations = get_conversations_by_assistant( - session=db, - assistant_id="non_existent_assistant_id", - project_id=project.id, - skip=0, - limit=10, - ) - - assert len(conversations) == 0 - - -def test_get_conversation_thread_success(db: Session): - """Test successful conversation thread retrieval.""" - conversation = get_conversation(db) - project = get_project(db) - - thread_conversations = get_conversation_thread( - session=db, - response_id=conversation.response_id, - project_id=project.id, - ) - - assert isinstance(thread_conversations, list) - assert len(thread_conversations) >= 1 - assert thread_conversations[0].response_id == conversation.response_id - - -def test_get_conversation_thread_not_found(db: Session): - """Test conversation thread retrieval with non-existent response ID.""" - project = get_project(db) - - thread_conversations = get_conversation_thread( - session=db, - response_id="non_existent_response_id", - project_id=project.id, - ) - - assert isinstance(thread_conversations, list) - assert len(thread_conversations) == 0 - - -def test_update_conversation_success( - db: Session, conversation_update_data: OpenAIConversationUpdate -): - """Test successful conversation update.""" - conversation = get_conversation(db) - project = get_project(db) - - updated_conversation = update_conversation( - session=db, - conversation_id=conversation.id, - project_id=project.id, - conversation_update=conversation_update_data, - ) - - assert updated_conversation is not None - assert updated_conversation.response == conversation_update_data.response - assert updated_conversation.model == conversation_update_data.model - assert updated_conversation.id == conversation.id - - -def test_update_conversation_not_found( - db: Session, conversation_update_data: OpenAIConversationUpdate -): - """Test conversation update with non-existent ID.""" - project = get_project(db) - - updated_conversation = update_conversation( - session=db, - conversation_id=99999, - project_id=project.id, - conversation_update=conversation_update_data, - ) - - assert updated_conversation is None - - def test_delete_conversation_success(db: Session): """Test successful conversation deletion.""" conversation = get_conversation(db) @@ -294,9 +224,9 @@ def test_delete_conversation_success(db: Session): ) assert deleted_conversation is not None + assert deleted_conversation.id == conversation.id assert deleted_conversation.is_deleted is True assert deleted_conversation.deleted_at is not None - assert deleted_conversation.id == conversation.id def test_delete_conversation_not_found(db: Session): @@ -312,60 +242,8 @@ def test_delete_conversation_not_found(db: Session): assert deleted_conversation is None -def test_upsert_conversation_create_new( - db: Session, conversation_create_data: OpenAIConversationCreate -): - """Test upsert conversation creates new conversation.""" - project = get_project(db) - organization = get_organization(db) - - conversation = upsert_conversation( - session=db, - conversation=conversation_create_data, - project_id=project.id, - organization_id=organization.id, - ) - - assert conversation is not None - assert conversation.response_id == conversation_create_data.response_id - assert conversation.user_question == conversation_create_data.user_question - - -def test_upsert_conversation_update_existing( - db: Session, conversation_create_data: OpenAIConversationCreate -): - """Test upsert conversation updates existing conversation.""" - project = get_project(db) - organization = get_organization(db) - - # First create a conversation - conversation1 = upsert_conversation( - session=db, - conversation=conversation_create_data, - project_id=project.id, - organization_id=organization.id, - ) - - # Update the data and upsert again - conversation_create_data.response = "Updated response" - conversation_create_data.model = "gpt-4o-mini" - - conversation2 = upsert_conversation( - session=db, - conversation=conversation_create_data, - project_id=project.id, - organization_id=organization.id, - ) - - assert conversation2 is not None - assert conversation2.id == conversation1.id # Same conversation - assert conversation2.response == "Updated response" - assert conversation2.model == "gpt-4o-mini" - assert conversation2.response_id == conversation1.response_id - - def test_conversation_soft_delete_behavior(db: Session): - """Test that soft deleted conversations are not returned by queries.""" + """Test that deleted conversations are not returned by get functions.""" conversation = get_conversation(db) project = get_project(db) @@ -376,31 +254,23 @@ def test_conversation_soft_delete_behavior(db: Session): project_id=project.id, ) - # Try to retrieve it by ID + # Verify it's not returned by get functions retrieved_conversation = get_conversation_by_id( session=db, conversation_id=conversation.id, project_id=project.id, ) - assert retrieved_conversation is None - # Try to retrieve it by response ID retrieved_conversation = get_conversation_by_response_id( session=db, response_id=conversation.response_id, project_id=project.id, ) - assert retrieved_conversation is None - # Check that it's not in the project list conversations = get_conversations_by_project( session=db, project_id=project.id, - skip=0, - limit=10, ) - - conversation_ids = [conv.id for conv in conversations] - assert conversation.id not in conversation_ids + assert conversation.id not in [c.id for c in conversations] From 42d1d2c224caaa94545830a14ddbf35cd4e67f6c Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Sun, 27 Jul 2025 22:42:32 +0530 Subject: [PATCH 04/22] removing create endpoint --- backend/app/api/routes/openai_conversation.py | 19 ------ .../api/routes/test_openai_conversation.py | 65 ------------------- .../tests/crud/test_openai_conversation.py | 39 ----------- 3 files changed, 123 deletions(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index 10491999..0cbc1425 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -9,7 +9,6 @@ get_conversation_by_response_id, get_conversation_by_ancestor_id, get_conversations_by_project, - create_conversation, delete_conversation, ) from app.models import ( @@ -22,24 +21,6 @@ router = APIRouter(prefix="/openai-conversation", tags=["OpenAI Conversations"]) -@router.post("/", response_model=APIResponse[OpenAIConversation], status_code=201) -def create_conversation_route( - conversation_in: OpenAIConversationCreate, - session: Session = Depends(get_db), - current_user: UserProjectOrg = Depends(get_current_user_org_project), -): - """ - Create a new OpenAI conversation in the database. - """ - conversation = create_conversation( - session=session, - conversation=conversation_in, - project_id=current_user.project_id, - organization_id=current_user.organization_id, - ) - return APIResponse.success_response(conversation) - - @router.get( "/{conversation_id}", response_model=APIResponse[OpenAIConversation], diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index fcd8d587..e28fa9dc 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -7,71 +7,6 @@ from app.tests.utils.conversation import get_conversation -@pytest.fixture -def conversation_create_payload(): - return { - "response_id": f"resp_{uuid4()}", - "ancestor_response_id": None, - "previous_response_id": None, - "user_question": "What is the capital of France?", - "response": "The capital of France is Paris.", - "model": "gpt-4o", - "assistant_id": f"asst_{uuid4()}", - } - - -def test_create_conversation_success( - client: TestClient, - conversation_create_payload: dict, - user_api_key_header: dict, -): - """Test successful conversation creation.""" - response = client.post( - "/api/v1/openai-conversation", - json=conversation_create_payload, - headers=user_api_key_header, - ) - - assert response.status_code == 201 - response_data = response.json() - assert response_data["success"] is True - assert ( - response_data["data"]["response_id"] - == conversation_create_payload["response_id"] - ) - assert ( - response_data["data"]["user_question"] - == conversation_create_payload["user_question"] - ) - assert response_data["data"]["response"] == conversation_create_payload["response"] - assert response_data["data"]["model"] == conversation_create_payload["model"] - assert ( - response_data["data"]["assistant_id"] - == conversation_create_payload["assistant_id"] - ) - - -def test_create_conversation_invalid_data( - client: TestClient, - user_api_key_header: dict, -): - """Test conversation creation with invalid data.""" - invalid_payload = { - "response_id": "", # Empty response_id - "user_question": "", # Empty user_question - "model": "", # Empty model - "assistant_id": "", # Empty assistant_id - } - - response = client.post( - "/api/v1/openai-conversation", - json=invalid_payload, - headers=user_api_key_header, - ) - - assert response.status_code == 422 - - def test_get_conversation_success( client: TestClient, db: Session, diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 1d4a3245..79f033f7 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -15,45 +15,6 @@ from app.tests.utils.utils import get_project, get_organization -@pytest.fixture -def conversation_create_data(): - return OpenAIConversationCreate( - response_id=f"resp_{uuid4()}", - ancestor_response_id=None, - previous_response_id=None, - user_question="What is the capital of France?", - response="The capital of France is Paris.", - model="gpt-4o", - assistant_id=f"asst_{uuid4()}", - ) - - -def test_create_conversation_success( - db: Session, conversation_create_data: OpenAIConversationCreate -): - """Test successful conversation creation.""" - project = get_project(db) - organization = get_organization(db) - - conversation = create_conversation( - session=db, - conversation=conversation_create_data, - project_id=project.id, - organization_id=organization.id, - ) - - assert conversation is not None - assert conversation.response_id == conversation_create_data.response_id - assert conversation.user_question == conversation_create_data.user_question - assert conversation.response == conversation_create_data.response - assert conversation.model == conversation_create_data.model - assert conversation.assistant_id == conversation_create_data.assistant_id - assert conversation.project_id == project.id - assert conversation.organization_id == organization.id - assert conversation.is_deleted is False - assert conversation.deleted_at is None - - def test_get_conversation_by_id_success(db: Session): """Test successful conversation retrieval by ID.""" conversation = get_conversation(db) From f4dbeb464ac400f251089e2afd6fa274895a1ec5 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 10:42:24 +0530 Subject: [PATCH 05/22] coderabbit suggestions --- backend/app/api/routes/openai_conversation.py | 1 - backend/app/crud/openai_conversation.py | 6 +++--- backend/app/tests/crud/test_openai_conversation.py | 1 - backend/app/tests/utils/conversation.py | 8 +++----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index 0cbc1425..548496bc 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -13,7 +13,6 @@ ) from app.models import ( UserProjectOrg, - OpenAIConversationCreate, OpenAIConversation, ) from app.utils import APIResponse diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index ad072132..dcd864a8 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from sqlmodel import Session, select from app.models import OpenAIConversation, OpenAIConversationCreate from app.core.util import now @@ -9,7 +9,7 @@ def get_conversation_by_id( session: Session, conversation_id: int, project_id: int -) -> Optional[OpenAIConversation]: +) -> OpenAIConversation | None: """ Return a conversation by its ID and project. """ @@ -57,7 +57,7 @@ def get_conversations_by_project( project_id: int, skip: int = 0, limit: int = 100, -) -> List[OpenAIConversation]: +) -> list[OpenAIConversation]: """ Return all conversations for a given project, with optional pagination. """ diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 79f033f7..ac2c3d6d 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -1,4 +1,3 @@ -import pytest from uuid import uuid4 from sqlmodel import Session diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index e8363ccd..c10c83ba 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -20,7 +20,7 @@ def get_conversation( select(OpenAIConversation) .where( OpenAIConversation.response_id == response_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) .limit(1) ) @@ -29,15 +29,13 @@ def get_conversation( select(OpenAIConversation) .where( OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) .limit(1) ) else: statement = ( - select(OpenAIConversation) - .where(OpenAIConversation.is_deleted == False) - .limit(1) + select(OpenAIConversation).where(not OpenAIConversation.is_deleted).limit(1) ) conversation = session.exec(statement).first() From 37dd69f815b2fe559f5bdb325914206ac6517881 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 11:05:12 +0530 Subject: [PATCH 06/22] added validations --- backend/app/models/openai_conversation.py | 80 +++++++++++++++++-- .../api/routes/test_openai_conversation.py | 16 +++- .../tests/crud/test_openai_conversation.py | 20 +++-- backend/app/tests/utils/conversation.py | 15 +++- 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index b9e90a41..d07fde46 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -1,13 +1,18 @@ +import re + from datetime import datetime from typing import Optional +import re +from pydantic import field_validator from sqlmodel import Field, Relationship, SQLModel from app.core.util import now class OpenAIConversationBase(SQLModel): - response_id: str = Field(index=True, description="OpenAI response ID") + # usually follow the pattern of resp_688704e41190819db512c30568dcaebc0a42e02be2c2c49b + response_id: str = Field(index=True, min_length=10) ancestor_response_id: Optional[str] = Field( default=None, index=True, @@ -18,8 +23,17 @@ class OpenAIConversationBase(SQLModel): ) user_question: str = Field(description="User's question/input") response: Optional[str] = Field(default=None, description="AI response") - model: str = Field(description="Model used for the response") - assistant_id: str = Field(description="Assistant ID used for the response") + # there are models with small name like o1 and usually fine tuned models have long names + model: str = Field( + description="The model used for the response", min_length=1, max_length=150 + ) + # usually follow the pattern of asst_WD9bumYqTtpSvxxxxx + assistant_id: Optional[str] = Field( + default=None, + description="The assistant ID used", + min_length=10, + max_length=50, + ) project_id: int = Field( foreign_key="project.id", nullable=False, ondelete="CASCADE" ) @@ -27,6 +41,28 @@ class OpenAIConversationBase(SQLModel): foreign_key="organization.id", nullable=False, ondelete="CASCADE" ) + @field_validator("response_id", "ancestor_response_id", "previous_response_id") + @classmethod + def validate_response_ids(cls, v): + if v is None: + return v + if not re.match(r"^resp_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "response_id fields must follow pattern: resp_ followed by at least 10 alphanumeric characters" + ) + return v + + @field_validator("assistant_id") + @classmethod + def validate_assistant_id(cls, v): + if v is None: + return v + if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" + ) + return v + class OpenAIConversation(OpenAIConversationBase, table=True): __tablename__ = "openai_conversation" @@ -43,7 +79,8 @@ class OpenAIConversation(OpenAIConversationBase, table=True): class OpenAIConversationCreate(SQLModel): - response_id: str = Field(description="OpenAI response ID") + # usually follow the pattern of resp_688704e41190819db512c30568dcaebc0a42e02be2c2c49b + response_id: str = Field(min_length=10) ancestor_response_id: Optional[str] = Field( default=None, description="Ancestor response ID for conversation threading" ) @@ -52,7 +89,36 @@ class OpenAIConversationCreate(SQLModel): ) user_question: str = Field(description="User's question/input", min_length=1) response: Optional[str] = Field(default=None, description="AI response") - model: str = Field(description="Model used for the response", min_length=1) - assistant_id: str = Field( - description="Assistant ID used for the response", min_length=1 + # there are models with small name like o1 and usually fine tuned models have long names + model: str = Field( + description="The model used for the response", min_length=1, max_length=150 + ) + # usually follow the pattern of asst_WD9bumYqTtpSvxxxxx + assistant_id: Optional[str] = Field( + default=None, + description="The assistant ID used", + min_length=10, + max_length=50, ) + + @field_validator("response_id", "ancestor_response_id", "previous_response_id") + @classmethod + def validate_response_ids(cls, v): + if v is None: + return v + if not re.match(r"^resp_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "response_id fields must follow pattern: resp_ followed by at least 10 alphanumeric characters" + ) + return v + + @field_validator("assistant_id") + @classmethod + def validate_assistant_id(cls, v): + if v is None: + return v + if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" + ) + return v diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index e28fa9dc..9c1e0c62 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -1,5 +1,6 @@ import pytest -from uuid import uuid4 +import secrets +import string from sqlmodel import Session from fastapi import HTTPException from fastapi.testclient import TestClient @@ -7,6 +8,13 @@ from app.tests.utils.conversation import get_conversation +def generate_realistic_id(prefix: str, length: int = 40) -> str: + """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" + chars = string.ascii_lowercase + string.digits + random_part = "".join(secrets.choice(chars) for _ in range(length)) + return f"{prefix}{random_part}" + + def test_get_conversation_success( client: TestClient, db: Session, @@ -105,15 +113,15 @@ def test_get_conversation_by_ancestor_id_success( api_key = get_user_from_api_key(db, user_api_key_header) # Create a conversation with an ancestor in the same project as the API key - ancestor_response_id = f"resp_{uuid4()}" + ancestor_response_id = generate_realistic_id("resp_", 40) conversation_data = OpenAIConversationCreate( - response_id=f"resp_{uuid4()}", + response_id=generate_realistic_id("resp_", 40), ancestor_response_id=ancestor_response_id, previous_response_id=None, user_question="What is the capital of France?", response="The capital of France is Paris.", model="gpt-4o", - assistant_id=f"asst_{uuid4()}", + assistant_id=generate_realistic_id("asst_", 20), ) conversation = create_conversation( diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index ac2c3d6d..d287f687 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -1,4 +1,5 @@ -from uuid import uuid4 +import secrets +import string from sqlmodel import Session from app.crud.openai_conversation import ( @@ -14,6 +15,13 @@ from app.tests.utils.utils import get_project, get_organization +def generate_realistic_id(prefix: str, length: int = 40) -> str: + """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" + chars = string.ascii_lowercase + string.digits + random_part = "".join(secrets.choice(chars) for _ in range(length)) + return f"{prefix}{random_part}" + + def test_get_conversation_by_id_success(db: Session): """Test successful conversation retrieval by ID.""" conversation = get_conversation(db) @@ -78,15 +86,15 @@ def test_get_conversation_by_ancestor_id_success(db: Session): organization = get_organization(db) # Create a conversation with an ancestor - ancestor_response_id = f"resp_{uuid4()}" + ancestor_response_id = generate_realistic_id("resp_", 40) conversation_data = OpenAIConversationCreate( - response_id=f"resp_{uuid4()}", + response_id=generate_realistic_id("resp_", 40), ancestor_response_id=ancestor_response_id, previous_response_id=None, user_question="What is the capital of France?", response="The capital of France is Paris.", model="gpt-4o", - assistant_id=f"asst_{uuid4()}", + assistant_id=generate_realistic_id("asst_", 20), ) conversation = create_conversation( @@ -128,13 +136,13 @@ def test_get_conversations_by_project_success(db: Session): # Create multiple conversations directly for i in range(3): conversation_data = OpenAIConversationCreate( - response_id=f"resp_{uuid4()}", + response_id=generate_realistic_id("resp_", 40), ancestor_response_id=None, previous_response_id=None, user_question=f"Test question {i}", response=f"Test response {i}", model="gpt-4o", - assistant_id=f"asst_{uuid4()}", + assistant_id=generate_realistic_id("asst_", 20), ) create_conversation( session=db, diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index c10c83ba..62bc3dd5 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -1,10 +1,19 @@ -from uuid import uuid4 +import secrets +import string from sqlmodel import Session, select from app.models import OpenAIConversation, OpenAIConversationCreate from app.crud.openai_conversation import create_conversation +def generate_realistic_id(prefix: str, length: int = 40) -> str: + """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" + # Generate random alphanumeric string + chars = string.ascii_lowercase + string.digits + random_part = "".join(secrets.choice(chars) for _ in range(length)) + return f"{prefix}{random_part}" + + def get_conversation( session: Session, response_id: str | None = None, project_id: int | None = None ) -> OpenAIConversation: @@ -59,13 +68,13 @@ def get_conversation( organization = get_organization(session) conversation_data = OpenAIConversationCreate( - response_id=f"resp_{uuid4()}", + response_id=generate_realistic_id("resp_", 40), ancestor_response_id=None, previous_response_id=None, user_question="Test question", response="Test response", model="gpt-4o", - assistant_id=f"asst_{uuid4()}", + assistant_id=generate_realistic_id("asst_", 20), ) conversation = create_conversation( From 8f9899af1c62a2fd526d706aa76fa7d7ec44cc69 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 11:06:36 +0530 Subject: [PATCH 07/22] coderabbit suggestions --- backend/app/crud/openai_conversation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index dcd864a8..0d17bee3 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -16,7 +16,7 @@ def get_conversation_by_id( statement = select(OpenAIConversation).where( OpenAIConversation.id == conversation_id, OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) result = session.exec(statement).first() return result @@ -24,14 +24,14 @@ def get_conversation_by_id( def get_conversation_by_response_id( session: Session, response_id: str, project_id: int -) -> Optional[OpenAIConversation]: +) -> OpenAIConversation | None: """ Return a conversation by its OpenAI response ID and project. """ statement = select(OpenAIConversation).where( OpenAIConversation.response_id == response_id, OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) result = session.exec(statement).first() return result @@ -39,14 +39,14 @@ def get_conversation_by_response_id( def get_conversation_by_ancestor_id( session: Session, ancestor_response_id: str, project_id: int -) -> Optional[OpenAIConversation]: +) -> OpenAIConversation | None: """ Return a conversation by its ancestor response ID and project. """ statement = select(OpenAIConversation).where( OpenAIConversation.ancestor_response_id == ancestor_response_id, OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) result = session.exec(statement).first() return result @@ -65,7 +65,7 @@ def get_conversations_by_project( select(OpenAIConversation) .where( OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + not OpenAIConversation.is_deleted, ) .order_by(OpenAIConversation.inserted_at.desc()) .offset(skip) @@ -105,7 +105,7 @@ def delete_conversation( session: Session, conversation_id: int, project_id: int, -) -> Optional[OpenAIConversation]: +) -> OpenAIConversation | None: """ Soft delete a conversation by marking it as deleted. """ From 8487a0b14ccb484419a884dee91c9a0ee2703599 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 11:21:26 +0530 Subject: [PATCH 08/22] cleanups --- backend/app/crud/openai_conversation.py | 8 +-- backend/app/models/openai_conversation.py | 58 +++++++++---------- .../tests/crud/test_openai_conversation.py | 8 +-- backend/app/tests/utils/conversation.py | 8 ++- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index 0d17bee3..1a1c37fe 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -16,7 +16,7 @@ def get_conversation_by_id( statement = select(OpenAIConversation).where( OpenAIConversation.id == conversation_id, OpenAIConversation.project_id == project_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) result = session.exec(statement).first() return result @@ -31,7 +31,7 @@ def get_conversation_by_response_id( statement = select(OpenAIConversation).where( OpenAIConversation.response_id == response_id, OpenAIConversation.project_id == project_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) result = session.exec(statement).first() return result @@ -46,7 +46,7 @@ def get_conversation_by_ancestor_id( statement = select(OpenAIConversation).where( OpenAIConversation.ancestor_response_id == ancestor_response_id, OpenAIConversation.project_id == project_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) result = session.exec(statement).first() return result @@ -65,7 +65,7 @@ def get_conversations_by_project( select(OpenAIConversation) .where( OpenAIConversation.project_id == project_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) .order_by(OpenAIConversation.inserted_at.desc()) .offset(skip) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index d07fde46..5090fd30 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -1,5 +1,3 @@ -import re - from datetime import datetime from typing import Optional import re @@ -10,8 +8,30 @@ from app.core.util import now +def validate_response_id_pattern(v: str) -> str: + """Shared validation function for response ID patterns""" + if v is None: + return v + if not re.match(r"^resp_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "response_id fields must follow pattern: resp_ followed by at least 10 alphanumeric characters" + ) + return v + + +def validate_assistant_id_pattern(v: str) -> str: + """Shared validation function for assistant ID patterns""" + if v is None: + return v + if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): + raise ValueError( + "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" + ) + return v + + class OpenAIConversationBase(SQLModel): - # usually follow the pattern of resp_688704e41190819db512c30568dcaebc0a42e02be2c2c49b + # usually follow the pattern of resp_688704e41190819db512c30568xxxxxxx response_id: str = Field(index=True, min_length=10) ancestor_response_id: Optional[str] = Field( default=None, @@ -44,24 +64,12 @@ class OpenAIConversationBase(SQLModel): @field_validator("response_id", "ancestor_response_id", "previous_response_id") @classmethod def validate_response_ids(cls, v): - if v is None: - return v - if not re.match(r"^resp_[a-zA-Z0-9]{10,}$", v): - raise ValueError( - "response_id fields must follow pattern: resp_ followed by at least 10 alphanumeric characters" - ) - return v + return validate_response_id_pattern(v) @field_validator("assistant_id") @classmethod def validate_assistant_id(cls, v): - if v is None: - return v - if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): - raise ValueError( - "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" - ) - return v + return validate_assistant_id_pattern(v) class OpenAIConversation(OpenAIConversationBase, table=True): @@ -104,21 +112,9 @@ class OpenAIConversationCreate(SQLModel): @field_validator("response_id", "ancestor_response_id", "previous_response_id") @classmethod def validate_response_ids(cls, v): - if v is None: - return v - if not re.match(r"^resp_[a-zA-Z0-9]{10,}$", v): - raise ValueError( - "response_id fields must follow pattern: resp_ followed by at least 10 alphanumeric characters" - ) - return v + return validate_response_id_pattern(v) @field_validator("assistant_id") @classmethod def validate_assistant_id(cls, v): - if v is None: - return v - if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): - raise ValueError( - "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" - ) - return v + return validate_assistant_id_pattern(v) diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index d287f687..0992641c 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -24,8 +24,8 @@ def generate_realistic_id(prefix: str, length: int = 40) -> str: def test_get_conversation_by_id_success(db: Session): """Test successful conversation retrieval by ID.""" - conversation = get_conversation(db) project = get_project(db) + conversation = get_conversation(db, project_id=project.id) retrieved_conversation = get_conversation_by_id( session=db, @@ -53,8 +53,8 @@ def test_get_conversation_by_id_not_found(db: Session): def test_get_conversation_by_response_id_success(db: Session): """Test successful conversation retrieval by response ID.""" - conversation = get_conversation(db) project = get_project(db) + conversation = get_conversation(db, project_id=project.id) retrieved_conversation = get_conversation_by_response_id( session=db, @@ -182,8 +182,8 @@ def test_get_conversations_by_project_with_pagination(db: Session): def test_delete_conversation_success(db: Session): """Test successful conversation deletion.""" - conversation = get_conversation(db) project = get_project(db) + conversation = get_conversation(db, project_id=project.id) deleted_conversation = delete_conversation( session=db, @@ -212,8 +212,8 @@ def test_delete_conversation_not_found(db: Session): def test_conversation_soft_delete_behavior(db: Session): """Test that deleted conversations are not returned by get functions.""" - conversation = get_conversation(db) project = get_project(db) + conversation = get_conversation(db, project_id=project.id) # Delete the conversation delete_conversation( diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index 62bc3dd5..5d2c8af8 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -29,7 +29,7 @@ def get_conversation( select(OpenAIConversation) .where( OpenAIConversation.response_id == response_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) .limit(1) ) @@ -38,13 +38,15 @@ def get_conversation( select(OpenAIConversation) .where( OpenAIConversation.project_id == project_id, - not OpenAIConversation.is_deleted, + OpenAIConversation.is_deleted == False, ) .limit(1) ) else: statement = ( - select(OpenAIConversation).where(not OpenAIConversation.is_deleted).limit(1) + select(OpenAIConversation) + .where(OpenAIConversation.is_deleted == False) + .limit(1) ) conversation = session.exec(statement).first() From e0fa37521179a28e3f8adafd224814cd53e5228f Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 11:56:01 +0530 Subject: [PATCH 09/22] cleaning up names --- .../app/tests/api/routes/test_openai_conversation.py | 8 ++++---- backend/app/tests/crud/test_openai_conversation.py | 12 ++++++------ backend/app/tests/utils/conversation.py | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 9c1e0c62..e6d9c952 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -8,7 +8,7 @@ from app.tests.utils.conversation import get_conversation -def generate_realistic_id(prefix: str, length: int = 40) -> str: +def generate_openai_id(prefix: str, length: int = 40) -> str: """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" chars = string.ascii_lowercase + string.digits random_part = "".join(secrets.choice(chars) for _ in range(length)) @@ -113,15 +113,15 @@ def test_get_conversation_by_ancestor_id_success( api_key = get_user_from_api_key(db, user_api_key_header) # Create a conversation with an ancestor in the same project as the API key - ancestor_response_id = generate_realistic_id("resp_", 40) + ancestor_response_id = generate_openai_id("resp_", 40) conversation_data = OpenAIConversationCreate( - response_id=generate_realistic_id("resp_", 40), + response_id=generate_openai_id("resp_", 40), ancestor_response_id=ancestor_response_id, previous_response_id=None, user_question="What is the capital of France?", response="The capital of France is Paris.", model="gpt-4o", - assistant_id=generate_realistic_id("asst_", 20), + assistant_id=generate_openai_id("asst_", 20), ) conversation = create_conversation( diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 0992641c..864fa666 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -15,7 +15,7 @@ from app.tests.utils.utils import get_project, get_organization -def generate_realistic_id(prefix: str, length: int = 40) -> str: +def generate_openai_id(prefix: str, length: int = 40) -> str: """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" chars = string.ascii_lowercase + string.digits random_part = "".join(secrets.choice(chars) for _ in range(length)) @@ -86,15 +86,15 @@ def test_get_conversation_by_ancestor_id_success(db: Session): organization = get_organization(db) # Create a conversation with an ancestor - ancestor_response_id = generate_realistic_id("resp_", 40) + ancestor_response_id = generate_openai_id("resp_", 40) conversation_data = OpenAIConversationCreate( - response_id=generate_realistic_id("resp_", 40), + response_id=generate_openai_id("resp_", 40), ancestor_response_id=ancestor_response_id, previous_response_id=None, user_question="What is the capital of France?", response="The capital of France is Paris.", model="gpt-4o", - assistant_id=generate_realistic_id("asst_", 20), + assistant_id=generate_openai_id("asst_", 20), ) conversation = create_conversation( @@ -136,13 +136,13 @@ def test_get_conversations_by_project_success(db: Session): # Create multiple conversations directly for i in range(3): conversation_data = OpenAIConversationCreate( - response_id=generate_realistic_id("resp_", 40), + response_id=generate_openai_id("resp_", 40), ancestor_response_id=None, previous_response_id=None, user_question=f"Test question {i}", response=f"Test response {i}", model="gpt-4o", - assistant_id=generate_realistic_id("asst_", 20), + assistant_id=generate_openai_id("asst_", 20), ) create_conversation( session=db, diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index 5d2c8af8..eca11db3 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -6,7 +6,7 @@ from app.crud.openai_conversation import create_conversation -def generate_realistic_id(prefix: str, length: int = 40) -> str: +def generate_openai_id(prefix: str, length: int = 40) -> str: """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" # Generate random alphanumeric string chars = string.ascii_lowercase + string.digits @@ -70,13 +70,13 @@ def get_conversation( organization = get_organization(session) conversation_data = OpenAIConversationCreate( - response_id=generate_realistic_id("resp_", 40), + response_id=generate_openai_id("resp_", 40), ancestor_response_id=None, previous_response_id=None, user_question="Test question", response="Test response", model="gpt-4o", - assistant_id=generate_realistic_id("asst_", 20), + assistant_id=generate_openai_id("asst_", 20), ) conversation = create_conversation( From 417e54d379ae103134715cfc6da772978507e572 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 12:56:17 +0530 Subject: [PATCH 10/22] fixing testcases to use header --- .../api/routes/test_openai_conversation.py | 78 ++++++++----------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index e6d9c952..140b55fe 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -2,10 +2,12 @@ import secrets import string from sqlmodel import Session -from fastapi import HTTPException from fastapi.testclient import TestClient +from app.models import APIKeyPublic from app.tests.utils.conversation import get_conversation +from app.crud.openai_conversation import create_conversation +from app.models import OpenAIConversationCreate def generate_openai_id(prefix: str, length: int = 40) -> str: @@ -18,21 +20,16 @@ def generate_openai_id(prefix: str, length: int = 40) -> str: def test_get_conversation_success( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test successful conversation retrieval.""" - # Get the project ID from the user's API key - from app.tests.utils.utils import get_user_from_api_key - - api_key = get_user_from_api_key(db, user_api_key_header) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=api_key.project_id) + conversation = get_conversation(db, project_id=user_api_key.project_id) conversation_id = conversation.id response = client.get( f"/api/v1/openai-conversation/{conversation_id}", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -44,12 +41,12 @@ def test_get_conversation_success( def test_get_conversation_not_found( client: TestClient, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation retrieval with non-existent ID.""" response = client.get( "/api/v1/openai-conversation/99999", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 @@ -60,21 +57,19 @@ def test_get_conversation_not_found( def test_get_conversation_by_response_id_success( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test successful conversation retrieval by response ID.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key - api_key = get_user_from_api_key(db, user_api_key_header) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=api_key.project_id) + conversation = get_conversation(db, project_id=user_api_key.project_id) response_id = conversation.response_id response = client.get( f"/api/v1/openai-conversation/response/{response_id}", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -86,12 +81,12 @@ def test_get_conversation_by_response_id_success( def test_get_conversation_by_response_id_not_found( client: TestClient, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation retrieval with non-existent response ID.""" response = client.get( "/api/v1/openai-conversation/response/nonexistent_response_id", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 @@ -102,15 +97,10 @@ def test_get_conversation_by_response_id_not_found( def test_get_conversation_by_ancestor_id_success( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test successful conversation retrieval by ancestor ID.""" # Get the project ID from the user's API key - from app.tests.utils.utils import get_user_from_api_key - from app.crud.openai_conversation import create_conversation - from app.models import OpenAIConversationCreate - - api_key = get_user_from_api_key(db, user_api_key_header) # Create a conversation with an ancestor in the same project as the API key ancestor_response_id = generate_openai_id("resp_", 40) @@ -127,13 +117,13 @@ def test_get_conversation_by_ancestor_id_success( conversation = create_conversation( session=db, conversation=conversation_data, - project_id=api_key.project_id, - organization_id=api_key.organization_id, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, ) response = client.get( f"/api/v1/openai-conversation/ancestor/{ancestor_response_id}", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -145,12 +135,12 @@ def test_get_conversation_by_ancestor_id_success( def test_get_conversation_by_ancestor_id_not_found( client: TestClient, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation retrieval with non-existent ancestor ID.""" response = client.get( "/api/v1/openai-conversation/ancestor/nonexistent_ancestor_id", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 @@ -161,20 +151,18 @@ def test_get_conversation_by_ancestor_id_not_found( def test_list_conversations_success( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test successful conversation listing.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key - api_key = get_user_from_api_key(db, user_api_key_header) - # Create a conversation in the same project as the API key - get_conversation(db, project_id=api_key.project_id) + get_conversation(db, project_id=user_api_key.project_id) response = client.get( "/api/v1/openai-conversation", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -187,7 +175,7 @@ def test_list_conversations_success( def test_list_conversations_with_pagination( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation listing with pagination.""" # Create multiple conversations @@ -196,7 +184,7 @@ def test_list_conversations_with_pagination( response = client.get( "/api/v1/openai-conversation?skip=1&limit=2", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -208,12 +196,12 @@ def test_list_conversations_with_pagination( def test_list_conversations_invalid_pagination( client: TestClient, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation listing with invalid pagination parameters.""" response = client.get( "/api/v1/openai-conversation?skip=-1&limit=0", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 422 @@ -222,21 +210,19 @@ def test_list_conversations_invalid_pagination( def test_delete_conversation_success( client: TestClient, db: Session, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test successful conversation deletion.""" # Get the project ID from the user's API key from app.tests.utils.utils import get_user_from_api_key - api_key = get_user_from_api_key(db, user_api_key_header) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=api_key.project_id) + conversation = get_conversation(db, project_id=user_api_key.project_id) conversation_id = conversation.id response = client.delete( f"/api/v1/openai-conversation/{conversation_id}", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 @@ -247,19 +233,19 @@ def test_delete_conversation_success( # Verify the conversation is marked as deleted response = client.get( f"/api/v1/openai-conversation/{conversation_id}", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 def test_delete_conversation_not_found( client: TestClient, - user_api_key_header: dict, + user_api_key: APIKeyPublic, ): """Test conversation deletion with non-existent ID.""" response = client.delete( "/api/v1/openai-conversation/99999", - headers=user_api_key_header, + headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 404 From 3c512a9893bb8e7eba21af7a064689b588dc7f02 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 13:03:03 +0530 Subject: [PATCH 11/22] updated testcases --- .../api/routes/test_openai_conversation.py | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 140b55fe..e5c5d889 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -23,19 +23,33 @@ def test_get_conversation_success( user_api_key: APIKeyPublic, ): """Test successful conversation retrieval.""" - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=user_api_key.project_id) - conversation_id = conversation.id + response_id = generate_openai_id("resp_", 40) + conversation_data = OpenAIConversationCreate( + response_id=response_id, + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) response = client.get( - f"/api/v1/openai-conversation/{conversation_id}", + f"/api/v1/openai-conversation/{conversation.id}", headers={"X-API-KEY": user_api_key.key}, ) assert response.status_code == 200 response_data = response.json() assert response_data["success"] is True - assert response_data["data"]["id"] == conversation_id + assert response_data["data"]["id"] == conversation.id assert response_data["data"]["response_id"] == conversation.response_id @@ -60,12 +74,23 @@ def test_get_conversation_by_response_id_success( user_api_key: APIKeyPublic, ): """Test successful conversation retrieval by response ID.""" - # Get the project ID from the user's API key - from app.tests.utils.utils import get_user_from_api_key + response_id = generate_openai_id("resp_", 40) + conversation_data = OpenAIConversationCreate( + response_id=response_id, + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=user_api_key.project_id) - response_id = conversation.response_id + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) response = client.get( f"/api/v1/openai-conversation/response/{response_id}", @@ -100,9 +125,6 @@ def test_get_conversation_by_ancestor_id_success( user_api_key: APIKeyPublic, ): """Test successful conversation retrieval by ancestor ID.""" - # Get the project ID from the user's API key - - # Create a conversation with an ancestor in the same project as the API key ancestor_response_id = generate_openai_id("resp_", 40) conversation_data = OpenAIConversationCreate( response_id=generate_openai_id("resp_", 40), From 90b67bcf34833c08f7aea4485d00573db5d62890 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 13:09:35 +0530 Subject: [PATCH 12/22] updated testcases --- backend/app/api/routes/openai_conversation.py | 10 +++++----- backend/app/models/__init__.py | 1 + backend/app/models/openai_conversation.py | 13 +++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index 548496bc..f5bbe833 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -13,7 +13,7 @@ ) from app.models import ( UserProjectOrg, - OpenAIConversation, + OpenAIConversationPublic, ) from app.utils import APIResponse @@ -22,7 +22,7 @@ @router.get( "/{conversation_id}", - response_model=APIResponse[OpenAIConversation], + response_model=APIResponse[OpenAIConversationPublic], summary="Get a single conversation by its ID", ) def get_conversation_route( @@ -45,7 +45,7 @@ def get_conversation_route( @router.get( "/response/{response_id}", - response_model=APIResponse[OpenAIConversation], + response_model=APIResponse[OpenAIConversationPublic], summary="Get a conversation by its OpenAI response ID", ) def get_conversation_by_response_id_route( @@ -69,7 +69,7 @@ def get_conversation_by_response_id_route( @router.get( "/ancestor/{ancestor_response_id}", - response_model=APIResponse[OpenAIConversation], + response_model=APIResponse[OpenAIConversationPublic], summary="Get a conversation by its ancestor response ID", ) def get_conversation_by_ancestor_id_route( @@ -95,7 +95,7 @@ def get_conversation_by_ancestor_id_route( @router.get( "/", - response_model=APIResponse[list[OpenAIConversation]], + response_model=APIResponse[list[OpenAIConversationPublic]], summary="List all conversations in the current project", ) def list_conversations_route( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d1222474..a1c2009c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -57,6 +57,7 @@ from .assistants import Assistant, AssistantBase, AssistantCreate, AssistantUpdate from .openai_conversation import ( + OpenAIConversationPublic, OpenAIConversation, OpenAIConversationBase, OpenAIConversationCreate, diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index 5090fd30..0efa57bf 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -118,3 +118,16 @@ def validate_response_ids(cls, v): @classmethod def validate_assistant_id(cls, v): return validate_assistant_id_pattern(v) + + +class OpenAIConversationPublic(OpenAIConversationBase): + """Public model for OpenAIConversation without sensitive fields""" + + id: int + inserted_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + populate_by_name = True + use_enum_values = True From fb9875df2d12590b7973f3d3bccae2bdbf69e118 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 13:26:26 +0530 Subject: [PATCH 13/22] fixed return ancestor_id to return top result --- backend/app/crud/openai_conversation.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index 1a1c37fe..6d02d5c4 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -41,12 +41,17 @@ def get_conversation_by_ancestor_id( session: Session, ancestor_response_id: str, project_id: int ) -> OpenAIConversation | None: """ - Return a conversation by its ancestor response ID and project. + Return the latest conversation by its ancestor response ID and project. """ - statement = select(OpenAIConversation).where( - OpenAIConversation.ancestor_response_id == ancestor_response_id, - OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, + statement = ( + select(OpenAIConversation) + .where( + OpenAIConversation.ancestor_response_id == ancestor_response_id, + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + .order_by(OpenAIConversation.inserted_at.desc()) + .limit(1) ) result = session.exec(statement).first() return result From 080ef6b9707bfbe49a11bb78e1264cddbfdf3f57 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 13:53:12 +0530 Subject: [PATCH 14/22] removing redundant code --- backend/app/models/openai_conversation.py | 2 +- .../api/routes/test_openai_conversation.py | 78 +++++++++++-- .../tests/crud/test_openai_conversation.py | 107 ++++++++++++++++-- backend/app/tests/utils/conversation.py | 78 +------------ 4 files changed, 170 insertions(+), 95 deletions(-) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index 0efa57bf..e3652ad1 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -33,7 +33,7 @@ def validate_assistant_id_pattern(v: str) -> str: class OpenAIConversationBase(SQLModel): # usually follow the pattern of resp_688704e41190819db512c30568xxxxxxx response_id: str = Field(index=True, min_length=10) - ancestor_response_id: Optional[str] = Field( + ancestor_response_id: str = Field( default=None, index=True, description="Ancestor response ID for conversation threading", diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index e5c5d889..06da8605 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -5,7 +5,6 @@ from fastapi.testclient import TestClient from app.models import APIKeyPublic -from app.tests.utils.conversation import get_conversation from app.crud.openai_conversation import create_conversation from app.models import OpenAIConversationCreate @@ -176,11 +175,23 @@ def test_list_conversations_success( user_api_key: APIKeyPublic, ): """Test successful conversation listing.""" - # Get the project ID from the user's API key - from app.tests.utils.utils import get_user_from_api_key + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of France?", + response="The capital of France is Paris.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) - # Create a conversation in the same project as the API key - get_conversation(db, project_id=user_api_key.project_id) + # Actually create the conversation in the database + create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) response = client.get( "/api/v1/openai-conversation", @@ -201,8 +212,40 @@ def test_list_conversations_with_pagination( ): """Test conversation listing with pagination.""" # Create multiple conversations - for _ in range(3): - get_conversation(db) + conversation_data_1 = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + conversation_data_2 = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Brazil?", + response="The capital of Brazil is Brasília.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + # Actually create the conversations in the database + create_conversation( + session=db, + conversation=conversation_data_1, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) + + create_conversation( + session=db, + conversation=conversation_data_2, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) response = client.get( "/api/v1/openai-conversation?skip=1&limit=2", @@ -235,11 +278,24 @@ def test_delete_conversation_success( user_api_key: APIKeyPublic, ): """Test successful conversation deletion.""" - # Get the project ID from the user's API key - from app.tests.utils.utils import get_user_from_api_key + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + # Create the conversation in the database and get the created object with ID + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) - # Create a conversation in the same project as the API key - conversation = get_conversation(db, project_id=user_api_key.project_id) conversation_id = conversation.id response = client.delete( diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 864fa666..8214be6f 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -11,7 +11,6 @@ delete_conversation, ) from app.models import OpenAIConversationCreate -from app.tests.utils.conversation import get_conversation from app.tests.utils.utils import get_project, get_organization @@ -25,7 +24,26 @@ def generate_openai_id(prefix: str, length: int = 40) -> str: def test_get_conversation_by_id_success(db: Session): """Test successful conversation retrieval by ID.""" project = get_project(db) - conversation = get_conversation(db, project_id=project.id) + organization = get_organization(db) + + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + + # Create the conversation in the database + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) retrieved_conversation = get_conversation_by_id( session=db, @@ -54,7 +72,26 @@ def test_get_conversation_by_id_not_found(db: Session): def test_get_conversation_by_response_id_success(db: Session): """Test successful conversation retrieval by response ID.""" project = get_project(db) - conversation = get_conversation(db, project_id=project.id) + organization = get_organization(db) + + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + + # Create the conversation in the database + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) retrieved_conversation = get_conversation_by_response_id( session=db, @@ -95,6 +132,7 @@ def test_get_conversation_by_ancestor_id_success(db: Session): response="The capital of France is Paris.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, ) conversation = create_conversation( @@ -143,6 +181,7 @@ def test_get_conversations_by_project_success(db: Session): response=f"Test response {i}", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, ) create_conversation( session=db, @@ -165,10 +204,26 @@ def test_get_conversations_by_project_success(db: Session): def test_get_conversations_by_project_with_pagination(db: Session): """Test conversation listing by project with pagination.""" project = get_project(db) + organization = get_organization(db) # Create multiple conversations - for _ in range(5): - get_conversation(db, project_id=project.id) + for i in range(5): + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=None, + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) conversations = get_conversations_by_project( session=db, @@ -183,7 +238,26 @@ def test_get_conversations_by_project_with_pagination(db: Session): def test_delete_conversation_success(db: Session): """Test successful conversation deletion.""" project = get_project(db) - conversation = get_conversation(db, project_id=project.id) + organization = get_organization(db) + + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + + # Create the conversation in the database first + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) deleted_conversation = delete_conversation( session=db, @@ -213,7 +287,26 @@ def test_delete_conversation_not_found(db: Session): def test_conversation_soft_delete_behavior(db: Session): """Test that deleted conversations are not returned by get functions.""" project = get_project(db) - conversation = get_conversation(db, project_id=project.id) + organization = get_organization(db) + + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="What is the capital of Japan?", + response="The capital of Japan is Tokyo.", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + + # Create the conversation in the database first + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) # Delete the conversation delete_conversation( diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index eca11db3..3a976642 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -2,8 +2,9 @@ import string from sqlmodel import Session, select -from app.models import OpenAIConversation, OpenAIConversationCreate +from app.models import OpenAIConversation, OpenAIConversationCreate, Project from app.crud.openai_conversation import create_conversation +from app.tests.utils.utils import get_project, get_organization def generate_openai_id(prefix: str, length: int = 40) -> str: @@ -12,78 +13,3 @@ def generate_openai_id(prefix: str, length: int = 40) -> str: chars = string.ascii_lowercase + string.digits random_part = "".join(secrets.choice(chars) for _ in range(length)) return f"{prefix}{random_part}" - - -def get_conversation( - session: Session, response_id: str | None = None, project_id: int | None = None -) -> OpenAIConversation: - """ - Retrieve an active conversation from the database. - - If a response_id is provided, fetch the active conversation with that response_id. - If a project_id is provided, fetch a conversation from that specific project. - If no response_id or project_id is provided, fetch any random conversation. - """ - if response_id: - statement = ( - select(OpenAIConversation) - .where( - OpenAIConversation.response_id == response_id, - OpenAIConversation.is_deleted == False, - ) - .limit(1) - ) - elif project_id: - statement = ( - select(OpenAIConversation) - .where( - OpenAIConversation.project_id == project_id, - OpenAIConversation.is_deleted == False, - ) - .limit(1) - ) - else: - statement = ( - select(OpenAIConversation) - .where(OpenAIConversation.is_deleted == False) - .limit(1) - ) - - conversation = session.exec(statement).first() - - if not conversation: - # Create a new conversation if none exists - from app.tests.utils.utils import get_project, get_organization - - if project_id: - # Get the specific project - from app.models import Project - - project = session.exec( - select(Project).where(Project.id == project_id) - ).first() - if not project: - raise ValueError(f"Project with ID {project_id} not found") - else: - project = get_project(session) - - organization = get_organization(session) - - conversation_data = OpenAIConversationCreate( - response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, - previous_response_id=None, - user_question="Test question", - response="Test response", - model="gpt-4o", - assistant_id=generate_openai_id("asst_", 20), - ) - - conversation = create_conversation( - session=session, - conversation=conversation_data, - project_id=project.id, - organization_id=organization.id, - ) - - return conversation From 2de92592de27c3ac657d8e272f27363689acfd35 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 14:44:46 +0530 Subject: [PATCH 15/22] added pagination --- backend/app/api/routes/openai_conversation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index f5bbe833..8b3da17a 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -108,7 +108,10 @@ def list_conversations_route( List all conversations in the current project. """ conversations = get_conversations_by_project( - session=session, project_id=current_user.project_id, skip=skip, limit=limit + session=session, + project_id=current_user.project_id, + skip=skip, # ← Pagination offset + limit=limit, # ← Page size ) return APIResponse.success_response(conversations) From 4325aa4940c38bee70211e5f8d2697325aecd1dc Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 15:27:21 +0530 Subject: [PATCH 16/22] added metadata --- backend/app/api/routes/openai_conversation.py | 13 +- backend/app/crud/__init__.py | 1 + backend/app/crud/openai_conversation.py | 17 +- .../api/routes/test_openai_conversation.py | 146 ++++++++++++++++ .../tests/crud/test_openai_conversation.py | 156 ++++++++++++++++++ 5 files changed, 331 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/openai_conversation.py b/backend/app/api/routes/openai_conversation.py index 8b3da17a..5d4f8f8d 100644 --- a/backend/app/api/routes/openai_conversation.py +++ b/backend/app/api/routes/openai_conversation.py @@ -9,6 +9,8 @@ get_conversation_by_response_id, get_conversation_by_ancestor_id, get_conversations_by_project, + get_conversations_count_by_project, + create_conversation, delete_conversation, ) from app.models import ( @@ -113,7 +115,16 @@ def list_conversations_route( skip=skip, # ← Pagination offset limit=limit, # ← Page size ) - return APIResponse.success_response(conversations) + + # Get total count for pagination metadata + total = get_conversations_count_by_project( + session=session, + project_id=current_user.project_id, + ) + + return APIResponse.success_response( + data=conversations, metadata={"skip": skip, "limit": limit, "total": total} + ) @router.delete("/{conversation_id}", response_model=APIResponse) diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index fe5855e9..e4b973a0 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -60,6 +60,7 @@ get_conversation_by_response_id, get_conversation_by_ancestor_id, get_conversations_by_project, + get_conversations_count_by_project, create_conversation, delete_conversation, ) diff --git a/backend/app/crud/openai_conversation.py b/backend/app/crud/openai_conversation.py index 6d02d5c4..505e5e40 100644 --- a/backend/app/crud/openai_conversation.py +++ b/backend/app/crud/openai_conversation.py @@ -1,6 +1,6 @@ import logging from typing import Optional -from sqlmodel import Session, select +from sqlmodel import Session, select, func from app.models import OpenAIConversation, OpenAIConversationCreate from app.core.util import now @@ -57,6 +57,21 @@ def get_conversation_by_ancestor_id( return result +def get_conversations_count_by_project( + session: Session, + project_id: int, +) -> int: + """ + Return the total count of conversations for a given project. + """ + statement = select(func.count(OpenAIConversation.id)).where( + OpenAIConversation.project_id == project_id, + OpenAIConversation.is_deleted == False, + ) + result = session.exec(statement).one() + return result + + def get_conversations_by_project( session: Session, project_id: int, diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 06da8605..0971d069 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -258,6 +258,152 @@ def test_list_conversations_with_pagination( assert isinstance(response_data["data"], list) assert len(response_data["data"]) <= 2 + # Check pagination metadata + assert "metadata" in response_data + metadata = response_data["metadata"] + assert metadata["skip"] == 1 + assert metadata["limit"] == 2 + assert "total" in metadata + assert isinstance(metadata["total"], int) + assert metadata["total"] >= 2 + + +def test_list_conversations_pagination_metadata( + client: TestClient, + db: Session, + user_api_key: APIKeyPublic, +): + """Test conversation listing pagination metadata.""" + # Create 5 conversations + for i in range(5): + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) + + # Test first page + response = client.get( + "/api/v1/openai-conversation?skip=0&limit=3", + headers={"X-API-KEY": user_api_key.key}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + + metadata = response_data["metadata"] + assert metadata["skip"] == 0 + assert metadata["limit"] == 3 + assert ( + metadata["total"] >= 5 + ) # Should include the 5 we created plus any existing ones + + # Test second page + response = client.get( + "/api/v1/openai-conversation?skip=3&limit=3", + headers={"X-API-KEY": user_api_key.key}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + + metadata = response_data["metadata"] + assert metadata["skip"] == 3 + assert metadata["limit"] == 3 + assert metadata["total"] >= 5 + + +def test_list_conversations_default_pagination( + client: TestClient, + db: Session, + user_api_key: APIKeyPublic, +): + """Test conversation listing with default pagination parameters.""" + # Create a conversation + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question="Test question", + response="Test response", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + + create_conversation( + session=db, + conversation=conversation_data, + project_id=user_api_key.project_id, + organization_id=user_api_key.organization_id, + ) + + # Test without pagination parameters (should use defaults) + response = client.get( + "/api/v1/openai-conversation", + headers={"X-API-KEY": user_api_key.key}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + + metadata = response_data["metadata"] + assert metadata["skip"] == 0 # Default skip + assert metadata["limit"] == 100 # Default limit + assert "total" in metadata + assert isinstance(metadata["total"], int) + + +def test_list_conversations_edge_cases( + client: TestClient, + db: Session, + user_api_key: APIKeyPublic, +): + """Test conversation listing edge cases for pagination.""" + # Test with skip larger than total + response = client.get( + "/api/v1/openai-conversation?skip=1000&limit=10", + headers={"X-API-KEY": user_api_key.key}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert len(response_data["data"]) == 0 # Should return empty list + + metadata = response_data["metadata"] + assert metadata["skip"] == 1000 + assert metadata["limit"] == 10 + assert "total" in metadata + + # Test with maximum limit + response = client.get( + "/api/v1/openai-conversation?skip=0&limit=100", + headers={"X-API-KEY": user_api_key.key}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + + metadata = response_data["metadata"] + assert metadata["skip"] == 0 + assert metadata["limit"] == 100 + assert "total" in metadata + def test_list_conversations_invalid_pagination( client: TestClient, diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 8214be6f..385a6bf6 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -1,5 +1,6 @@ import secrets import string +import pytest from sqlmodel import Session from app.crud.openai_conversation import ( @@ -7,6 +8,7 @@ get_conversation_by_response_id, get_conversation_by_ancestor_id, get_conversations_by_project, + get_conversations_count_by_project, create_conversation, delete_conversation, ) @@ -335,3 +337,157 @@ def test_conversation_soft_delete_behavior(db: Session): project_id=project.id, ) assert conversation.id not in [c.id for c in conversations] + + +def test_get_conversations_count_by_project_success(db: Session): + """Test successful conversation count retrieval by project.""" + project = get_project(db) + organization = get_organization(db) + + # Get initial count + initial_count = get_conversations_count_by_project( + session=db, + project_id=project.id, + ) + + # Create multiple conversations + for i in range(3): + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=None, + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) + + # Get updated count + updated_count = get_conversations_count_by_project( + session=db, + project_id=project.id, + ) + + assert updated_count == initial_count + 3 + + +def test_get_conversations_count_by_project_excludes_deleted(db: Session): + """Test that deleted conversations are not counted.""" + project = get_project(db) + organization = get_organization(db) + + # Create a conversation + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=None, + previous_response_id=None, + user_question="Test question", + response="Test response", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project.id, + ) + + conversation = create_conversation( + session=db, + conversation=conversation_data, + project_id=project.id, + organization_id=organization.id, + ) + + # Get count before deletion + count_before = get_conversations_count_by_project( + session=db, + project_id=project.id, + ) + + # Delete the conversation + delete_conversation( + session=db, + conversation_id=conversation.id, + project_id=project.id, + ) + + # Get count after deletion + count_after = get_conversations_count_by_project( + session=db, + project_id=project.id, + ) + + assert count_after == count_before - 1 + + +def test_get_conversations_count_by_project_different_projects(db: Session): + """Test that count is isolated by project.""" + project1 = get_project(db) + organization = get_organization(db) + + # Get another project (assuming there are multiple projects in test data) + project2 = ( + get_project(db, "Dalgo") + if project1.name == "Glific" + else get_project(db, "Glific") + ) + + if not project2 or project2.id == project1.id: + pytest.skip("Need at least 2 different projects for this test") + + # Create conversation in project1 + conversation_data1 = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=None, + previous_response_id=None, + user_question="Test question 1", + response="Test response 1", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project1.id, + ) + + create_conversation( + session=db, + conversation=conversation_data1, + project_id=project1.id, + organization_id=organization.id, + ) + + # Create conversation in project2 + conversation_data2 = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=None, + previous_response_id=None, + user_question="Test question 2", + response="Test response 2", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + project_id=project2.id, + ) + + create_conversation( + session=db, + conversation=conversation_data2, + project_id=project2.id, + organization_id=organization.id, + ) + + # Get counts for both projects + count1 = get_conversations_count_by_project( + session=db, + project_id=project1.id, + ) + + count2 = get_conversations_count_by_project( + session=db, + project_id=project2.id, + ) + + # Both should have at least 1 conversation (the one we just created) + assert count1 >= 1 + assert count2 >= 1 From 7caa8b26caf646e1f1247eb58073c08490947fe4 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 15:33:07 +0530 Subject: [PATCH 17/22] removing assistant validation --- backend/app/models/openai_conversation.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index e3652ad1..6c582872 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -19,17 +19,6 @@ def validate_response_id_pattern(v: str) -> str: return v -def validate_assistant_id_pattern(v: str) -> str: - """Shared validation function for assistant ID patterns""" - if v is None: - return v - if not re.match(r"^asst_[a-zA-Z0-9]{10,}$", v): - raise ValueError( - "assistant_id must follow pattern: asst_ followed by at least 10 alphanumeric characters" - ) - return v - - class OpenAIConversationBase(SQLModel): # usually follow the pattern of resp_688704e41190819db512c30568xxxxxxx response_id: str = Field(index=True, min_length=10) @@ -66,11 +55,6 @@ class OpenAIConversationBase(SQLModel): def validate_response_ids(cls, v): return validate_response_id_pattern(v) - @field_validator("assistant_id") - @classmethod - def validate_assistant_id(cls, v): - return validate_assistant_id_pattern(v) - class OpenAIConversation(OpenAIConversationBase, table=True): __tablename__ = "openai_conversation" @@ -114,11 +98,6 @@ class OpenAIConversationCreate(SQLModel): def validate_response_ids(cls, v): return validate_response_id_pattern(v) - @field_validator("assistant_id") - @classmethod - def validate_assistant_id(cls, v): - return validate_assistant_id_pattern(v) - class OpenAIConversationPublic(OpenAIConversationBase): """Public model for OpenAIConversation without sensitive fields""" From b1f33f3d06149bfaee55c145591923606d137e3c Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 15:34:03 +0530 Subject: [PATCH 18/22] cleanups --- backend/app/tests/utils/conversation.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py index 3a976642..3c308236 100644 --- a/backend/app/tests/utils/conversation.py +++ b/backend/app/tests/utils/conversation.py @@ -1,10 +1,5 @@ import secrets import string -from sqlmodel import Session, select - -from app.models import OpenAIConversation, OpenAIConversationCreate, Project -from app.crud.openai_conversation import create_conversation -from app.tests.utils.utils import get_project, get_organization def generate_openai_id(prefix: str, length: int = 40) -> str: From 0be542b053d712a3dc3ed55eab521cd035920b3d Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 16:35:05 +0530 Subject: [PATCH 19/22] update migration --- .../versions/e9dd35eff62c_add_openai_conversation_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py b/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py index abbaf7b6..9ff047ec 100644 --- a/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py +++ b/backend/app/alembic/versions/e9dd35eff62c_add_openai_conversation_table.py @@ -23,7 +23,7 @@ def upgrade(): sa.Column("id", sa.Integer(), nullable=False), sa.Column("response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column( - "ancestor_response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True + "ancestor_response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False ), sa.Column( "previous_response_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True @@ -31,7 +31,7 @@ def upgrade(): sa.Column("user_question", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("response", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("model", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("assistant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("assistant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("project_id", sa.Integer(), nullable=False), sa.Column("organization_id", sa.Integer(), nullable=False), sa.Column("is_deleted", sa.Boolean(), nullable=False), From bf703eecd04f984a88f1e68a6212d0f041020453 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 16:44:35 +0530 Subject: [PATCH 20/22] moving to openai util --- .../tests/api/routes/test_openai_conversation.py | 10 +--------- backend/app/tests/crud/test_openai_conversation.py | 10 +--------- backend/app/tests/utils/conversation.py | 10 ---------- backend/app/tests/utils/openai.py | 13 ++++++++++++- 4 files changed, 14 insertions(+), 29 deletions(-) delete mode 100644 backend/app/tests/utils/conversation.py diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 0971d069..f7fe5ffc 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -1,19 +1,11 @@ import pytest -import secrets -import string from sqlmodel import Session from fastapi.testclient import TestClient from app.models import APIKeyPublic from app.crud.openai_conversation import create_conversation from app.models import OpenAIConversationCreate - - -def generate_openai_id(prefix: str, length: int = 40) -> str: - """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" - chars = string.ascii_lowercase + string.digits - random_part = "".join(secrets.choice(chars) for _ in range(length)) - return f"{prefix}{random_part}" +from app.tests.utils.openai import generate_openai_id def test_get_conversation_success( diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index 385a6bf6..d62ba68b 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -1,5 +1,3 @@ -import secrets -import string import pytest from sqlmodel import Session @@ -14,13 +12,7 @@ ) from app.models import OpenAIConversationCreate from app.tests.utils.utils import get_project, get_organization - - -def generate_openai_id(prefix: str, length: int = 40) -> str: - """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" - chars = string.ascii_lowercase + string.digits - random_part = "".join(secrets.choice(chars) for _ in range(length)) - return f"{prefix}{random_part}" +from app.tests.utils.openai import generate_openai_id def test_get_conversation_by_id_success(db: Session): diff --git a/backend/app/tests/utils/conversation.py b/backend/app/tests/utils/conversation.py deleted file mode 100644 index 3c308236..00000000 --- a/backend/app/tests/utils/conversation.py +++ /dev/null @@ -1,10 +0,0 @@ -import secrets -import string - - -def generate_openai_id(prefix: str, length: int = 40) -> str: - """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" - # Generate random alphanumeric string - chars = string.ascii_lowercase + string.digits - random_part = "".join(secrets.choice(chars) for _ in range(length)) - return f"{prefix}{random_part}" diff --git a/backend/app/tests/utils/openai.py b/backend/app/tests/utils/openai.py index 6f11bbf5..a864ee33 100644 --- a/backend/app/tests/utils/openai.py +++ b/backend/app/tests/utils/openai.py @@ -1,5 +1,8 @@ -from typing import Optional import time +import secrets +import string + +from typing import Optional from unittest.mock import MagicMock from openai.types.beta import Assistant as OpenAIAssistant @@ -8,6 +11,14 @@ from openai.types.beta.file_search_tool import FileSearch +def generate_openai_id(prefix: str, length: int = 40) -> str: + """Generate a realistic ID similar to OpenAI's format (alphanumeric only)""" + # Generate random alphanumeric string + chars = string.ascii_lowercase + string.digits + random_part = "".join(secrets.choice(chars) for _ in range(length)) + return f"{prefix}{random_part}" + + def mock_openai_assistant( assistant_id: str = "assistant_mock", vector_store_ids: Optional[list[str]] = ["vs_1", "vs_2"], From 56a92f7c503585634b8381b25be3e6d3abe62057 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 19:51:06 +0530 Subject: [PATCH 21/22] updating testcases --- backend/app/models/openai_conversation.py | 5 +- .../api/routes/test_openai_conversation.py | 63 ++++++++ .../tests/crud/test_openai_conversation.py | 146 +++++++++++------- 3 files changed, 154 insertions(+), 60 deletions(-) diff --git a/backend/app/models/openai_conversation.py b/backend/app/models/openai_conversation.py index 6c582872..93b1106a 100644 --- a/backend/app/models/openai_conversation.py +++ b/backend/app/models/openai_conversation.py @@ -23,7 +23,6 @@ class OpenAIConversationBase(SQLModel): # usually follow the pattern of resp_688704e41190819db512c30568xxxxxxx response_id: str = Field(index=True, min_length=10) ancestor_response_id: str = Field( - default=None, index=True, description="Ancestor response ID for conversation threading", ) @@ -73,8 +72,8 @@ class OpenAIConversation(OpenAIConversationBase, table=True): class OpenAIConversationCreate(SQLModel): # usually follow the pattern of resp_688704e41190819db512c30568dcaebc0a42e02be2c2c49b response_id: str = Field(min_length=10) - ancestor_response_id: Optional[str] = Field( - default=None, description="Ancestor response ID for conversation threading" + ancestor_response_id: str = Field( + description="Ancestor response ID for conversation threading" ) previous_response_id: Optional[str] = Field( default=None, description="Previous response ID in the conversation" diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index f7fe5ffc..3680f6c8 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -467,3 +467,66 @@ def test_delete_conversation_not_found( assert response.status_code == 404 response_data = response.json() assert "not found" in response_data["error"] + + +def test_get_conversation_unauthorized_no_api_key( + client: TestClient, + db: Session, +): + """Test conversation retrieval without API key.""" + response = client.get("/api/v1/openai-conversation/1") + assert response.status_code == 401 + + +def test_get_conversation_unauthorized_invalid_api_key( + client: TestClient, + db: Session, +): + """Test conversation retrieval with invalid API key.""" + response = client.get( + "/api/v1/openai-conversation/1", + headers={"X-API-KEY": "invalid_api_key"}, + ) + assert response.status_code == 401 + + +def test_list_conversations_unauthorized_no_api_key( + client: TestClient, + db: Session, +): + """Test conversation listing without API key.""" + response = client.get("/api/v1/openai-conversation") + assert response.status_code == 401 + + +def test_list_conversations_unauthorized_invalid_api_key( + client: TestClient, + db: Session, +): + """Test conversation listing with invalid API key.""" + response = client.get( + "/api/v1/openai-conversation", + headers={"X-API-KEY": "invalid_api_key"}, + ) + assert response.status_code == 401 + + +def test_delete_conversation_unauthorized_no_api_key( + client: TestClient, + db: Session, +): + """Test conversation deletion without API key.""" + response = client.delete("/api/v1/openai-conversation/1") + assert response.status_code == 401 + + +def test_delete_conversation_unauthorized_invalid_api_key( + client: TestClient, + db: Session, +): + """Test conversation deletion with invalid API key.""" + response = client.delete( + "/api/v1/openai-conversation/1", + headers={"X-API-KEY": "invalid_api_key"}, + ) + assert response.status_code == 401 diff --git a/backend/app/tests/crud/test_openai_conversation.py b/backend/app/tests/crud/test_openai_conversation.py index d62ba68b..cfb8e092 100644 --- a/backend/app/tests/crud/test_openai_conversation.py +++ b/backend/app/tests/crud/test_openai_conversation.py @@ -28,7 +28,6 @@ def test_get_conversation_by_id_success(db: Session): response="The capital of Japan is Tokyo.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) # Create the conversation in the database @@ -76,7 +75,6 @@ def test_get_conversation_by_response_id_success(db: Session): response="The capital of Japan is Tokyo.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) # Create the conversation in the database @@ -126,7 +124,6 @@ def test_get_conversation_by_ancestor_id_success(db: Session): response="The capital of France is Paris.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) conversation = create_conversation( @@ -169,13 +166,12 @@ def test_get_conversations_by_project_success(db: Session): for i in range(3): conversation_data = OpenAIConversationCreate( response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, + ancestor_response_id=generate_openai_id("resp_", 40), previous_response_id=None, user_question=f"Test question {i}", response=f"Test response {i}", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) create_conversation( session=db, @@ -204,13 +200,12 @@ def test_get_conversations_by_project_with_pagination(db: Session): for i in range(5): conversation_data = OpenAIConversationCreate( response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, + ancestor_response_id=generate_openai_id("resp_", 40), previous_response_id=None, user_question=f"Test question {i}", response=f"Test response {i}", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) create_conversation( session=db, @@ -242,7 +237,6 @@ def test_delete_conversation_success(db: Session): response="The capital of Japan is Tokyo.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) # Create the conversation in the database first @@ -291,7 +285,6 @@ def test_conversation_soft_delete_behavior(db: Session): response="The capital of Japan is Tokyo.", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) # Create the conversation in the database first @@ -346,13 +339,12 @@ def test_get_conversations_count_by_project_success(db: Session): for i in range(3): conversation_data = OpenAIConversationCreate( response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, + ancestor_response_id=generate_openai_id("resp_", 40), previous_response_id=None, user_question=f"Test question {i}", response=f"Test response {i}", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) create_conversation( session=db, @@ -378,13 +370,12 @@ def test_get_conversations_count_by_project_excludes_deleted(db: Session): # Create a conversation conversation_data = OpenAIConversationCreate( response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, + ancestor_response_id=generate_openai_id("resp_", 40), previous_response_id=None, user_question="Test question", response="Test response", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project.id, ) conversation = create_conversation( @@ -424,62 +415,103 @@ def test_get_conversations_count_by_project_different_projects(db: Session): # Get another project (assuming there are multiple projects in test data) project2 = ( get_project(db, "Dalgo") - if project1.name == "Glific" - else get_project(db, "Glific") + if get_project(db, "Dalgo") is not None + else get_project(db) ) - if not project2 or project2.id == project1.id: - pytest.skip("Need at least 2 different projects for this test") + # Create conversations in project1 + for i in range(2): + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + create_conversation( + session=db, + conversation=conversation_data, + project_id=project1.id, + organization_id=organization.id, + ) - # Create conversation in project1 - conversation_data1 = OpenAIConversationCreate( - response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, - previous_response_id=None, - user_question="Test question 1", - response="Test response 1", - model="gpt-4o", - assistant_id=generate_openai_id("asst_", 20), - project_id=project1.id, - ) + # Create conversations in project2 + for i in range(3): + conversation_data = OpenAIConversationCreate( + response_id=generate_openai_id("resp_", 40), + ancestor_response_id=generate_openai_id("resp_", 40), + previous_response_id=None, + user_question=f"Test question {i}", + response=f"Test response {i}", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) + create_conversation( + session=db, + conversation=conversation_data, + project_id=project2.id, + organization_id=organization.id, + ) - create_conversation( - session=db, - conversation=conversation_data1, - project_id=project1.id, - organization_id=organization.id, - ) + # Check counts are isolated + count1 = get_conversations_count_by_project(session=db, project_id=project1.id) + count2 = get_conversations_count_by_project(session=db, project_id=project2.id) - # Create conversation in project2 - conversation_data2 = OpenAIConversationCreate( - response_id=generate_openai_id("resp_", 40), - ancestor_response_id=None, + assert count1 >= 2 + assert count2 >= 3 + + +def test_response_id_validation_pattern(db: Session): + """Test that response ID validation pattern is enforced.""" + project = get_project(db) + organization = get_organization(db) + + # Test valid response ID + valid_response_id = "resp_1234567890abcdef" + conversation_data = OpenAIConversationCreate( + response_id=valid_response_id, + ancestor_response_id="resp_abcdef1234567890", previous_response_id=None, - user_question="Test question 2", - response="Test response 2", + user_question="Test question", + response="Test response", model="gpt-4o", assistant_id=generate_openai_id("asst_", 20), - project_id=project2.id, ) - create_conversation( + # This should work + conversation = create_conversation( session=db, - conversation=conversation_data2, - project_id=project2.id, + conversation=conversation_data, + project_id=project.id, organization_id=organization.id, ) + assert conversation is not None + assert conversation.response_id == valid_response_id - # Get counts for both projects - count1 = get_conversations_count_by_project( - session=db, - project_id=project1.id, - ) - - count2 = get_conversations_count_by_project( - session=db, - project_id=project2.id, - ) + # Test invalid response ID (too short) + invalid_response_id = "resp_123" + with pytest.raises(ValueError, match="String should have at least 10 characters"): + OpenAIConversationCreate( + response_id=invalid_response_id, + ancestor_response_id="resp_abcdef1234567890", + previous_response_id=None, + user_question="Test question", + response="Test response", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) - # Both should have at least 1 conversation (the one we just created) - assert count1 >= 1 - assert count2 >= 1 + # Test invalid response ID (wrong prefix but long enough) + invalid_response_id2 = "msg_1234567890abcdef" + with pytest.raises(ValueError, match="response_id fields must follow pattern"): + OpenAIConversationCreate( + response_id=invalid_response_id2, + ancestor_response_id="resp_abcdef1234567890", + previous_response_id=None, + user_question="Test question", + response="Test response", + model="gpt-4o", + assistant_id=generate_openai_id("asst_", 20), + ) From 36b05ac8b4ce19e45987e069ba00ece7293d8843 Mon Sep 17 00:00:00 2001 From: Akhilesh Negi Date: Mon, 28 Jul 2025 19:58:43 +0530 Subject: [PATCH 22/22] cleanups --- backend/app/tests/api/routes/test_openai_conversation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/app/tests/api/routes/test_openai_conversation.py b/backend/app/tests/api/routes/test_openai_conversation.py index 3680f6c8..55d309ed 100644 --- a/backend/app/tests/api/routes/test_openai_conversation.py +++ b/backend/app/tests/api/routes/test_openai_conversation.py @@ -1,4 +1,3 @@ -import pytest from sqlmodel import Session from fastapi.testclient import TestClient @@ -361,7 +360,6 @@ def test_list_conversations_default_pagination( def test_list_conversations_edge_cases( client: TestClient, - db: Session, user_api_key: APIKeyPublic, ): """Test conversation listing edge cases for pagination."""