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 ...@@ -14,7 +14,16 @@ import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Import your settings and models # Import your settings and models
from src.config import settings 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 # 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 fastapi import FastAPI
from api.fastapi.routes import auth, charts from .routes import auth, charts, conversations
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
...@@ -35,9 +35,6 @@ app.add_middleware( ...@@ -35,9 +35,6 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(auth.router, prefix="/auth") app.include_router(auth.router, prefix="/auth")
app.include_router(charts.router, prefix="/charts") 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.include_router(conversations.router, prefix="/conversations")
@app.get("/") @app.get("/")
......
...@@ -26,7 +26,7 @@ async def register( ...@@ -26,7 +26,7 @@ async def register(
auth_service: AuthServicePort = Depends(get_auth_service) auth_service: AuthServicePort = Depends(get_auth_service)
): ):
try: try:
user = await auth_service.register(request.email, request.password) user = await auth_service.register(request)
return {"id": user.id, "email": user.email} return {"id": user.id, "email": user.email}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
...@@ -37,7 +37,7 @@ async def login( ...@@ -37,7 +37,7 @@ async def login(
auth_service: AuthServicePort = Depends(get_auth_service) auth_service: AuthServicePort = Depends(get_auth_service)
): ):
try: try:
token = await auth_service.login(request.email, request.password) token = await auth_service.login(request)
return {"access_token": token, "token_type": "bearer"} return {"access_token": token.access_token, "token_type": "bearer"}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=401, detail=str(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 ( ...@@ -3,10 +3,11 @@ from src.application.dtos.conversation import (
CreateConversationRequestDTO, CreateConversationRequestDTO,
SendMessageRequestDTO, SendMessageRequestDTO,
ConversationResponseDTO, ConversationResponseDTO,
ConversationDetailResponseDTO ConversationDetailResponseDTO,
MessageResponseDTO
) )
from src.application.services.chat_service import ChatService from src.application.services.conversation_service import ConversationService
from src.dependencies import get_current_user, get_chat_service from src.dependencies import get_current_user, get_conversation_service
from typing import List from typing import List
import logging import logging
...@@ -17,7 +18,7 @@ router = APIRouter(tags=["conversations"]) ...@@ -17,7 +18,7 @@ router = APIRouter(tags=["conversations"])
async def create_conversation( async def create_conversation(
request: CreateConversationRequestDTO, request: CreateConversationRequestDTO,
current_user: dict = Depends(get_current_user), 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""" """Create a new conversation for a chart"""
try: try:
...@@ -32,7 +33,7 @@ async def create_conversation( ...@@ -32,7 +33,7 @@ async def create_conversation(
async def list_conversations( async def list_conversations(
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
limit: int = 20, limit: int = 20,
chat_service: ChatService = Depends(get_chat_service) chat_service: ConversationService = Depends(get_conversation_service)
): ):
"""List user's conversations""" """List user's conversations"""
try: try:
...@@ -45,7 +46,7 @@ async def list_conversations( ...@@ -45,7 +46,7 @@ async def list_conversations(
async def get_conversation( async def get_conversation(
conversation_id: str, conversation_id: str,
current_user: dict = Depends(get_current_user), 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""" """Get conversation details with messages"""
try: try:
...@@ -61,7 +62,7 @@ async def send_message( ...@@ -61,7 +62,7 @@ async def send_message(
conversation_id: str, conversation_id: str,
request: SendMessageRequestDTO, request: SendMessageRequestDTO,
current_user: dict = Depends(get_current_user), 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""" """Send a message in a conversation"""
try: try:
...@@ -76,7 +77,7 @@ async def send_message( ...@@ -76,7 +77,7 @@ async def send_message(
async def delete_conversation( async def delete_conversation(
conversation_id: str, conversation_id: str,
current_user: dict = Depends(get_current_user), 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)""" """Delete a conversation (soft delete)"""
try: try:
...@@ -94,7 +95,8 @@ async def delete_conversation( ...@@ -94,7 +95,8 @@ async def delete_conversation(
@router.get("/statistics/summary") @router.get("/statistics/summary")
async def get_conversation_statistics( async def get_conversation_statistics(
current_user: dict = Depends(get_current_user), 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""" """Get conversation statistics for the current user"""
try: try:
......
...@@ -2,12 +2,12 @@ from pydantic import BaseModel ...@@ -2,12 +2,12 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
class CreateConversationRequestDTO(BaseModel): class CreateConversationRequestDTO(BaseModel):
chart_image_id: str
title: str title: str
initial_chart_image_id: Optional[str] = None # Optional initial chart
class SendMessageRequestDTO(BaseModel): class SendMessageRequestDTO(BaseModel):
conversation_id: str
message: str message: str
chart_image_id: Optional[str] = None # Optional chart to include with message
class ConversationResponseDTO(BaseModel): class ConversationResponseDTO(BaseModel):
id: str id: str
...@@ -15,16 +15,23 @@ class ConversationResponseDTO(BaseModel): ...@@ -15,16 +15,23 @@ class ConversationResponseDTO(BaseModel):
created_at: str created_at: str
updated_at: str updated_at: str
message_count: int message_count: int
chart_count: int
class MessageContentDTO(BaseModel):
text: Optional[str] = None
chart_image_id: Optional[str] = None
class MessageResponseDTO(BaseModel): class MessageResponseDTO(BaseModel):
id: str id: str
message_type: str message_type: str
content: str content: MessageContentDTO
timestamp: str timestamp: str
sender_id: str
class ConversationDetailResponseDTO(BaseModel): class ConversationDetailResponseDTO(BaseModel):
id: str id: str
title: str title: str
created_at: str created_at: str
updated_at: str updated_at: str
messages: List[MessageResponseDTO] messages: List[MessageResponseDTO]
\ No newline at end of file referenced_charts: List[str]
\ No newline at end of file
...@@ -39,8 +39,5 @@ class AnalyzeService(AnalysisServicePort): ...@@ -39,8 +39,5 @@ class AnalyzeService(AnalysisServicePort):
def _build_prompt(self, image_data: bytes, question: str) -> str: def _build_prompt(self, image_data: bytes, question: str) -> str:
"""Construct the prompt for the LLM""" """Construct the prompt for the LLM"""
return ( return LLMRequestDTO(image_bytes = image_data , question = f"You are a data analysis expert. Analyze this chart and answer the following question: {question}\n")
f"You are a data analysis expert. Analyze this chart and "
f"answer the following question: {question}\n" \ No newline at end of file
f"Chart data: {image_data}"
)
\ No newline at end of file
from src.domain.entities.chart_analysis import ChartAnalysis from src.domain.entities.conversation import Conversation, ConversationMessage, MessageContent, MessageType
from src.domain.ports.repositories.analysis_repository import AnalysisRepositoryPort 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.ports.llm_service_port import LLMServicePort
from src.application.dtos.LLM import LLMRequestDTO from src.application.dtos.LLM import LLMRequestDTO
from src.application.dtos.conversation import SendMessageRequestDTO, MessageResponseDTO, MessageContentDTO
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List, Optional
class ChatConversationUseCase: class ChatConversationUseCase:
def __init__( def __init__(
self, self,
analysis_repo: AnalysisRepositoryPort, conversation_repo: ConversationRepositoryPort,
charts_repo: ChartsRepositoryPort,
llm_service: LLMServicePort llm_service: LLMServicePort
): ):
self._analysis_repo = analysis_repo self._conversation_repo = conversation_repo
self._charts_repo = charts_repo
self._llm_service = llm_service self._llm_service = llm_service
def execute(self, chart_image_id: str, conversation_history: List[str], new_question: str) -> ChartAnalysis: async def execute(
"""Handle interactive chat conversation about a chart""" self,
conversation_id: str,
user_id: str,
request: SendMessageRequestDTO
) -> MessageResponseDTO:
"""Handle interactive chat conversation with support for multiple charts"""
# Build context from conversation history # Get conversation
context = self._build_conversation_context(conversation_history) conversation = await self._conversation_repo.get_conversation_by_id(conversation_id)
# Create enhanced question with context if not conversation:
enhanced_question = f"Context from previous conversation: {context}\n\nNew question: {new_question}" raise ValueError("Conversation not found")
# Create request for LLM if conversation.user_id != user_id:
request = LLMRequestDTO( raise ValueError("Conversation does not belong to user")
image_bytes=b"", # Will be loaded by service
question=enhanced_question # 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
) )
# Get response from LLM # Create user message
response = await self._llm_service.analyze(request) user_message = ConversationMessage(
# Save the conversation turn
analysis = ChartAnalysis(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
chart_image_id=chart_image_id, conversation_id=conversation_id,
question=new_question, sender_id=user_id,
answer=response.answer, message_type=MessageType.TEXT,
created_at=datetime.now(timezone.utc) 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.messages[:-1]) # Exclude current message
# Create enhanced question with context
enhanced_question = self._create_enhanced_question(request.message, context)
# 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
)
llm_response = await self._llm_service.analyze(llm_request)
# Create assistant message
assistant_content = MessageContent.create_text(llm_response.answer)
assistant_message = ConversationMessage(
id=str(uuid.uuid4()),
conversation_id=conversation_id,
sender_id="system", # AI assistant
message_type=MessageType.TEXT,
content=assistant_content,
timestamp=datetime.now(timezone.utc)
)
# 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
self._analysis_repo.save_analysis(analysis) content_dto = MessageContentDTO(
return analysis 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, history: List[str]) -> str: def _build_conversation_context(self, messages: List[ConversationMessage]) -> str:
"""Build context string from conversation history""" """Build context string from conversation history"""
if not history: if not messages:
return "" return ""
# Take last 5 messages for context to avoid token limits
recent_messages = messages[-5:]
context_parts = [] context_parts = []
for i, entry in enumerate(history[-5:], 1): # Last 5 entries for context for i, msg in enumerate(recent_messages, 1):
context_parts.append(f"Turn {i}: {entry}") 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 " | ".join(context_parts) return f"""Previous conversation context: {context}
\ No newline at end of file
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 fastapi import Depends, Query
from typing import AsyncGenerator 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.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from src.application.services.authentication_service import AuthService 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.application.use_cases.upload_chart import UploadChartUseCase
from src.domain.ports.repositories.user_repository import UserRepositoryPort from src.domain.ports.repositories.user_repository import UserRepositoryPort
from src.domain.ports.repositories.token_repository import TokenRepositoryPort from src.domain.ports.repositories.token_repository import TokenRepositoryPort
from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort from src.domain.ports.repositories.charts_repository import ChartsRepositoryPort
from src.domain.ports.repositories.conversation_repository import ConversationRepositoryPort from src.domain.ports.repositories.conversation_repository import ConversationRepositoryPort
from src.infrastructure.adapters.sqlserver.sql_user_repository import SqlUserRepository from src.infrastructure.persistence.repositories.sql_user_repository import SqlUserRepository
from src.infrastructure.adapters.sqlserver.sql_charts_repository import SqlChartsRepository from src.infrastructure.persistence.repositories.sql_charts_repository import SqlChartsRepository
from src.infrastructure.adapters.sqlserver.sql_conversation_repository import SqlConversationRepository from src.infrastructure.persistence.repositories.sql_conversation_repository import SqlConversationRepository
from src.application.services.analyze_service import AnalyzeService from src.application.services.analyze_service import AnalyzeService
from src.application.services.ollama_service import OllamaService from src.infrastructure.services.ollama_service import OllamaService
from src.application.services.chartGemma_service import ChartGemmaService from src.infrastructure.services.chartGemma_service import ChartGemmaService
from src.application.services.chat_service import ChatService from src.application.services.conversation_service import ConversationService
from src.application.ports.llm_service_port import LLMServicePort 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 from src.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=True) engine = create_async_engine(settings.DATABASE_URL, echo=True)
...@@ -82,11 +86,49 @@ def get_llm_service_by_query( ...@@ -82,11 +86,49 @@ def get_llm_service_by_query(
""" """
return get_llm_service(model) 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), conversation_repo: ConversationRepositoryPort = Depends(get_conversation_repository),
charts_repo: ChartsRepositoryPort = Depends(get_charts_repository), charts_repo: ChartsRepositoryPort = Depends(get_charts_repository),
file_storage: FileStoragePort = Depends(get_file_storage_service),
llm_service: LLMServicePort = Depends(get_llm_service) llm_service: LLMServicePort = Depends(get_llm_service)
) -> ChatService: ) -> ConversationService:
"""Factory for the chat service""" """Factory for conversation service with all dependencies"""
return ChatService(conversation_repo, charts_repo, llm_service) return ConversationService(
conversation_repo=conversation_repo,
chart_repo=charts_repo,
file_storage=file_storage,
llm_service=llm_service
)
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
class ChartImage(BaseModel): class ChartImage(BaseModel):
id: str # UUID id: str # UUID
user_id: str # Owner of the chart 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)
uploaded_at: datetime = datetime.now(timezone.utc) image_data: Optional[bytes] = None # Direct image data for one-time analysis
\ No newline at end of file #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 pydantic import BaseModel
from datetime import datetime, timezone from uuid import UUID, uuid4
from typing import List, Optional 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): class ConversationMessage(BaseModel):
id: str id: str = str(uuid4())
conversation_id: str conversation_id: str
user_id: str sender_id: str # user_id or system
message_type: str # "user" or "assistant" message_type: MessageType
content: str content: MessageContent
timestamp: datetime = datetime.now(timezone.utc) timestamp: datetime = datetime.utcnow()
metadata: dict = {} # For additional data like AI analysis results
class Conversation(BaseModel): class Conversation(BaseModel):
id: str id: str = str(uuid4())
user_id: str user_id: str
chart_image_id: str
title: str title: str
created_at: datetime = datetime.now(timezone.utc) created_at: datetime = datetime.utcnow()
updated_at: datetime = datetime.now(timezone.utc) updated_at: datetime = datetime.utcnow()
is_active: bool = True is_active: bool = True
messages: List[ConversationMessage] = [] messages: List[ConversationMessage] = []
\ No newline at end of file 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 # src/infrastructure/adapters/sqlserver/mappers/mappers.py
from src.domain.entities.user import User as UserEntity 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: def user_model_to_entity(model: UserModel) -> UserEntity:
return UserEntity( return UserEntity(
......
from sqlalchemy.sql import func from sqlalchemy import Column, String, Text, ForeignKey, JSON
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid import uuid
class ChartAnalysisModel(Base, TimestampMixin): class ChartAnalysisModel(Base, TimestampMixin):
__tablename__ = 'chart_analysis' __tablename__ = 'chart_analysis'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
chart_image_id = Column(UUID(as_uuid=True), ForeignKey('chart_images.id'), nullable=False) chart_image_id = Column(String(36), ForeignKey('chart_images.id'), nullable=False)
question = Column(Text, nullable=False) question = Column(Text, nullable=False)
answer = 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 # Relationship
chart_image = relationship("ChartImageModel", back_populates="analysis") 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.sql import func
from sqlalchemy.orm import declarative_base from src.infrastructure.persistence.models.base import Base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
import uuid import uuid
class BlacklistedTokenModel(Base): class BlacklistedTokenModel(Base):
__tablename__ = 'blacklisted_tokens' __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) token = Column(String(512), unique=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# src/infrastructure/database/models.py from sqlalchemy import Column, String, ForeignKey, LargeBinary
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.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid import uuid
class ChartImageModel(Base, TimestampMixin): class ChartImageModel(Base, TimestampMixin):
__tablename__ = 'chart_images' __tablename__ = 'chart_images'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False) user_id = Column(String(36), ForeignKey('users.id'), nullable=False)
file_path = Column(String(512), nullable=False) # Changed from image_data to file_path 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) thumbnail_path = Column(String(512), nullable=True)
# Relationships # Relationships
user = relationship("UserModel", back_populates="chart_images") user = relationship("UserModel", back_populates="chart_images")
analysis = relationship("ChartAnalysisModel", back_populates="chart_image") 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.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql import func
from src.infrastructure.persistence.models.base import Base from src.infrastructure.persistence.models.base import Base, TimestampMixin
from datetime import datetime, timezone import uuid
class ConversationModel(Base, TimestampMixin): class ConversationModel(Base, TimestampMixin):
__tablename__ = "conversations" __tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False) user_id = Column(String(36), ForeignKey('users.id'), nullable=False)
chart_image_id = Column(UUID(as_uuid=True), ForeignKey('chart_images.id'), nullable=True)
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
# Relationships # Relationships
user = relationship("UserModel", back_populates="conversations") user = relationship("UserModel", back_populates="conversations")
chart_image = relationship("ChartImageModel", back_populates="conversations")
messages = relationship("ConversationMessageModel", back_populates="conversation", messages = relationship("ConversationMessageModel", back_populates="conversation",
cascade="all, delete-orphan") cascade="all, delete-orphan", order_by="ConversationMessageModel.timestamp")
class ConversationMessageModel(Base): class ConversationMessageModel(Base):
__tablename__ = "conversation_messages" __tablename__ = "conversation_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False) conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False)
message_type = Column(String(20), nullable=False) # 'user' or 'assistant' sender_id = Column(String(36), nullable=False) # user_id or system
content = Column(Text, nullable=False) text = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) chart_image_id = Column(String(36), nullable=True)
metadata = Column(JSONB) # For additional message data timestamp = Column(DateTime(timezone=True), server_default=func.now())
#metadata = Column(JSON) # For additional message data
# Relationship # Relationship
conversation = relationship("ConversationModel", back_populates="messages") conversation = relationship("ConversationModel", back_populates="messages")
\ No newline at end of file
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 import Column, String, Boolean, DateTime, LargeBinary, ForeignKey, Text
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from src.infrastructure.persistence.models.base import Base, TimestampMixin
import uuid import uuid
from .base import Base, TimestampMixin
class UserModel(Base, TimestampMixin): class UserModel(Base, TimestampMixin):
__tablename__ = 'users' __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) email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
from sqlalchemy import select, delete from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.ports.repositories.token_repository import TokenRepositoryPort 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 from datetime import datetime
class SqlTokenRepository(TokenRepositoryPort): class SqlTokenRepository(TokenRepositoryPort):
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.ports import UserRepositoryPort 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.domain.entities.user import User as UserEntity
from src.infrastructure.adapters.sqlserver.mappers.mappers import user_model_to_entity, user_entity_to_model 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