Commit deb050c8 authored by ZeinabRm13's avatar ZeinabRm13

Runable

parent 8f656a91
Metadata-Version: 2.4
Name: ChartAnalyzer
Version: 0.1.0
Summary: FastAPI project with SQLAlchemy, Auth, and PostgreSQL
Author-email: Zeinab Rostom <zeinab.rostom@hiast.edu.sy>
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: fastapi
Requires-Dist: uvicorn[standard]
Requires-Dist: sqlalchemy
Requires-Dist: pydantic_settings
Requires-Dist: alembic
Requires-Dist: psycopg2-binary
Requires-Dist: asyncpg
Requires-Dist: passlib[bcrypt]
Requires-Dist: python-jose[cryptography]
Requires-Dist: python-multipart
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: httpx; extra == "dev"
Requires-Dist: sqlalchemy2-stubs; extra == "dev"
Requires-Dist: black; extra == "dev"
pyproject.toml
ChartAnalyzer.egg-info/PKG-INFO
ChartAnalyzer.egg-info/SOURCES.txt
ChartAnalyzer.egg-info/dependency_links.txt
ChartAnalyzer.egg-info/requires.txt
ChartAnalyzer.egg-info/top_level.txt
alembic/env.py
alembic/versions/cdffa422e278_add_conversation_tables_with_multiple_.py
frontend/node_modules/flatted/python/flatted.py
src/__init__.py
src/config.py
src/dependencies.py
src/app/__init__.py
src/app/main.py
src/app/routes/__init__.py
src/app/routes/auth.py
src/app/routes/charts.py
src/app/routes/conversations.py
src/app/routes/uploads.py
src/application/__init__.py
src/application/dtos/LLM.py
src/application/dtos/__init__.py
src/application/dtos/analysis.py
src/application/dtos/authentication.py
src/application/dtos/conversation.py
src/application/dtos/error.py
src/application/ports/__init__.py
src/application/ports/analyze_service.py
src/application/ports/authentication_service_port.py
src/application/ports/conversation_service.py
src/application/ports/file_storage_service.py
src/application/ports/llm_service_port.py
src/application/services/__init__.py
src/application/services/analyze_service.py
src/application/services/authentication_service.py
src/application/services/conversation_service.py
src/application/services/llm_service.py
src/application/use_cases/__init__.py
src/application/use_cases/analyze_chart.py
src/application/use_cases/chart_srvice.py
src/application/use_cases/chat_conversation.py
src/application/use_cases/continue_convo.py
src/application/use_cases/general_analysis.py
src/application/use_cases/get_analysis_history.py
src/application/use_cases/save_analysis.py
src/application/use_cases/specific_question.py
src/application/use_cases/upload_chart.py
src/domain/__init__.py
src/domain/entities/__init__.py
src/domain/entities/chart_analysis.py
src/domain/entities/chart_image.py
src/domain/entities/conversation.py
src/domain/entities/user.py
src/domain/exceptions/__init__.py
src/domain/exceptions/domain_errors.py
src/domain/ports/__init__.py
src/domain/ports/repositories/analysis_repository.py
src/domain/ports/repositories/charts_repository.py
src/domain/ports/repositories/conversation_repository.py
src/domain/ports/repositories/token_repository.py
src/domain/ports/repositories/user_repository.py
src/domain/services/__init__.py
src/domain/services/chart_validation.py
src/domain/value_objects/__init__.py
src/domain/value_objects/trend_summary.py
src/infrastructure/__init__.py
src/infrastructure/adapters/__init__.py
src/infrastructure/adapters/model_adapter.py
src/infrastructure/adapters/mongodb_image_storage.py
src/infrastructure/adapters/auth/jwt_token_generator.py
src/infrastructure/adapters/sqlserver/__init__.py
src/infrastructure/adapters/sqlserver/mappers/mappers.py
src/infrastructure/persistence/database.py
src/infrastructure/persistence/models/__init__.py
src/infrastructure/persistence/models/analysis_model.py
src/infrastructure/persistence/models/base.py
src/infrastructure/persistence/models/blacklisted_token_model.py
src/infrastructure/persistence/models/chart_model.py
src/infrastructure/persistence/models/conversation_models.py
src/infrastructure/persistence/models/user_model.py
src/infrastructure/persistence/repositories/sql_analysis_repository.py
src/infrastructure/persistence/repositories/sql_charts_repository.py
src/infrastructure/persistence/repositories/sql_conversation_repository.py
src/infrastructure/persistence/repositories/sql_token_repository.py
src/infrastructure/persistence/repositories/sql_user_repository.py
src/infrastructure/services/chartGemma_service.py
src/infrastructure/services/file_storage_service.py
src/infrastructure/services/ollama_service.py
\ No newline at end of file
fastapi
uvicorn[standard]
sqlalchemy
pydantic_settings
alembic
psycopg2-binary
asyncpg
passlib[bcrypt]
python-jose[cryptography]
python-multipart
[dev]
pytest
httpx
sqlalchemy2-stubs
black
alembic
frontend
src
storage
tests
......@@ -14,7 +14,16 @@ import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Import your settings and models
from src.config import settings
from src.app.models.chart import Chart
from src.infrastructure.persistence.models.base import Base
from src.infrastructure.persistence.models.chart_model import ChartImageModel
from src.infrastructure.persistence.models.conversation_models import (
ConversationModel,
ConversationMessageModel,
ConversationChartReferenceModel
)
from src.infrastructure.persistence.models.user_model import UserModel
from src.infrastructure.persistence.models.analysis_model import ChartAnalysisModel
from src.infrastructure.persistence.models.blacklisted_token_model import BlacklistedTokenModel
# ------------------------------
# Configure Alembic
......
"""Add conversation tables with multiple chart support
Revision ID: cdffa422e278
Revises:
Create Date: 2025-07-31 09:48:24.747741
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cdffa422e278'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('blacklisted_tokens',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('token', sa.String(length=512), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
op.create_table('users',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('chart_images',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('file_path', sa.String(length=512), nullable=True),
sa.Column('image_data', sa.LargeBinary(), nullable=True),
sa.Column('thumbnail_path', sa.String(length=512), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('conversations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('chart_analysis',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('chart_image_id', sa.String(length=36), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('answer', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['chart_image_id'], ['chart_images.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('conversation_chart_references',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False),
sa.Column('chart_image_id', sa.String(length=36), nullable=False),
sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['chart_image_id'], ['chart_images.id'], ),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('conversation_messages',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('conversation_id', sa.String(length=36), nullable=False),
sa.Column('sender_id', sa.String(length=36), nullable=False),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('chart_image_id', sa.String(length=36), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('conversation_messages')
op.drop_table('conversation_chart_references')
op.drop_table('chart_analysis')
op.drop_table('conversations')
op.drop_table('chart_images')
op.drop_table('users')
op.drop_table('blacklisted_tokens')
# ### end Alembic commands ###
[build-system]
requires = ["setuptools>=65.0.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages]
find = {}
[project]
name = "ChartAnalyzer"
version = "0.1.0"
description = "FastAPI project with SQLAlchemy, Auth, and PostgreSQL"
authors = [{ name = "Zeinab Rostom", email = "zeinab.rostom@hiast.edu.sy" }]
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
# Web Framework
"fastapi",
"uvicorn[standard]",
# Database and ORM
"sqlalchemy",
"pydantic_settings",
"alembic",
# PostgreSQL Drivers
"psycopg2-binary",
"asyncpg",
# Authentication
"passlib[bcrypt]",
"python-jose[cryptography]",
# Other
"python-multipart"
]
[project.optional-dependencies]
dev = [
"pytest",
"httpx",
"sqlalchemy2-stubs", # For type hints
"black", # Code formatting
]
\ No newline at end of file
from .config import Settings
\ No newline at end of file
from fastapi import FastAPI
from api.fastapi.routes import auth, charts
from .routes import auth, charts, conversations
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError
......@@ -35,9 +35,6 @@ app.add_middleware(
# Include routers
app.include_router(auth.router, prefix="/auth")
app.include_router(charts.router, prefix="/charts")
# Import and include conversation router
from src.infrastructure.api.fastapi.routes import conversations
app.include_router(conversations.router, prefix="/conversations")
@app.get("/")
......
......@@ -26,7 +26,7 @@ async def register(
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
user = await auth_service.register(request.email, request.password)
user = await auth_service.register(request)
return {"id": user.id, "email": user.email}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
......@@ -37,7 +37,7 @@ async def login(
auth_service: AuthServicePort = Depends(get_auth_service)
):
try:
token = await auth_service.login(request.email, request.password)
return {"access_token": token, "token_type": "bearer"}
token = await auth_service.login(request)
return {"access_token": token.access_token, "token_type": "bearer"}
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
\ No newline at end of file
......@@ -3,10 +3,11 @@ from src.application.dtos.conversation import (
CreateConversationRequestDTO,
SendMessageRequestDTO,
ConversationResponseDTO,
ConversationDetailResponseDTO
ConversationDetailResponseDTO,
MessageResponseDTO
)
from src.application.services.chat_service import ChatService
from src.dependencies import get_current_user, get_chat_service
from src.application.services.conversation_service import ConversationService
from src.dependencies import get_current_user, get_conversation_service
from typing import List
import logging
......@@ -17,7 +18,7 @@ router = APIRouter(tags=["conversations"])
async def create_conversation(
request: CreateConversationRequestDTO,
current_user: dict = Depends(get_current_user),
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""Create a new conversation for a chart"""
try:
......@@ -32,7 +33,7 @@ async def create_conversation(
async def list_conversations(
current_user: dict = Depends(get_current_user),
limit: int = 20,
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""List user's conversations"""
try:
......@@ -45,7 +46,7 @@ async def list_conversations(
async def get_conversation(
conversation_id: str,
current_user: dict = Depends(get_current_user),
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""Get conversation details with messages"""
try:
......@@ -61,7 +62,7 @@ async def send_message(
conversation_id: str,
request: SendMessageRequestDTO,
current_user: dict = Depends(get_current_user),
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""Send a message in a conversation"""
try:
......@@ -76,7 +77,7 @@ async def send_message(
async def delete_conversation(
conversation_id: str,
current_user: dict = Depends(get_current_user),
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""Delete a conversation (soft delete)"""
try:
......@@ -94,7 +95,8 @@ async def delete_conversation(
@router.get("/statistics/summary")
async def get_conversation_statistics(
current_user: dict = Depends(get_current_user),
chat_service: ChatService = Depends(get_chat_service)
chat_service: ConversationService = Depends(get_conversation_service)
):
"""Get conversation statistics for the current user"""
try:
......
......@@ -2,12 +2,12 @@ from pydantic import BaseModel
from typing import List, Optional
class CreateConversationRequestDTO(BaseModel):
chart_image_id: str
title: str
initial_chart_image_id: Optional[str] = None # Optional initial chart
class SendMessageRequestDTO(BaseModel):
conversation_id: str
message: str
chart_image_id: Optional[str] = None # Optional chart to include with message
class ConversationResponseDTO(BaseModel):
id: str
......@@ -15,12 +15,18 @@ class ConversationResponseDTO(BaseModel):
created_at: str
updated_at: str
message_count: int
chart_count: int
class MessageContentDTO(BaseModel):
text: Optional[str] = None
chart_image_id: Optional[str] = None
class MessageResponseDTO(BaseModel):
id: str
message_type: str
content: str
content: MessageContentDTO
timestamp: str
sender_id: str
class ConversationDetailResponseDTO(BaseModel):
id: str
......@@ -28,3 +34,4 @@ class ConversationDetailResponseDTO(BaseModel):
created_at: str
updated_at: str
messages: List[MessageResponseDTO]
referenced_charts: List[str]
\ No newline at end of file
......@@ -39,8 +39,5 @@ class AnalyzeService(AnalysisServicePort):
def _build_prompt(self, image_data: bytes, question: str) -> str:
"""Construct the prompt for the LLM"""
return (
f"You are a data analysis expert. Analyze this chart and "
f"answer the following question: {question}\n"
f"Chart data: {image_data}"
)
\ No newline at end of file
return LLMRequestDTO(image_bytes = image_data , question = f"You are a data analysis expert. Analyze this chart and answer the following question: {question}\n")
\ No newline at end of file
from src.domain.entities.chart_analysis import ChartAnalysis
from src.domain.ports.repositories.analysis_repository import AnalysisRepositoryPort
from src.domain.entities.conversation import Conversation, ConversationMessage, MessageContent, MessageType
from src.domain.ports.repositories.conversation_repository import ConversationRepositoryPort
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
from src.application.ports.llm_service_port import LLMServicePort
from src.application.dtos.LLM import LLMRequestDTO
from src.application.dtos.conversation import SendMessageRequestDTO, MessageResponseDTO, MessageContentDTO
import uuid
from datetime import datetime, timezone
from typing import List
from typing import List, Optional
class ChatConversationUseCase:
def __init__(
self,
analysis_repo: AnalysisRepositoryPort,
conversation_repo: ConversationRepositoryPort,
charts_repo: ChartsRepositoryPort,
llm_service: LLMServicePort
):
self._analysis_repo = analysis_repo
self._conversation_repo = conversation_repo
self._charts_repo = charts_repo
self._llm_service = llm_service
def execute(self, chart_image_id: str, conversation_history: List[str], new_question: str) -> ChartAnalysis:
"""Handle interactive chat conversation about a chart"""
async def execute(
self,
conversation_id: str,
user_id: str,
request: SendMessageRequestDTO
) -> MessageResponseDTO:
"""Handle interactive chat conversation with support for multiple charts"""
# Get conversation
conversation = await self._conversation_repo.get_conversation_by_id(conversation_id)
if not conversation:
raise ValueError("Conversation not found")
if conversation.user_id != user_id:
raise ValueError("Conversation does not belong to user")
# If chart is included in message, verify it exists and belongs to user
if request.chart_image_id:
chart_image = await self._charts_repo.get_by_id(request.chart_image_id)
if not chart_image:
raise ValueError("Chart image not found")
if chart_image.user_id != user_id:
raise ValueError("Chart image does not belong to user")
# Add to referenced charts if not already there
if request.chart_image_id not in conversation.referenced_charts:
conversation.referenced_charts.append(request.chart_image_id)
# Create user message content
user_content = MessageContent(
text=request.message,
chart_image_id=request.chart_image_id
)
# Create user message
user_message = ConversationMessage(
id=str(uuid.uuid4()),
conversation_id=conversation_id,
sender_id=user_id,
message_type=MessageType.TEXT,
content=user_content,
timestamp=datetime.now(timezone.utc)
)
# Add user message to conversation
conversation.messages.append(user_message)
conversation.updated_at = datetime.now(timezone.utc)
# Get AI response if there's text in the message
assistant_message = None
if request.message.strip():
# Build context from conversation history
context = self._build_conversation_context(conversation_history)
context = self._build_conversation_context(conversation.messages[:-1]) # Exclude current message
# Create enhanced question with context
enhanced_question = f"Context from previous conversation: {context}\n\nNew question: {new_question}"
enhanced_question = self._create_enhanced_question(request.message, context)
# Create request for LLM
request = LLMRequestDTO(
image_bytes=b"", # Will be loaded by service
# Get the most recent chart for analysis (or the one in current message)
chart_to_analyze = request.chart_image_id
if not chart_to_analyze and conversation.referenced_charts:
chart_to_analyze = conversation.referenced_charts[-1] # Most recent chart
if chart_to_analyze:
chart_image = await self._charts_repo.get_by_id(chart_to_analyze)
if chart_image:
# Get image bytes (handles both file path and direct data)
image_bytes = chart_image.get_image_bytes()
# Get AI response using LLM service
llm_request = LLMRequestDTO(
image_bytes=image_bytes,
question=enhanced_question
)
# Get response from LLM
response = await self._llm_service.analyze(request)
llm_response = await self._llm_service.analyze(llm_request)
# Save the conversation turn
analysis = ChartAnalysis(
# Create assistant message
assistant_content = MessageContent.create_text(llm_response.answer)
assistant_message = ConversationMessage(
id=str(uuid.uuid4()),
chart_image_id=chart_image_id,
question=new_question,
answer=response.answer,
created_at=datetime.now(timezone.utc)
conversation_id=conversation_id,
sender_id="system", # AI assistant
message_type=MessageType.TEXT,
content=assistant_content,
timestamp=datetime.now(timezone.utc)
)
self._analysis_repo.save_analysis(analysis)
return analysis
# Add assistant message to conversation
conversation.messages.append(assistant_message)
conversation.updated_at = datetime.now(timezone.utc)
# Save updated conversation
await self._conversation_repo.update_conversation(conversation)
# Return the assistant message if it exists, otherwise return user message
message_to_return = assistant_message if assistant_message else user_message
def _build_conversation_context(self, history: List[str]) -> str:
content_dto = MessageContentDTO(
text=message_to_return.content.text,
chart_image_id=message_to_return.content.chart_image_id
)
return MessageResponseDTO(
id=message_to_return.id,
message_type=message_to_return.message_type,
content=content_dto,
timestamp=message_to_return.timestamp.isoformat(),
sender_id=message_to_return.sender_id
)
def _build_conversation_context(self, messages: List[ConversationMessage]) -> str:
"""Build context string from conversation history"""
if not history:
if not messages:
return ""
# Take last 5 messages for context to avoid token limits
recent_messages = messages[-5:]
context_parts = []
for i, entry in enumerate(history[-5:], 1): # Last 5 entries for context
context_parts.append(f"Turn {i}: {entry}")
for i, msg in enumerate(recent_messages, 1):
role = "User" if msg.sender_id != "system" else "Assistant"
content_text = msg.content.text or ""
if msg.content.chart_image_id:
content_text += f" [Chart: {msg.content.chart_image_id}]"
context_parts.append(f"Turn {i} - {role}: {content_text}")
return " | ".join(context_parts)
def _create_enhanced_question(self, current_question: str, context: str) -> str:
"""Create enhanced question with conversation context"""
if not context:
return current_question
return f"""Previous conversation context: {context}
Current question: {current_question}
Please provide a response that takes into account the conversation history and directly addresses the current question about the chart."""
\ No newline at end of file
from fastapi import Depends, Query
from typing import AsyncGenerator
from src.domain.entities.user import User
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from src.application.services.authentication_service import AuthService
from src.application.ports.authentication_service_port import AuthServicePort
from src.application.ports.file_storage_service import FileStoragePort
from src.application.use_cases.upload_chart import UploadChartUseCase
from src.domain.ports.repositories.user_repository import UserRepositoryPort
from src.domain.ports.repositories.token_repository import TokenRepositoryPort
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
from src.domain.ports.repositories.conversation_repository import ConversationRepositoryPort
from src.infrastructure.adapters.sqlserver.sql_user_repository import SqlUserRepository
from src.infrastructure.adapters.sqlserver.sql_charts_repository import SqlChartsRepository
from src.infrastructure.adapters.sqlserver.sql_conversation_repository import SqlConversationRepository
from src.infrastructure.persistence.repositories.sql_user_repository import SqlUserRepository
from src.infrastructure.persistence.repositories.sql_charts_repository import SqlChartsRepository
from src.infrastructure.persistence.repositories.sql_conversation_repository import SqlConversationRepository
from src.application.services.analyze_service import AnalyzeService
from src.application.services.ollama_service import OllamaService
from src.application.services.chartGemma_service import ChartGemmaService
from src.application.services.chat_service import ChatService
from src.infrastructure.services.ollama_service import OllamaService
from src.infrastructure.services.chartGemma_service import ChartGemmaService
from src.application.services.conversation_service import ConversationService
from src.application.ports.llm_service_port import LLMServicePort
from src.infrastructure.adapters.sqlserver.sql_token_repository import SqlTokenRepository
from src.infrastructure.persistence.repositories.sql_token_repository import SqlTokenRepository
from src.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True)
......@@ -82,11 +86,49 @@ def get_llm_service_by_query(
"""
return get_llm_service(model)
def get_chat_service(
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
async def get_current_user(
token: str = Depends(oauth2_scheme),
auth_service: AuthServicePort = Depends(get_auth_service)
) -> User:
"""Dependency to get current authenticated user"""
try:
user = await auth_service.get_current_user(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return user
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def get_file_storage_service() -> FileStoragePort:
"""Factory for file storage service (switches between local and cloud)"""
if settings.USE_CLOUD_STORAGE:
return CloudFileStorageService(
bucket_name=settings.STORAGE_BUCKET,
credentials_path=settings.GOOGLE_APPLICATION_CREDENTIALS
)
return LocalFileStorageService(storage_root=settings.STORAGE_ROOT)
def get_conversation_service(
conversation_repo: ConversationRepositoryPort = Depends(get_conversation_repository),
charts_repo: ChartsRepositoryPort = Depends(get_charts_repository),
file_storage: FileStoragePort = Depends(get_file_storage_service),
llm_service: LLMServicePort = Depends(get_llm_service)
) -> ChatService:
"""Factory for the chat service"""
return ChatService(conversation_repo, charts_repo, llm_service)
) -> ConversationService:
"""Factory for conversation service with all dependencies"""
return ConversationService(
conversation_repo=conversation_repo,
chart_repo=charts_repo,
file_storage=file_storage,
llm_service=llm_service
)
from pydantic import BaseModel
from datetime import datetime, timezone
from typing import Optional
class ChartImage(BaseModel):
id: str # UUID
user_id: str # Owner of the chart
image_data: bytes # Or file path if storing externally
file_path: Optional[str] = None # File path for filesystem storage (registered users)
image_data: Optional[bytes] = None # Direct image data for one-time analysis
#thumbnail_path: Optional[str] = None # Optional thumbnail path
uploaded_at: datetime = datetime.now(timezone.utc)
def get_image_bytes(self) -> bytes:
"""Get image bytes either from file or direct data"""
if self.image_data:
return self.image_data
elif self.file_path:
with open(self.file_path, 'rb') as f:
return f.read()
else:
raise ValueError("No image data available")
\ No newline at end of file
from enum import Enum
from pydantic import BaseModel
from datetime import datetime, timezone
from typing import List, Optional
from uuid import UUID, uuid4
from datetime import datetime
from typing import List, Optional, Union
class MessageType(str, Enum):
TEXT = "text"
CHART = "chart"
SYSTEM = "system"
class MessageContent(BaseModel):
text: Optional[str] = None
chart_image_id: Optional[str] = None # Reference to chart image ID
@classmethod
def create_text(cls, text: str):
return cls(text=text)
@classmethod
def create_chart(cls, chart_image_id: str):
return cls(chart_image_id=chart_image_id)
class ConversationMessage(BaseModel):
id: str
id: str = str(uuid4())
conversation_id: str
user_id: str
message_type: str # "user" or "assistant"
content: str
timestamp: datetime = datetime.now(timezone.utc)
sender_id: str # user_id or system
message_type: MessageType
content: MessageContent
timestamp: datetime = datetime.utcnow()
metadata: dict = {} # For additional data like AI analysis results
class Conversation(BaseModel):
id: str
id: str = str(uuid4())
user_id: str
chart_image_id: str
title: str
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
created_at: datetime = datetime.utcnow()
updated_at: datetime = datetime.utcnow()
is_active: bool = True
messages: List[ConversationMessage] = []
referenced_charts: List[str] = [] # All chart image IDs used in this conversation
\ No newline at end of file
from .sql_analysis_repository import SqlAnalysisRepository
from .sql_charts_repository import SqlChartsRepository
from .models import Base
\ No newline at end of file
# src/infrastructure/adapters/sqlserver/mappers/mappers.py
from src.domain.entities.user import User as UserEntity
from src.infrastructure.adapters.sqlserver.models import User as UserModel
from src.infrastructure.persistence.models import UserModel
def user_model_to_entity(model: UserModel) -> UserEntity:
return UserEntity(
......
from sqlalchemy.sql import func
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, String, Text, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid
class ChartAnalysisModel(Base, TimestampMixin):
__tablename__ = 'chart_analysis'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
chart_image_id = Column(UUID(as_uuid=True), ForeignKey('chart_images.id'), nullable=False)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
chart_image_id = Column(String(36), ForeignKey('chart_images.id'), nullable=False)
question = Column(Text, nullable=False)
answer = Column(Text, nullable=False)
metadata = Column(JSONB) # Added for additional analysis data
#metadata = Column(JSON) # Added for additional analysis data
# Relationship
chart_image = relationship("ChartImageModel", back_populates="analysis")
from sqlalchemy import Column, String, Boolean, DateTime, LargeBinary, ForeignKey, Text
from sqlalchemy import Column, String, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from src.infrastructure.persistence.models.base import Base
import uuid
class BlacklistedTokenModel(Base):
__tablename__ = 'blacklisted_tokens'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
token = Column(String(512), unique=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# src/infrastructure/database/models.py
from sqlalchemy import Column, String, Boolean, DateTime, LargeBinary, ForeignKey, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, String, ForeignKey, LargeBinary
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid
class ChartImageModel(Base, TimestampMixin):
__tablename__ = 'chart_images'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
file_path = Column(String(512), nullable=False) # Changed from image_data to file_path
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey('users.id'), nullable=False)
file_path = Column(String(512), nullable=True) # File path for filesystem storage
image_data = Column(LargeBinary, nullable=True) # Direct image data for one-time analysis
thumbnail_path = Column(String(512), nullable=True)
# Relationships
user = relationship("UserModel", back_populates="chart_images")
analysis = relationship("ChartAnalysisModel", back_populates="chart_image")
conversations = relationship("ConversationModel", back_populates="chart_image")
# Chart references are now handled through ConversationChartReferenceModel
from sqlalchemy import Column, String, DateTime, Boolean, Text, ForeignKey
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from src.infrastructure.persistence.models.base import Base
from datetime import datetime, timezone
from sqlalchemy.sql import func
from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid
class ConversationModel(Base, TimestampMixin):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
chart_image_id = Column(UUID(as_uuid=True), ForeignKey('chart_images.id'), nullable=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey('users.id'), nullable=False)
title = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
# Relationships
user = relationship("UserModel", back_populates="conversations")
chart_image = relationship("ChartImageModel", back_populates="conversations")
messages = relationship("ConversationMessageModel", back_populates="conversation",
cascade="all, delete-orphan")
cascade="all, delete-orphan", order_by="ConversationMessageModel.timestamp")
class ConversationMessageModel(Base):
__tablename__ = "conversation_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False)
message_type = Column(String(20), nullable=False) # 'user' or 'assistant'
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
metadata = Column(JSONB) # For additional message data
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False)
sender_id = Column(String(36), nullable=False) # user_id or system
text = Column(Text, nullable=True)
chart_image_id = Column(String(36), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
#metadata = Column(JSON) # For additional message data
# Relationship
conversation = relationship("ConversationModel", back_populates="messages")
class ConversationChartReferenceModel(Base):
__tablename__ = "conversation_chart_references"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False)
chart_image_id = Column(String(36), ForeignKey("chart_images.id"), nullable=False)
added_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
conversation = relationship("ConversationModel")
chart_image = relationship("ChartImageModel")
\ No newline at end of file
from sqlalchemy import Column, String, Boolean, DateTime, LargeBinary, ForeignKey, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid
from .base import Base, TimestampMixin
class UserModel(Base, TimestampMixin):
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
......
......@@ -2,7 +2,7 @@
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.ports.repositories.token_repository import TokenRepositoryPort
from src.infrastructure.adapters.sqlserver.models import BlacklistedToken
from src.infrastructure.persistence.models import BlacklistedTokenModel
from datetime import datetime
class SqlTokenRepository(TokenRepositoryPort):
......
......@@ -2,7 +2,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.ports import UserRepositoryPort
from src.infrastructure.adapters.sqlserver.models import User as UserModel
from src.infrastructure.persistence.models import UserModel
from src.domain.entities.user import User as UserEntity
from src.infrastructure.adapters.sqlserver.mappers.mappers import user_model_to_entity, user_entity_to_model
......
import os
import uuid
from pathlib import Path
from typing import Tuple
from uuid import UUID
from PIL import Image
from io import BytesIO
import aiofiles
from application.interfaces.file_storage import FileStoragePort
class LocalFileStorageService(FileStoragePort):
"""Local filesystem implementation of file storage"""
def __init__(self, storage_root: str = "storage/uploads"):
self.storage_root = Path(storage_root)
self._ensure_storage_root()
def _ensure_storage_root(self):
"""Create storage directory if it doesn't exist"""
self.storage_root.mkdir(parents=True, exist_ok=True)
def generate_file_path(self, user_id: UUID, extension: str) -> str:
"""Generate a unique file path in user-specific directory"""
user_dir = self.storage_root / str(user_id)
user_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{timestamp}_{uuid.uuid4().hex}.{extension}"
return str(user_dir / filename)
async def save_chart_image(
self,
user_id: UUID,
image_data: bytes,
content_type: str
) -> str:
"""Save image data to filesystem"""
# Determine file extension from content type
extension = self._get_extension_from_content_type(content_type)
file_path = self.generate_file_path(user_id, extension)
async with aiofiles.open(file_path, "wb") as f:
await f.write(image_data)
return file_path
async def get_chart_image(self, file_path: str) -> Tuple[bytes, str]:
"""Retrieve image data from filesystem"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
async with aiofiles.open(file_path, "rb") as f:
content = await f.read()
content_type = self._get_content_type_from_extension(file_path)
return content, content_type
async def generate_thumbnail(
self,
original_path: str,
output_path: str,
dimensions: Tuple[int, int] = (300, 300)
) -> str:
"""Generate thumbnail version of an image"""
# Read original image
content, _ = await self.get_chart_image(original_path)
# Create thumbnail
with Image.open(BytesIO(content)) as img:
img.thumbnail(dimensions)
# Determine format from original extension
ext = Path(original_path).suffix[1:].lower()
if ext in ['jpg', 'jpeg']:
format = 'JPEG'
elif ext == 'png':
format = 'PNG'
else:
format = ext.upper()
# Save thumbnail
thumb_dir = Path(output_path).parent
thumb_dir.mkdir(parents=True, exist_ok=True)
img.save(output_path, format=format)
return output_path
async def delete_file(self, file_path: str) -> bool:
"""Delete a file from storage"""
try:
os.unlink(file_path)
return True
except OSError:
return False
def _get_extension_from_content_type(self, content_type: str) -> str:
"""Map content type to file extension"""
mapping = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/webp': 'webp'
}
return mapping.get(content_type.lower(), 'bin')
def _get_content_type_from_extension(self, file_path: str) -> str:
"""Map file extension to content type"""
ext = Path(file_path).suffix[1:].lower()
mapping = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'webp': 'image/webp'
}
return mapping.get(ext, 'application/octet-stream')
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment