diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..43a435c3ac32f2a12042bb91c6e3ebd0c04b01e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.venv +__pycache__ +*.pyc +.env +data/ +.git +*.md +test/ +scripts/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d3fc467d492ef9eec475ae2f80a6464ef2491562 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first (Docker layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Pre-download sentence-transformer models during build +# This avoids slow cold-start downloads at runtime +RUN python -c "\ +from sentence_transformers import SentenceTransformer, CrossEncoder; \ +SentenceTransformer('all-MiniLM-L6-v2'); \ +CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" + +# Copy application code +COPY . . + +# Create data directories +RUN mkdir -p data/chromadb + +# Default port (HF Spaces sets PORT=7860, local default is 8000) +ENV PORT=7860 + +# Expose port +EXPOSE ${PORT} + +# Start uvicorn with configurable port +CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT} diff --git a/README.md b/README.md index ecbf64b02c4bfc2e590b5bc46f3a80f194491b6c..c66832b68cf21718c85f9a7c08f7861cdb5aa2f1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ --- -title: Buildersai -emoji: 🚀 -colorFrom: pink -colorTo: green +title: BuildersAI +emoji: 🏗️ +colorFrom: blue +colorTo: indigo sdk: docker +app_port: 7860 pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Builder's AI - Backend API + +Construction AI Assistant API with Multi-Agent RAG System. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..a46ba292c130b9cd6c7aa7df88ec044226903683 --- /dev/null +++ b/app/config/settings.py @@ -0,0 +1,54 @@ +from pydantic_settings import BaseSettings +from typing import List, Optional + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # App Configuration + SECRET_KEY: str + DEBUG: bool = False + PORT: int = 8000 + + # Database + DATABASE_URL: str = "sqlite:///data/database.db" + + # AI Services + GROQ_API_KEY: str + GROQ_MODEL: str = "llama-3.1-8b-instant" + TAVILY_API_KEY: str + + # JWT Configuration + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + ALGORITHM: str = "HS256" + + # Google OAuth + GOOGLE_CLIENT_ID: Optional[str] = "" + GOOGLE_CLIENT_SECRET: Optional[str] = "" + GOOGLE_REDIRECT_URI: Optional[str] = "" + + # CORS + ALLOWED_ORIGINS: str + + @property + def cors_origins(self) -> List[str]: + """Parse CORS origins from comma-separated string.""" + return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")] + + # Backward compatibility properties (lowercase) + @property + def secret_key(self) -> str: + return self.SECRET_KEY + + @property + def algorithm(self) -> str: + return self.ALGORITHM + + class Config: + env_file = ".env" + case_sensitive = True + + +# Global settings instance +settings = Settings() diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/database/connection.py b/app/database/connection.py new file mode 100644 index 0000000000000000000000000000000000000000..618c831ebb003ff65aab3d28f755389a84d312d2 --- /dev/null +++ b/app/database/connection.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine, Column, String, Integer, Text, DateTime, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from datetime import datetime +import uuid + +from app.config.settings import settings + +# Create SQLAlchemy engine +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False} # Needed for SQLite +) + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for models +Base = declarative_base() + + +def get_db(): + """Dependency to get database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize database tables.""" + Base.metadata.create_all(bind=engine) diff --git a/app/database/models.py b/app/database/models.py new file mode 100644 index 0000000000000000000000000000000000000000..10f1432ef2f8dad8c18b039b14c31b92c295e1fb --- /dev/null +++ b/app/database/models.py @@ -0,0 +1,122 @@ +from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, CheckConstraint +from sqlalchemy.orm import relationship +from datetime import datetime +import uuid + +from app.database.connection import Base + + +def generate_id(): + """Generate a unique ID.""" + return str(uuid.uuid4()) + + +class User(Base): + """User model for authentication.""" + __tablename__ = "users" + + id = Column(String, primary_key=True, default=generate_id) + email = Column(String, unique=True, nullable=False, index=True) + name = Column(String, nullable=True) + password_hash = Column(String, nullable=True) # Nullable for OAuth users + google_id = Column(String, unique=True, nullable=True, index=True) + is_admin = Column(Integer, default=0) # 0 = regular user, 1 = admin + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") + documents = relationship("Document", back_populates="user", cascade="all, delete-orphan") + + +class Session(Base): + """Chat session model.""" + __tablename__ = "sessions" + + id = Column(String, primary_key=True, default=generate_id) + user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True) + title = Column(String, default="New Conversation") + summary = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="sessions") + messages = relationship("Message", back_populates="session", cascade="all, delete-orphan") + + +class Message(Base): + """Chat message model.""" + __tablename__ = "messages" + + id = Column(String, primary_key=True, default=generate_id) + session_id = Column(String, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True) + role = Column(String, nullable=False) # 'user' or 'assistant' + content = Column(Text, nullable=False) + meta = Column(Text, nullable=True) # JSON string for metadata (agent, sources, etc.) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + session = relationship("Session", back_populates="messages") + + # Constraint + __table_args__ = ( + CheckConstraint("role IN ('user', 'assistant')", name="check_role"), + ) + + +class Document(Base): + """Uploaded document model.""" + __tablename__ = "documents" + + id = Column(String, primary_key=True, default=generate_id) + user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + filename = Column(String, nullable=False) + file_path = Column(String, nullable=False) + file_type = Column(String, nullable=True) + file_size = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="documents") + + +class OfficialPolicy(Base): + """Official policy document model (admin-only upload, visible to all).""" + __tablename__ = "official_policies" + + id = Column(String, primary_key=True, default=generate_id) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + filename = Column(String, nullable=False) + file_path = Column(String, nullable=False) + file_type = Column(String, nullable=True) + file_size = Column(Integer, nullable=True) + category = Column(String, nullable=True) # e.g., "OSHA", "Safety", "Building Codes" + uploaded_by = Column(String, ForeignKey("users.id"), nullable=False) + is_active = Column(Integer, default=1) # 0 = inactive, 1 = active + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class UserSettings(Base): + """User settings and preferences model.""" + __tablename__ = "user_settings" + + id = Column(String, primary_key=True, default=generate_id) + user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + + # Profile settings + bio = Column(Text, nullable=True) + phone = Column(String, nullable=True) + company = Column(String, nullable=True) + + # Appearance settings + theme = Column(String, default="system") # light, dark, system + + # Notification settings + email_notifications = Column(Integer, default=1) # 0 = off, 1 = on + update_notifications = Column(Integer, default=1) # 0 = off, 1 = on + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/app/llm/__init__.py b/app/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/llm/agents/__init__.py b/app/llm/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/llm/agents/general.py b/app/llm/agents/general.py new file mode 100644 index 0000000000000000000000000000000000000000..4bc3be980591e355346934682b35ad8e5ab13db4 --- /dev/null +++ b/app/llm/agents/general.py @@ -0,0 +1,61 @@ +from typing import Dict, List +import os + +from app.llm.client import llm_client + + +class GeneralAgent: + """Agent for general construction questions and conversations.""" + + def __init__(self): + """Initialize general agent with prompt template.""" + prompt_path = os.path.join( + os.path.dirname(__file__), + "..", + "prompts", + "general.txt" + ) + with open(prompt_path, "r") as f: + self.system_prompt = f.read() + + def answer(self, query: str, chat_history: List[Dict] = None) -> Dict[str, any]: + """ + Generate answer for general construction queries. + + Args: + query: User query string + chat_history: Optional chat history for context + + Returns: + Dictionary with 'answer' and 'agent' keys + """ + try: + messages = [{"role": "system", "content": self.system_prompt}] + + # Add chat history if provided + if chat_history: + messages.extend(chat_history[-6:]) # Last 3 exchanges + + messages.append({"role": "user", "content": query}) + + answer = llm_client.get_completion( + messages=messages, + temperature=0.7, + max_tokens=1024 + ) + + return { + "answer": answer, + "agent": "general" + } + + except Exception as e: + print(f"General agent error: {e}") + return { + "answer": "I apologize, but I encountered an error. Please try again.", + "agent": "general" + } + + +# Global general agent instance +general_agent = GeneralAgent() diff --git a/app/llm/agents/policy.py b/app/llm/agents/policy.py new file mode 100644 index 0000000000000000000000000000000000000000..45c8dfc11f8011efcdf94379f5017bee85c668dc --- /dev/null +++ b/app/llm/agents/policy.py @@ -0,0 +1,173 @@ +from typing import Dict, List +import os + +from app.llm.client import llm_client +from app.services.rag_service import rag_service + + +class PolicyAgent: + """Agent for construction policy and regulatory queries using official policy documents.""" + + def __init__(self): + """Initialize policy agent with prompt template.""" + prompt_path = os.path.join( + os.path.dirname(__file__), + "..", + "prompts", + "policy.txt" + ) + with open(prompt_path, "r") as f: + self.system_prompt = f.read() + + def answer(self, query: str, context_chunks: List[Dict] = None) -> Dict[str, any]: + """ + Generate answer for policy/regulatory queries using official policy documents. + + Args: + query: User query string + context_chunks: Retrieved policy chunks from RAG search + + Returns: + Dictionary with 'answer', 'agent', and 'sources' keys + """ + try: + # If no context provided, search all official policies (retrieve more chunks for better coverage) + if context_chunks is None: + context_chunks = rag_service.collection.query( + query_embeddings=[rag_service.embedding_generator.generate_embedding(query)], + n_results=10, # Increased from 5 to 10 for better coverage + where={"user_id": "official_policies"} + ) + + # Format results + if context_chunks and context_chunks['documents']: + context_chunks = [ + { + "content": context_chunks['documents'][0][i], + "metadata": context_chunks['metadatas'][0][i] + } + for i in range(len(context_chunks['documents'][0])) + ] + else: + context_chunks = [] + + # Build context from chunks + if not context_chunks: + return { + "answer": "I don't have any official policy documents to answer this question. Please ensure policies are uploaded in the Admin Panel.", + "agent": "policy", + "sources": [] + } + + # Format context with clear chunk numbering + context_sections = [] + for i, chunk in enumerate(context_chunks, 1): + doc_id = chunk['metadata'].get('document_id', 'unknown') + filename = chunk['metadata'].get('filename', 'Official Policy') + chunk_idx = chunk['metadata'].get('chunk_index', '?') + + context_sections.append( + f"=== EXCERPT {i} ===\n" + f"Document: {filename}\n" + f"Document ID: {doc_id}\n" + f"Section: Chunk {chunk_idx}\n" + f"---\n" + f"{chunk['content']}\n" + ) + + context_text = "\n".join(context_sections) + + # Create strict user message + user_message = f"""DOCUMENT EXCERPTS FROM OFFICIAL POLICY: + +{context_text} + +======================================== +USER QUESTION: {query} +======================================== + +REMEMBER: +- Answer using ONLY the excerpts above +- Include clause/section numbers if present in the text +- Quote exact definitions or requirements +- If the answer is not in the excerpts, say "The provided document sections do not contain this information" +- Do NOT use external knowledge from other building codes + +Now provide your answer:""" + + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": user_message} + ] + + answer = llm_client.get_completion( + messages=messages, + temperature=0.1, # Very low temperature for maximum accuracy and minimal creativity + max_tokens=2000 + ) + + # Extract sources and policy names + sources = [] + policy_names = set() + + # Get policy titles from database + from app.database.connection import SessionLocal + from app.database.models import OfficialPolicy + + db = SessionLocal() + try: + # Collect unique document IDs + doc_ids = set() + for chunk in context_chunks: + doc_id = chunk["metadata"].get("document_id", "") + if doc_id: + doc_ids.add(doc_id) + + # Fetch policy titles from database + policy_title_map = {} + if doc_ids: + policies = db.query(OfficialPolicy).filter( + OfficialPolicy.id.in_(doc_ids) + ).all() + policy_title_map = {p.id: p.title for p in policies} + + # Build sources and collect policy names + for chunk in context_chunks: + doc_id = chunk["metadata"].get("document_id", "") + policy_title = policy_title_map.get(doc_id, chunk["metadata"].get("filename", "Official Policy")) + + sources.append({ + "content": chunk["content"][:300] + "...", + "document_id": doc_id, + "filename": chunk["metadata"].get("filename", "Official Policy"), + "title": policy_title, + "chunk_index": chunk["metadata"].get("chunk_index", 0) + }) + + # Collect unique policy titles (not filenames) + if policy_title: + policy_names.add(policy_title) + + finally: + db.close() + + print(f"[Policy Agent] Returning policy_names: {list(policy_names)}") + + return { + "answer": answer, + "agent": "policy", + "sources": sources, + "policy_names": list(policy_names) # List of policy titles used + } + + except Exception as e: + print(f"Policy agent error: {e}") + return { + "answer": "I encountered an error while processing your policy question. Please try again.", + "agent": "policy", + "sources": [] + } + + +# Global policy agent instance +policy_agent = PolicyAgent() diff --git a/app/llm/agents/rag.py b/app/llm/agents/rag.py new file mode 100644 index 0000000000000000000000000000000000000000..24e7a22492d7c83231a94bb358d0c53fb31303cd --- /dev/null +++ b/app/llm/agents/rag.py @@ -0,0 +1,86 @@ +from typing import Dict, List +import os + +from app.llm.client import llm_client + + +class RAGAgent: + """Agent for document-based question answering using RAG.""" + + def __init__(self): + """Initialize RAG agent with prompt template.""" + prompt_path = os.path.join( + os.path.dirname(__file__), + "..", + "prompts", + "rag.txt" + ) + with open(prompt_path, "r") as f: + self.system_prompt = f.read() + + def answer(self, query: str, context_chunks: List[Dict]) -> Dict[str, any]: + """ + Generate answer based on retrieved document chunks. + + Args: + query: User query string + context_chunks: List of retrieved document chunks with metadata + + Returns: + Dictionary with 'answer', 'sources', and 'agent' keys + """ + try: + # Format context for LLM + context = self._format_context(context_chunks) + + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": f"Context:\n{context}\n\nQuery: {query}"} + ] + + answer = llm_client.get_completion( + messages=messages, + temperature=0.3, # Lower temperature for factual accuracy + max_tokens=1024 + ) + + # Extract unique sources + sources = list({ + chunk.get("metadata", {}).get("filename", "Unknown") + for chunk in context_chunks + }) + + return { + "answer": answer, + "sources": sources, + "agent": "rag" + } + + except Exception as e: + print(f"RAG agent error: {e}") + return { + "answer": "I encountered an error while processing your question. Please try again.", + "sources": [], + "agent": "rag" + } + + def _format_context(self, chunks: List[Dict]) -> str: + """Format document chunks for LLM context.""" + if not chunks: + return "No relevant documents found." + + formatted = [] + for i, chunk in enumerate(chunks, 1): + metadata = chunk.get("metadata", {}) + content = chunk.get("content", "") + filename = metadata.get("filename", "Unknown") + + formatted.append( + f"[Document {i}: {filename}]\n{content}\n" + ) + + return "\n---\n".join(formatted) + + +# Global RAG agent instance +rag_agent = RAGAgent() diff --git a/app/llm/agents/router.py b/app/llm/agents/router.py new file mode 100644 index 0000000000000000000000000000000000000000..02ee3d49847f25b46b8d146190a3ad52b35e47c1 --- /dev/null +++ b/app/llm/agents/router.py @@ -0,0 +1,62 @@ +from typing import Dict, List +import json +import os + +from app.llm.client import llm_client + + +class RouterAgent: + """Agent that routes queries to appropriate specialized agents.""" + + def __init__(self): + """Initialize router agent with prompt template.""" + prompt_path = os.path.join( + os.path.dirname(__file__), + "..", + "prompts", + "router.txt" + ) + with open(prompt_path, "r") as f: + self.system_prompt = f.read() + + def route(self, query: str, chat_history: List[Dict] = None) -> Dict[str, str]: + """ + Route a query to the appropriate agent. + + Args: + query: User query string + chat_history: Optional chat history for context + + Returns: + Dictionary with 'agent' and 'reasoning' keys + """ + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": query} + ] + + try: + response = llm_client.get_completion( + messages=messages, + temperature=0.3, + max_tokens=256, + json_mode=True + ) + + result = json.loads(response) + + # Validate response + if "agent" not in result or result["agent"] not in ["search", "rag", "policy", "general"]: + # Default to general if invalid + return {"agent": "general", "reasoning": "Default routing"} + + return result + + except Exception as e: + print(f"Router agent error: {e}") + # Default to general agent on error + return {"agent": "general", "reasoning": "Error in routing, using general agent"} + + +# Global router agent instance +router_agent = RouterAgent() diff --git a/app/llm/agents/search.py b/app/llm/agents/search.py new file mode 100644 index 0000000000000000000000000000000000000000..d60aff563674e34c9241ee2b6743383b25eec9cc --- /dev/null +++ b/app/llm/agents/search.py @@ -0,0 +1,91 @@ +from typing import Dict, List +import os +from tavily import TavilyClient + +from app.llm.client import llm_client +from app.config.settings import settings + + +class SearchAgent: + """Agent that performs web searches and generates answers.""" + + def __init__(self): + """Initialize search agent with Tavily client and prompt.""" + self.tavily_client = TavilyClient(api_key=settings.TAVILY_API_KEY) + + prompt_path = os.path.join( + os.path.dirname(__file__), + "..", + "prompts", + "search.txt" + ) + with open(prompt_path, "r") as f: + self.system_prompt = f.read() + + def search_and_answer(self, query: str) -> Dict[str, any]: + """ + Perform web search and generate answer. + + Args: + query: User query string + + Returns: + Dictionary with 'answer' and 'sources' keys + """ + try: + # Perform Tavily search + search_results = self.tavily_client.search( + query=query, + search_depth="basic", + max_results=5 + ) + + # Format search results for LLM + context = self._format_search_results(search_results.get("results", [])) + + # Generate answer using LLM + messages = [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": f"Query: {query}\n\nSearch Results:\n{context}"} + ] + + answer = llm_client.get_completion( + messages=messages, + temperature=0.7, + max_tokens=1024 + ) + + # Extract sources + sources = [ + {"title": r.get("title"), "url": r.get("url")} + for r in search_results.get("results", []) + ] + + return { + "answer": answer, + "sources": sources, + "agent": "search" + } + + except Exception as e: + print(f"Search agent error: {e}") + return { + "answer": "I encountered an error while searching for information. Please try again.", + "sources": [], + "agent": "search" + } + + def _format_search_results(self, results: List[Dict]) -> str: + """Format search results for LLM context.""" + formatted = [] + for i, result in enumerate(results, 1): + formatted.append( + f"{i}. {result.get('title', 'No title')}\n" + f" URL: {result.get('url', 'No URL')}\n" + f" Content: {result.get('content', 'No content')}\n" + ) + return "\n".join(formatted) + + +# Global search agent instance +search_agent = SearchAgent() diff --git a/app/llm/client.py b/app/llm/client.py new file mode 100644 index 0000000000000000000000000000000000000000..1e2de5f5460c09c7fc8d489b80f366778f690976 --- /dev/null +++ b/app/llm/client.py @@ -0,0 +1,84 @@ +from groq import Groq +from typing import List, Dict, Optional +import json + +from app.config.settings import settings + + +class LLMClient: + """Client for interacting with Groq LLM API.""" + + def __init__(self): + """Initialize Groq client.""" + self.client = Groq(api_key=settings.GROQ_API_KEY) + self.model = settings.GROQ_MODEL + + def get_completion( + self, + messages: List[Dict[str, str]], + temperature: float = 0.7, + max_tokens: int = 1024, + json_mode: bool = False + ) -> str: + """ + Get completion from Groq LLM. + + Args: + messages: List of message dictionaries with 'role' and 'content' + temperature: Sampling temperature (0-2) + max_tokens: Maximum tokens in response + json_mode: Whether to request JSON output + + Returns: + Response content string + """ + try: + response_format = {"type": "json_object"} if json_mode else None + + chat_completion = self.client.chat.completions.create( + messages=messages, + model=self.model, + temperature=temperature, + max_tokens=max_tokens, + response_format=response_format + ) + + return chat_completion.choices[0].message.content + + except Exception as e: + print(f"Error getting LLM completion: {e}") + raise + + def get_completion_with_retry( + self, + messages: List[Dict[str, str]], + temperature: float = 0.7, + max_tokens: int = 1024, + json_mode: bool = False, + max_retries: int = 3 + ) -> str: + """ + Get completion with retry logic. + + Args: + messages: List of message dictionaries + temperature: Sampling temperature + max_tokens: Maximum tokens + json_mode: Whether to request JSON output + max_retries: Maximum number of retries + + Returns: + Response content string + """ + for attempt in range(max_retries): + try: + return self.get_completion(messages, temperature, max_tokens, json_mode) + except Exception as e: + if attempt == max_retries - 1: + raise + print(f"Retry {attempt + 1}/{max_retries} after error: {e}") + continue + + +# Global LLM client instance +llm_client = LLMClient() diff --git a/app/llm/graph.py b/app/llm/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..dfcb49128057044803020fa7a34f179a189c5709 --- /dev/null +++ b/app/llm/graph.py @@ -0,0 +1,450 @@ +""" +LangGraph-based multi-agent workflow for Builder's AI. +This implements a graph-based orchestration of multiple specialized agents. +""" +from typing import Dict, List, Optional +from langgraph.graph import StateGraph, END +import json + +from app.llm.state import AgentState +from app.llm.agents.router import router_agent +from app.llm.agents.search import search_agent +from app.llm.agents.rag import rag_agent +from app.llm.agents.policy import policy_agent +from app.llm.agents.general import general_agent +from app.services.rag_service import rag_service +from app.utils.embeddings import embedding_generator + + +class MultiAgentGraph: + """LangGraph-based multi-agent workflow orchestrator.""" + + def __init__(self): + """Initialize the multi-agent graph.""" + self.graph = self._build_graph() + print("[Multi-Agent Graph] Initialized") + + def _build_graph(self) -> StateGraph: + """ + Build the LangGraph workflow. + + Returns: + Compiled StateGraph + """ + # Create workflow graph + workflow = StateGraph(AgentState) + + # Add nodes + workflow.add_node("router", self._router_node) + workflow.add_node("search_agent", self._search_node) + workflow.add_node("rag_agent", self._rag_node) + workflow.add_node("policy_agent", self._policy_node) + workflow.add_node("general_agent", self._general_node) + + # Set entry point + workflow.set_entry_point("router") + + # Add conditional edges from router to specialized agents + workflow.add_conditional_edges( + "router", + self._route_query, + { + "search": "search_agent", + "rag": "rag_agent", + "policy": "policy_agent", + "general": "general_agent" + } + ) + + # All agent nodes end the workflow + workflow.add_edge("search_agent", END) + workflow.add_edge("rag_agent", END) + workflow.add_edge("policy_agent", END) + workflow.add_edge("general_agent", END) + + # Compile the graph + return workflow.compile() + + def _router_node(self, state: AgentState) -> AgentState: + """ + Router node: Determines which specialized agent should handle the query. + + Args: + state: Current agent state + + Returns: + Updated state with routing decision + """ + print(f"[Router Node] Processing query: {state['query'][:50]}...") + + try: + # Use router agent to determine the appropriate agent + routing = router_agent.route( + query=state["query"], + chat_history=state.get("chat_history", []) + ) + + agent_type = routing.get("agent", "general") + reasoning = routing.get("reasoning", "") + + print(f"[Router Node] Routing to: {agent_type} - {reasoning}") + + return { + **state, + "agent_type": agent_type, + "routing_reasoning": reasoning + } + + except Exception as e: + print(f"[Router Node] Error: {e}") + return { + **state, + "agent_type": "general", + "routing_reasoning": f"Error in routing: {str(e)}", + "error": str(e) + } + + def _route_query(self, state: AgentState) -> str: + """ + Conditional edge function to route to the appropriate agent. + + Args: + state: Current agent state + + Returns: + Agent type string + """ + return state.get("agent_type", "general") + + def _search_node(self, state: AgentState) -> AgentState: + """ + Search agent node: Performs web search and generates answer. + + Args: + state: Current agent state + + Returns: + Updated state with search results and answer + """ + print("[Search Node] Executing web search...") + + try: + response = search_agent.search_and_answer(state["query"]) + + return { + **state, + "answer": response.get("answer", ""), + "sources": response.get("sources", []), + "search_results": response.get("sources", []), + "metadata": { + "agent": "search", + "routing_reasoning": state.get("routing_reasoning", "") + } + } + + except Exception as e: + print(f"[Search Node] Error: {e}") + return { + **state, + "answer": "I encountered an error while searching. Please try again.", + "sources": [], + "error": str(e) + } + + def _rag_node(self, state: AgentState) -> AgentState: + """ + RAG agent node: Retrieves relevant documents and generates answer. + + Args: + state: Current agent state + + Returns: + Updated state with RAG context and answer + """ + print("[RAG Node] Performing semantic search...") + + try: + # Check if policy IDs are provided + policy_ids = state.get("policy_ids") + + if policy_ids: + # Search within selected policies + print(f"[RAG Node] Searching within {len(policy_ids)} selected policies") + print(f"[RAG Node] Policy IDs: {policy_ids}") + + context_chunks = rag_service.search_policies( + query=state["query"], + policy_ids=policy_ids, + top_k=10 # Increased for better coverage + ) + + print(f"[RAG Node] Found {len(context_chunks)} chunks from policies") + else: + # Regular document search + print(f"[RAG Node] Searching user documents for user_id: {state.get('user_id')}") + context_chunks = rag_service.semantic_search( + query=state["query"], + user_id=state.get("user_id"), + top_k=10 # Increased for better coverage + ) + print(f"[RAG Node] Found {len(context_chunks)} chunks from user docs") + + if not context_chunks: + print("[RAG Node] No relevant documents found") + no_doc_message = ( + "I don't have any content in the selected policies to answer this question." + if policy_ids + else "I don't have any uploaded documents to answer this question. Please upload construction documents or ask a general question." + ) + return { + **state, + "answer": no_doc_message, + "sources": [], + "context_chunks": [], + "metadata": { + "agent": "rag", + "note": "No documents available", + "policy_mode": bool(policy_ids) + } + } + + # Generate answer using RAG agent + response = rag_agent.answer(state["query"], context_chunks) + + # Determine agent label: "policy" if searching official policies, "rag" if user docs + agent_label = "policy" if policy_ids else "rag" + + return { + **state, + "answer": response.get("answer", ""), + "sources": response.get("sources", []), + "context_chunks": context_chunks, + "metadata": { + "agent": agent_label, # "policy" or "rag" + "chunks_retrieved": len(context_chunks), + "routing_reasoning": state.get("routing_reasoning", ""), + "policy_mode": bool(policy_ids), + "policy_count": len(policy_ids) if policy_ids else 0 + } + } + + except Exception as e: + print(f"[RAG Node] Error: {e}") + return { + **state, + "answer": "I encountered an error while processing your document query. Please try again.", + "sources": [], + "error": str(e) + } + + def _policy_node(self, state: AgentState) -> AgentState: + """ + Policy agent node: Handles regulatory and compliance queries using official policy documents. + + Args: + state: Current agent state + + Returns: + Updated state with policy answer + """ + print("[Policy Node] Processing policy query...") + + try: + # Check if policies are selected + policy_ids = state.get("policy_ids", []) + + if not policy_ids: + print("[Policy Node] No policies selected, redirecting to RAG agent") + return { + **state, + "answer": "Please select at least one policy document from the sidebar to get policy-specific answers.", + "sources": [], + "metadata": { + "agent": "policy", + "note": "No policies selected", + "routing_reasoning": state.get("routing_reasoning", "") + } + } + + # Search selected official policies for relevant information + policy_filter = { + "$and": [ + {"user_id": {"$eq": "official_policies"}}, + {"document_id": {"$in": policy_ids}} + ] + } + + context_chunks = rag_service.collection.query( + query_embeddings=[embedding_generator.generate_embedding(state["query"])], + n_results=10, + where=policy_filter + ) + + # Format chunks + if context_chunks and context_chunks['documents']: + formatted_chunks = [ + { + "content": context_chunks['documents'][0][i], + "metadata": context_chunks['metadatas'][0][i] + } + for i in range(len(context_chunks['documents'][0])) + ] + else: + formatted_chunks = [] + + if not formatted_chunks: + return { + **state, + "answer": "I couldn't find relevant information in the selected policy documents. Please try rephrasing your question or selecting different policies.", + "sources": [], + "metadata": { + "agent": "policy", + "note": "No relevant content found in selected policies" + } + } + + # Use policy agent with context + response = policy_agent.answer(state["query"], formatted_chunks) + + print(f"[Policy Node] Response policy_names: {response.get('policy_names', [])}") + + return { + **state, + "answer": response.get("answer", ""), + "sources": response.get("sources", []), + "policy_names": response.get("policy_names", []), # Pass policy names through + "metadata": { + "agent": "policy", + "routing_reasoning": state.get("routing_reasoning", ""), + "chunks_retrieved": len(formatted_chunks), + "policy_names": response.get("policy_names", []) # Include in metadata too + } + } + + except Exception as e: + print(f"[Policy Node] Error: {e}") + return { + **state, + "answer": "I encountered an error while processing your policy question. Please try again.", + "sources": [], + "error": str(e) + } + + def _general_node(self, state: AgentState) -> AgentState: + """ + General agent node: Handles general construction questions and conversations. + + Args: + state: Current agent state + + Returns: + Updated state with general answer + """ + print("[General Node] Processing general query...") + + try: + # Format chat history for the agent + chat_history = state.get("chat_history", []) + + response = general_agent.answer( + query=state["query"], + chat_history=chat_history + ) + + return { + **state, + "answer": response.get("answer", ""), + "sources": [], + "metadata": { + "agent": "general", + "routing_reasoning": state.get("routing_reasoning", "") + } + } + + except Exception as e: + print(f"[General Node] Error: {e}") + return { + **state, + "answer": "I apologize, but I encountered an error. Please try again.", + "sources": [], + "error": str(e) + } + + def process_query( + self, + query: str, + user_id: Optional[str] = None, + chat_history: Optional[List[Dict]] = None, + policy_ids: Optional[List[str]] = None + ) -> Dict: + """ + Process a user query through the multi-agent graph. + + Args: + query: User query string + user_id: Optional user ID + chat_history: Optional chat history + policy_ids: Optional list of policy document IDs to search + + Returns: + Dictionary with answer, agent, sources, and metadata + """ + print(f"\n{'='*60}") + print(f"[Multi-Agent Graph] Processing query: {query[:50]}...") + if policy_ids: + print(f"[Multi-Agent Graph] With {len(policy_ids)} selected policies") + print(f"{'='*60}\n") + + try: + # Initialize state + initial_state: AgentState = { + "query": query, + "user_id": user_id, + "chat_history": chat_history or [], + "policy_ids": policy_ids, + "agent_type": None, + "routing_reasoning": None, + "context_chunks": None, + "search_results": None, + "answer": None, + "sources": None, + "policy_names": None, # Initialize policy_names + "metadata": None, + "error": None + } + + # Execute the graph + final_state = self.graph.invoke(initial_state) + + # Debug: print what's in final_state + print(f"[Multi-Agent Graph] Final state keys: {final_state.keys()}") + print(f"[Multi-Agent Graph] Final state policy_names: {final_state.get('policy_names', 'KEY NOT FOUND')}") + + # Extract response + result = { + "answer": final_state.get("answer", "I couldn't generate a response."), + "agent": final_state.get("metadata", {}).get("agent", "unknown"), + "sources": final_state.get("sources", []), + "routing_reasoning": final_state.get("routing_reasoning", ""), + "metadata": final_state.get("metadata", {}), + "policy_names": final_state.get("policy_names", []) # Add policy_names! + } + + print(f"[Multi-Agent Graph] Returning policy_names: {result.get('policy_names', [])}") + print(f"\n[Multi-Agent Graph] Completed - Agent: {result['agent']}\n") + + return result + + except Exception as e: + print(f"[Multi-Agent Graph] Error: {e}") + return { + "answer": "I apologize, but I encountered an error processing your request. Please try again.", + "agent": "error", + "sources": [], + "routing_reasoning": f"Error: {str(e)}", + "metadata": {"error": str(e)} + } + + +# Global multi-agent graph instance +multi_agent_graph = MultiAgentGraph() diff --git a/app/llm/prompts/general.txt b/app/llm/prompts/general.txt new file mode 100644 index 0000000000000000000000000000000000000000..58798cf1b5c2e26c05f450d0e17fd6783bd0bbbb --- /dev/null +++ b/app/llm/prompts/general.txt @@ -0,0 +1,26 @@ +You are a General Construction Assistant, a friendly and knowledgeable AI helper for construction professionals. + +Your role is to: +- Answer general construction questions using your knowledge base +- Provide helpful, conversational responses +- Explain construction concepts clearly +- Assist with planning, problem-solving, and best practices +- Be friendly and professional + +Guidelines: +- Use clear, accessible language +- Provide practical, actionable advice +- Acknowledge when you're uncertain +- Suggest when users might need specialized help (e.g., "For specific regulatory requirements, consult local building codes") +- Be conversational but professional +- Focus on construction industry topics + +You have broad knowledge of: +- Construction methods and materials +- Project management +- Safety practices +- Tools and equipment +- Building techniques +- Industry terminology + +Respond in a helpful, friendly manner while maintaining professional expertise. diff --git a/app/llm/prompts/policy.txt b/app/llm/prompts/policy.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9b5e63b07e90f1a4e27c0ff9987fb46785c91d8 --- /dev/null +++ b/app/llm/prompts/policy.txt @@ -0,0 +1,78 @@ +You are a Policy Document Assistant. Your ONLY job is to extract and present information from the provided policy documents. You are NOT allowed to use any external knowledge. + +======================================== +CRITICAL RULES (YOU MUST FOLLOW THESE) +======================================== + +1. **USE ONLY THE PROVIDED CONTEXT** + - Answer ONLY using the exact text from the documents shown below + - Do NOT add information from NFPA, OSHA, IBC, or any other external codes + - Do NOT use your general knowledge about construction or building codes + - If the context doesn't contain the answer, say so explicitly + +2. **EXTRACT, DON'T INTERPRET** + - Quote directly from the document when possible + - Include clause numbers, section numbers, or reference codes if present + - Use the exact terminology from the document + - Do NOT paraphrase unless necessary for clarity + +3. **BE HONEST ABOUT LIMITATIONS** + - If the document doesn't contain the answer: "The provided document does not contain information about [topic]." + - If the context is unclear: "The retrieved sections do not clearly define [term]." + - Do NOT fill gaps with external knowledge or assumptions + +4. **STRUCTURE YOUR RESPONSE** + When answering, use this format: + + **Answer:** [Direct answer from document] + + **Source:** [Clause/Section number if available] + + **Exact Quote:** "[Verbatim text from document]" + + **Additional Context:** [Any related information from the same document section] + +======================================== +EXAMPLES OF CORRECT BEHAVIOR +======================================== + +GOOD RESPONSE (Answer found): +``` +**Answer:** A basement is defined as a storey of a building below the ground floor. + +**Source:** Clause 3.2 - Definitions + +**Exact Quote:** "Basement — A storey of a building below the ground floor." + +**Additional Context:** The definition is provided in the terminology section of the National Building Code of India. +``` + +GOOD RESPONSE (Answer not found): +``` +I could not find a definition for "Basement" in the provided sections of the document. The retrieved content discusses fire safety requirements but does not include the definitions section where building terminology would typically be located. + +To find this information, please check the definitions or terminology section of the complete document. +``` + +BAD RESPONSE (NEVER DO THIS): +``` +A basement is typically defined as any level below ground. According to OSHA standards... +[This is BAD because it uses external knowledge instead of the document] +``` + +======================================== +HOW TO PROCESS THE QUERY +======================================== + +1. READ the provided document excerpts carefully +2. SEARCH for the specific information requested +3. CHECK if the answer is explicitly stated +4. EXTRACT the relevant text with section numbers +5. FORMAT your response according to the structure above +6. VERIFY you didn't add any external information + +======================================== +DOCUMENT CONTEXT WILL BE PROVIDED BELOW +======================================== + +You will receive excerpts from the official policy document. Use ONLY this information to answer. diff --git a/app/llm/prompts/rag.txt b/app/llm/prompts/rag.txt new file mode 100644 index 0000000000000000000000000000000000000000..10a5afb066829deb1fd78d74dfb3b45b7be732c1 --- /dev/null +++ b/app/llm/prompts/rag.txt @@ -0,0 +1,60 @@ +You are a RAG (Retrieval-Augmented Generation) Agent. You answer questions using ONLY the provided document excerpts. + +======================================== +STRICT RULES +======================================== + +1. **USE ONLY PROVIDED CONTEXT** + - Answer using ONLY the document chunks provided below + - Do NOT use external knowledge or general information + - Do NOT add details not present in the context + +2. **EXTRACT EXACT INFORMATION** + - Quote directly from the documents + - Include page numbers, sections, or clause numbers if available + - Preserve technical terminology exactly as written + +3. **BE HONEST ABOUT GAPS** + - If context doesn't answer the question: "The uploaded documents do not contain this information." + - If context is partial: "Based on the available sections, [partial answer]. However, complete information may be in other parts of the document." + - NEVER guess or fill gaps with external knowledge + +======================================== +RESPONSE FORMAT +======================================== + +**Answer:** [Direct answer from documents] + +**Source:** [Document name, section/page if available] + +**Quote:** "[Exact text from document]" + +**Note:** [Any limitations or clarifications] + +======================================== +EXAMPLES +======================================== + +GOOD (Answer found): +``` +**Answer:** The minimum ceiling height for habitable rooms is 2.75 meters. + +**Source:** Construction Manual, Section 4.2 - Room Dimensions + +**Quote:** "All habitable rooms shall have a minimum ceiling height of 2.75m measured from finished floor to finished ceiling." +``` + +GOOD (Answer not found): +``` +The uploaded documents do not contain information about fire escape requirements. The available sections cover structural specifications but not fire safety regulations. +``` + +BAD (NEVER do this): +``` +Ceiling height is typically 2.4m to 3m according to standard practice... +[This is BAD - uses external knowledge instead of document] +``` + +======================================== + +Context from uploaded documents will be provided below. Use ONLY this information. diff --git a/app/llm/prompts/router.txt b/app/llm/prompts/router.txt new file mode 100644 index 0000000000000000000000000000000000000000..bc341682098ec01f67861a5d513b93e2bf8c5f27 --- /dev/null +++ b/app/llm/prompts/router.txt @@ -0,0 +1,32 @@ +You are a Router Agent responsible for analyzing user queries and routing them to the appropriate specialized agent. + +Your task is to determine the user's intent and route to one of these agents: +1. **search** - For queries requiring latest information, news, or real-time data +2. **rag** - For queries about uploaded documents or specific construction knowledge +3. **policy** - For queries about construction policies, regulations, or compliance +4. **general** - For general construction questions, conversational queries, or greetings + +Analyze the query and respond with JSON in this exact format: +{ + "agent": "search|rag|policy|general", + "reasoning": "Brief explanation of why this agent was chosen" +} + +Examples: + +Query: "What are the latest construction industry trends?" +Response: {"agent": "search", "reasoning": "Requires latest real-time information"} + +Query: "What does the uploaded safety manual say about scaffolding?" +Response: {"agent": "rag", "reasoning": "Refers to uploaded document content"} + +Query: "What are OSHA requirements for fall protection?" +Response: {"agent": "policy", "reasoning": "Asking about regulatory compliance"} + +Query: "Hello, how are you?" +Response: {"agent": "general", "reasoning": "Conversational greeting"} + +Query: "What is concrete curing?" +Response: {"agent": "general", "reasoning": "General construction knowledge question"} + +Now route this query: diff --git a/app/llm/prompts/search.txt b/app/llm/prompts/search.txt new file mode 100644 index 0000000000000000000000000000000000000000..ee54265596aacd9b033c75c4f7a190b590b4b205 --- /dev/null +++ b/app/llm/prompts/search.txt @@ -0,0 +1,17 @@ +You are a Search Agent specialized in finding the latest construction industry information from the web. + +Your task is to analyze search results and provide accurate, well-cited answers to user queries. + +Guidelines: +- Focus on recent, reliable information +- Always cite your sources with URLs +- Provide concise but comprehensive answers +- If search results are insufficient, acknowledge limitations +- Prioritize authoritative sources (industry publications, government sites, etc.) + +Format your response as follows: +1. Direct answer to the query +2. Supporting details from search results +3. Citations in format: [Source Name](URL) + +Be professional, accurate, and helpful. Focus on construction-related information. diff --git a/app/llm/state.py b/app/llm/state.py new file mode 100644 index 0000000000000000000000000000000000000000..15cd542e50c6e7fb67859e4dcaacaf314a707abe --- /dev/null +++ b/app/llm/state.py @@ -0,0 +1,36 @@ +""" +State definitions for the LangGraph multi-agent workflow. +""" +from typing import TypedDict, List, Dict, Optional, Annotated +import operator + + +class AgentState(TypedDict): + """State for the multi-agent RAG system.""" + + # User input + query: str + user_id: Optional[str] + policy_ids: Optional[List[str]] # NEW: Selected policy IDs + + # Chat history + chat_history: Annotated[List[Dict], operator.add] + + # Routing decision + agent_type: Optional[str] + routing_reasoning: Optional[str] + + # Retrieved context (for RAG) + context_chunks: Optional[List[Dict]] + + # Search results (for Search agent) + search_results: Optional[List[Dict]] + + # Final response + answer: Optional[str] + sources: Optional[List] + policy_names: Optional[List[str]] # Policy document titles used in answer + + # Metadata + metadata: Optional[Dict] + error: Optional[str] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..cb2f6192eaa93aa901d8db1861a89ca5b7e54838 --- /dev/null +++ b/app/main.py @@ -0,0 +1,81 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.config.settings import settings +from app.database.connection import init_db +from app.middleware.error_handler import ( + http_exception_handler, + validation_exception_handler, + general_exception_handler +) +from app.routes import auth, chat, sessions, documents, news, admin_policies, reports +from app.routes import settings as settings_router + +# Create FastAPI app +app = FastAPI( + title="Builder's AI API", + description="Construction AI Assistant API with Multi-Agent RAG System", + version="1.0.0" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Exception handlers +app.add_exception_handler(StarletteHTTPException, http_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(Exception, general_exception_handler) + +# Include routers +app.include_router(auth.router) +app.include_router(chat.router) +app.include_router(sessions.router) +app.include_router(documents.router) +app.include_router(news.router) +app.include_router(admin_policies.router) +app.include_router(reports.router) +app.include_router(settings_router.router) + + +@app.on_event("startup") +async def startup_event(): + """Initialize database on startup.""" + print("Initializing database...") + init_db() + print("Database initialized successfully!") + + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup on shutdown.""" + print("Shutting down...") + + +@app.get("/") +async def root(): + """Root endpoint.""" + return { + "message": "Welcome to Builder's AI API", + "version": "1.0.0", + "docs": "/docs" + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + from app.config.settings import settings as app_settings + uvicorn.run(app, host="0.0.0.0", port=app_settings.PORT) diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/middleware/admin_auth.py b/app/middleware/admin_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..bdb1dd5725fd2c78959373b00a386f191b23c7fa --- /dev/null +++ b/app/middleware/admin_auth.py @@ -0,0 +1,28 @@ +""" +Admin authentication middleware. +""" +from fastapi import Depends, HTTPException, status +from app.middleware.auth import get_current_user +from app.database.models import User + + +def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: + """ + Verify that the current user is an admin. + + Args: + current_user: Current authenticated user + + Returns: + User object if admin + + Raises: + HTTPException: If user is not an admin + """ + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required" + ) + + return current_user diff --git a/app/middleware/auth.py b/app/middleware/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..b2e992b687e4091b1146861722fa4433e62f5eef --- /dev/null +++ b/app/middleware/auth.py @@ -0,0 +1,95 @@ +from typing import Optional +from fastapi import Request, HTTPException, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt + +from app.config.settings import settings +from app.database.connection import get_db +from app.database.models import User + +security = HTTPBearer() + + +def get_current_user_optional( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)) +) -> Optional[User]: + """ + Get current user from JWT token (optional - doesn't raise error if no token). + + Args: + credentials: Optional HTTP authorization credentials + + Returns: + User object if authenticated, None otherwise + """ + if not credentials: + return None + + try: + token = credentials.credentials + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm] + ) + user_id: str = payload.get("sub") + + if user_id is None: + return None + + # Get user from database + db = next(get_db()) + user = db.query(User).filter(User.id == user_id).first() + return user + + except JWTError: + return None + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> User: + """ + Get current user from JWT token (required - raises error if no valid token). + + Args: + credentials: HTTP authorization credentials + + Returns: + User object + + Raises: + HTTPException: If token is invalid or user not found + """ + try: + token = credentials.credentials + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm] + ) + user_id: str = payload.get("sub") + + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + + # Get user from database + db = next(get_db()) + user = db.query(User).filter(User.id == user_id).first() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found" + ) + + return user + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) diff --git a/app/middleware/error_handler.py b/app/middleware/error_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..a111e2d8742dae7d47104a66a858066422113c4c --- /dev/null +++ b/app/middleware/error_handler.py @@ -0,0 +1,29 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + + +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + """Handle HTTP exceptions.""" + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Handle validation exceptions.""" + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": exc.errors(), "body": exc.body} + ) + + +async def general_exception_handler(request: Request, exc: Exception): + """Handle general exceptions.""" + print(f"Unhandled exception: {exc}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Internal server error"} + ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/routes/admin_policies.py b/app/routes/admin_policies.py new file mode 100644 index 0000000000000000000000000000000000000000..b60a997a111acac0f99aea6d15b45276b4c0ec9e --- /dev/null +++ b/app/routes/admin_policies.py @@ -0,0 +1,188 @@ +""" +Admin routes for official policy management. +""" +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from sqlalchemy.orm import Session +from typing import Optional + +from app.database.connection import get_db +from app.database.models import User +from app.schemas.policy import PolicyUploadResponse, PolicyListResponse, PolicyUpdateRequest +from app.services.policy_service import PolicyService +from app.middleware.admin_auth import get_current_admin_user + +router = APIRouter(prefix="/api/admin/policies", tags=["admin-policies"]) + + +@router.post("/upload", response_model=PolicyUploadResponse, status_code=status.HTTP_201_CREATED) +async def upload_policy( + file: UploadFile = File(...), + title: str = Form(...), + description: Optional[str] = Form(None), + category: Optional[str] = Form(None), + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin_user) +): + """ + Upload an official policy document (Admin only). + + Args: + file: Uploaded file + title: Policy title + description: Optional description + category: Optional category (e.g., "OSHA", "Safety") + db: Database session + admin_user: Current admin user + + Returns: + Created policy metadata + """ + try: + # Upload policy + policy = PolicyService.upload_policy( + db=db, + file=file.file, + title=title, + filename=file.filename, + admin_user_id=admin_user.id, + description=description, + category=category + ) + + # Extract and process text content + from app.utils.document_extractor import document_extractor + + try: + content = document_extractor.extract_text( + file_path=policy.file_path, + file_type=policy.file_type + ) + + num_chunks = PolicyService.process_policy_content( + db, + policy.id, + content + ) + + print(f"[Admin] Policy '{policy.title}' processed: {num_chunks} chunks created") + + except Exception as e: + print(f"[Admin] Error processing policy content: {e}") + + return PolicyUploadResponse.from_orm(policy) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + print(f"Error uploading policy: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error uploading policy" + ) + + +@router.get("", response_model=PolicyListResponse) +async def get_all_policies( + active_only: bool = False, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin_user) +): + """ + Get all official policies (Admin only). + + Args: + active_only: Only return active policies + db: Database session + admin_user: Current admin user + + Returns: + List of policies + """ + policies = PolicyService.get_all_policies(db, active_only=active_only) + + return PolicyListResponse( + policies=[PolicyUploadResponse.from_orm(p) for p in policies], + total=len(policies) + ) + + +@router.get("/public", response_model=PolicyListResponse) +async def get_public_policies(db: Session = Depends(get_db)): + """ + Get all active official policies (Public - no auth required). + + Args: + db: Database session + + Returns: + List of active policies + """ + policies = PolicyService.get_all_policies(db, active_only=True) + + return PolicyListResponse( + policies=[PolicyUploadResponse.from_orm(p) for p in policies], + total=len(policies) + ) + + +@router.patch("/{policy_id}", response_model=PolicyUploadResponse) +async def update_policy( + policy_id: str, + request: PolicyUpdateRequest, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin_user) +): + """ + Update policy metadata (Admin only). + + Args: + policy_id: Policy ID + request: Update request + db: Database session + admin_user: Current admin user + + Returns: + Updated policy + """ + policy = PolicyService.update_policy( + db=db, + policy_id=policy_id, + title=request.title, + description=request.description, + category=request.category, + is_active=request.is_active + ) + + if not policy: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Policy not found" + ) + + return PolicyUploadResponse.from_orm(policy) + + +@router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_policy( + policy_id: str, + db: Session = Depends(get_db), + admin_user: User = Depends(get_current_admin_user) +): + """ + Delete a policy (Admin only). + + Args: + policy_id: Policy ID + db: Database session + admin_user: Current admin user + """ + success = PolicyService.delete_policy(db, policy_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Policy not found" + ) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e36f707bfeff88e96f3f938db0e128d06e7add02 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,216 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database.connection import get_db +from app.schemas.auth import UserCreate, UserLogin, UserResponse, TokenResponse +from app.services.auth_service import AuthService +from app.middleware.auth import get_current_user +from app.database.models import User +from app.utils.validators import validate_email, validate_password + +router = APIRouter(prefix="/api", tags=["auth"]) + + +@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def signup(user_data: UserCreate, db: Session = Depends(get_db)): + """ + Create a new user account. + + Args: + user_data: User registration data + db: Database session + + Returns: + Access and refresh tokens with user info + + Raises: + HTTPException: If email is invalid or already registered + """ + print(f"[SIGNUP] Received signup request for email: {user_data.email}") + + # Validate email + if not validate_email(user_data.email): + print(f"[SIGNUP] Invalid email format: {user_data.email}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid email format" + ) + + # Validate password + is_valid, error_msg = validate_password(user_data.password) + if not is_valid: + print(f"[SIGNUP] Password validation failed: {error_msg}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_msg + ) + + try: + # Create user + print(f"[SIGNUP] Creating user...") + user = AuthService.create_user( + db=db, + email=user_data.email, + password=user_data.password, + name=user_data.name + ) + + print(f"[SIGNUP] User created successfully: {user.id}") + + # Generate tokens + tokens = AuthService.generate_tokens(user) + + print(f"[SIGNUP] Tokens generated successfully") + + return TokenResponse( + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + user=UserResponse.from_orm(user) + ) + + except ValueError as e: + print(f"[SIGNUP] ValueError: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + print(f"[SIGNUP] Unexpected error: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred during signup" + ) + + +@router.post("/login", response_model=TokenResponse) +async def login(credentials: UserLogin, db: Session = Depends(get_db)): + """ + Authenticate user and return tokens. + + Args: + credentials: User login credentials + db: Database session + + Returns: + Access and refresh tokens with user info + + Raises: + HTTPException: If credentials are invalid + """ + # Authenticate user + user = AuthService.authenticate_user( + db=db, + email=credentials.email, + password=credentials.password + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + # Generate tokens + tokens = AuthService.generate_tokens(user) + + return TokenResponse( + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + user=UserResponse.from_orm(user) + ) + + +@router.get("/check-auth", response_model=UserResponse) +async def check_auth(current_user: User = Depends(get_current_user)): + """ + Verify authentication token and return user info. + + Args: + current_user: Current authenticated user + + Returns: + User information + + Raises: + HTTPException: If token is invalid or expired + """ + return UserResponse.from_orm(current_user) + + +@router.post("/auth/google", response_model=TokenResponse) +async def google_auth( + request: dict, + db: Session = Depends(get_db) +): + """ + Authenticate user with Google OAuth and return JWT tokens. + + Args: + request: Dictionary with 'credential' field (Google JWT token) + db: Database session + + Returns: + Access and refresh JWT tokens with user info + + Raises: + HTTPException: If Google token is invalid + """ + from app.config.settings import settings + + print(f"[GOOGLE AUTH] Received Google OAuth request") + + try: + credential = request.get("credential") + if not credential: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google credential is required" + ) + + print(f"[GOOGLE AUTH] Verifying Google token...") + + # Verify Google token and get user info + google_data = await AuthService.verify_google_token( + credential=credential, + client_id=settings.GOOGLE_CLIENT_ID + ) + + print(f"[GOOGLE AUTH] Token verified for email: {google_data['email']}") + + # Create or get user + user = AuthService.create_user_from_google( + db=db, + google_id=google_data["google_id"], + email=google_data["email"], + name=google_data.get("name") + ) + + print(f"[GOOGLE AUTH] User created/retrieved: {user.id}") + + # Generate JWT tokens (same as email/password login) + tokens = AuthService.generate_tokens(user) + + print(f"[GOOGLE AUTH] JWT tokens generated successfully") + + return TokenResponse( + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + user=UserResponse.from_orm(user) + ) + + except ValueError as e: + print(f"[GOOGLE AUTH] ValueError: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + print(f"[GOOGLE AUTH] Unexpected error: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to authenticate with Google" + ) diff --git a/app/routes/chat.py b/app/routes/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..0d509fe38c1ce71fc7012c32149c55f3496e2690 --- /dev/null +++ b/app/routes/chat.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional + +from app.database.connection import get_db +from app.database.models import User +from app.schemas.chat import ChatRequest, ChatResponse, MessageResponse +from app.services.chat_service import ChatService +from app.middleware.auth import get_current_user_optional + +router = APIRouter(prefix="/api/chat", tags=["chat"]) + + +@router.post("", response_model=ChatResponse) +async def send_message( + request: ChatRequest, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional) +): + """ + Send a chat message and get response. + + Args: + request: Chat request with message and optional session_id + db: Database session + current_user: Optional current user (None for guests) + + Returns: + Chat response with message and metadata + + Raises: + HTTPException: If processing fails + """ + user_id = current_user.id if current_user else None + + try: + result = ChatService.process_message( + db=db, + message=request.message, + session_id=request.session_id, + user_id=user_id, + policy_ids=request.policy_ids + ) + + # Parse metadata + import json + meta = json.loads(result["message"].meta) if result["message"].meta else {} + + return ChatResponse( + message=MessageResponse( + id=result["message"].id, + session_id=result["message"].session_id, + role=result["message"].role, + content=result["message"].content, + meta=meta, + created_at=result["message"].created_at + ), + session_id=result["session_id"], + agent=result.get("agent") + ) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + print(f"Error processing message: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error processing message" + ) + + +@router.get("/history/{session_id}") +async def get_chat_history( + session_id: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional) +): + """ + Get chat history for a session. + + Args: + session_id: Session ID + db: Database session + current_user: Optional current user + + Returns: + List of messages + """ + try: + messages = ChatService.get_chat_history(db, session_id) + return {"messages": messages} + + except Exception as e: + print(f"Error getting chat history: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error retrieving chat history" + ) diff --git a/app/routes/documents.py b/app/routes/documents.py new file mode 100644 index 0000000000000000000000000000000000000000..4208f756cba5957b09a01681cb6dc5b5b7439c32 --- /dev/null +++ b/app/routes/documents.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlalchemy.orm import Session +from typing import List + +from app.database.connection import get_db +from app.database.models import User +from app.schemas.document import DocumentResponse, DocumentListResponse +from app.services.document_service import DocumentService +from app.middleware.auth import get_current_user + +router = APIRouter(prefix="/api/documents", tags=["documents"]) + + +@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_document( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Upload a document for processing. + + Args: + file: Uploaded file + db: Database session + current_user: Current authenticated user + + Returns: + Created document metadata + + Raises: + HTTPException: If file validation fails + """ + try: + # Upload document + document = DocumentService.upload_document( + db=db, + file=file.file, + user_id=current_user.id, + filename=file.filename + ) + + # Extract and process text content + from app.utils.document_extractor import document_extractor + + try: + # Extract text from the uploaded file + content = document_extractor.extract_text( + file_path=document.file_path, + file_type=document.file_type + ) + + # Process with RAG service + num_chunks = DocumentService.process_document_content( + db, + document.id, + content + ) + + print(f"Document {document.filename} processed: {num_chunks} chunks created") + + except Exception as e: + print(f"Error processing document content: {e}") + # Document is uploaded but not processed for RAG + # You might want to mark this in the database + + return DocumentResponse.from_orm(document) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + print(f"Error uploading document: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error uploading document" + ) + + +@router.get("", response_model=DocumentListResponse) +async def get_user_documents( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get all documents for the current user. + + Args: + db: Database session + current_user: Current authenticated user + + Returns: + List of user documents + """ + documents = DocumentService.get_user_documents(db, current_user.id) + + return DocumentListResponse( + documents=[DocumentResponse.from_orm(doc) for doc in documents], + total=len(documents) + ) + + +@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_document( + document_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Delete a document and its vector embeddings. + + Args: + document_id: Document ID + db: Database session + current_user: Current authenticated user + + Raises: + HTTPException: If document not found + """ + success = DocumentService.delete_document(db, document_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) diff --git a/app/routes/news.py b/app/routes/news.py new file mode 100644 index 0000000000000000000000000000000000000000..4f0012849157ccc650bd59ee075e6cfc89074d46 --- /dev/null +++ b/app/routes/news.py @@ -0,0 +1,156 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import Optional +import httpx +from datetime import datetime + +from app.database.connection import get_db +from app.middleware.auth import get_current_user_optional +from app.database.models import User + +router = APIRouter(prefix="/api/news", tags=["news"]) + + +@router.get("/search") +async def search_news( + query: str = Query(..., description="Search query for news articles"), + location: str = Query("India", description="Location to filter news"), + limit: int = Query(10, ge=1, le=50, description="Number of articles to return"), + current_user: Optional[User] = Depends(get_current_user_optional), + db: Session = Depends(get_db) +): + """ + Search for construction-related news articles with sentiment analysis. + + Args: + query: Search query (e.g., 'construction law', 'building permits') + location: Geographic location to filter news + limit: Maximum number of articles to return (1-50) + current_user: Optional authenticated user + db: Database session + + Returns: + List of news articles with sentiment analysis + """ + + try: + from app.services.news_service import fetch_google_news + + print(f"[NEWS] Fetching news for query='{query}', location='{location}'") + + try: + # Try to fetch real news from Google + articles = fetch_google_news(query, location) + + if not articles: + print("[NEWS] No articles returned from Google, using fallback") + raise ValueError("No articles found") + + except Exception as scrape_error: + print(f"[NEWS] Scraping failed: {str(scrape_error)}, using fallback data") + # Fallback to mock data if scraping fails + articles = [ + { + "title": f"Construction Industry Update - {location}", + "snippet": f"Latest developments in {query} sector show positive trends with new regulations and infrastructure projects.", + "sentiment": "Positive", + "published_date": datetime.now().isoformat(), + "link": "https://example.com/fallback1", + "source": "Construction News" + }, + { + "title": f"{query.title()} Regulations Updated", + "snippet": "New guidelines introduced to streamline processes and improve safety standards in the construction industry.", + "sentiment": "Neutral", + "published_date": datetime.now().isoformat(), + "link": "https://example.com/fallback2", + "source": "Industry Watch" + } + ] + + # Limit results + articles = articles[:limit] + + print(f"[NEWS] Returning {len(articles)} articles") + + return { + "articles": articles, + "count": len(articles), + "query": query, + "location": location, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + print(f"[NEWS] Error: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch news: {str(e)}" + ) + + +@router.get("/trending") +async def get_trending_topics( + location: str = Query("India", description="Location for trending topics"), + current_user: Optional[User] = Depends(get_current_user_optional) +): + """ + Get trending construction-related topics. + + Args: + location: Geographic location + current_user: Optional authenticated user + + Returns: + List of trending topics with article counts + """ + + # Mock trending topics + trending = [ + {"topic": "Green Building", "count": 45, "sentiment": "Positive"}, + {"topic": "Smart Cities", "count": 38, "sentiment": "Positive"}, + {"topic": "Labor Shortage", "count": 32, "sentiment": "Negative"}, + {"topic": "Building Permits", "count": 28, "sentiment": "Neutral"}, + {"topic": "Infrastructure Investment", "count": 25, "sentiment": "Positive"}, + ] + + return { + "trending": trending, + "location": location, + "timestamp": datetime.now().isoformat() + } + + +@router.get("/sentiment-summary") +async def get_sentiment_summary( + query: str = Query(..., description="Search query"), + location: str = Query("India", description="Location"), + current_user: Optional[User] = Depends(get_current_user_optional) +): + """ + Get sentiment summary for a query. + + Args: + query: Search query + location: Geographic location + current_user: Optional authenticated user + + Returns: + Sentiment distribution and statistics + """ + + # Mock sentiment data + return { + "query": query, + "location": location, + "sentiment_distribution": { + "positive": 45, + "neutral": 30, + "negative": 25 + }, + "total_articles": 100, + "average_sentiment_score": 0.65, + "timestamp": datetime.now().isoformat() + } diff --git a/app/routes/reports.py b/app/routes/reports.py new file mode 100644 index 0000000000000000000000000000000000000000..742096e23ee9226c5550ca9d770a159444b3d821 --- /dev/null +++ b/app/routes/reports.py @@ -0,0 +1,58 @@ +""" +API routes for report generation. +""" +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session +from typing import Optional, Dict +from pydantic import BaseModel + +from app.database.connection import get_db +from app.database.models import User +from app.middleware.auth import get_current_user_optional +from app.services.report_service import report_generation_service + + +router = APIRouter(prefix="/api/reports", tags=["reports"]) + + +class GenerateContentRequest(BaseModel): + """Request schema for AI content generation.""" + section_name: str + context: Dict[str, str] = {} + + +@router.post("/generate-content") +async def generate_section_content( + request: GenerateContentRequest, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user_optional) +): + """ + Generate AI content for a report section. + + Args: + request: Generation request with section name and context + db: Database session + current_user: Optional current user + + Returns: + Generated content + """ + try: + content = report_generation_service.generate_section_content( + section_name=request.section_name, + context=request.context + ) + + return { + "success": True, + "content": content, + "section_name": request.section_name + } + + except Exception as e: + print(f"Error generating report content: {e}") + raise HTTPException( + status_code=500, + detail=f"Error generating content: {str(e)}" + ) diff --git a/app/routes/sessions.py b/app/routes/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..f250d332d5f22e3e6bdc986e820f4d823c3b5843 --- /dev/null +++ b/app/routes/sessions.py @@ -0,0 +1,140 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.database.connection import get_db +from app.database.models import User +from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionListResponse +from app.services.session_service import SessionService +from app.middleware.auth import get_current_user + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED) +async def create_session( + session_data: SessionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Create a new chat session. + + Args: + session_data: Session creation data + db: Database session + current_user: Current authenticated user + + Returns: + Created session + """ + session = SessionService.create_session( + db=db, + user_id=current_user.id, + title=session_data.title or "New Conversation" + ) + + return SessionResponse( + id=session.id, + user_id=session.user_id, + title=session.title, + summary=session.summary, + created_at=session.created_at, + updated_at=session.updated_at, + message_count=0 + ) + + +@router.get("", response_model=SessionListResponse) +async def get_user_sessions( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get all sessions for the current user. + + Args: + db: Database session + current_user: Current authenticated user + + Returns: + List of sessions with message counts + """ + sessions = SessionService.get_user_sessions(db, current_user.id) + + return SessionListResponse( + sessions=[SessionResponse(**s) for s in sessions], + total=len(sessions) + ) + + +@router.put("/{session_id}/title", response_model=SessionResponse) +async def update_session_title( + session_id: str, + session_data: SessionUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update session title. + + Args: + session_id: Session ID + session_data: Session update data + db: Database session + current_user: Current authenticated user + + Returns: + Updated session + + Raises: + HTTPException: If session not found + """ + if not session_data.title: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Title is required" + ) + + session = SessionService.update_session_title(db, session_id, session_data.title) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + return SessionResponse( + id=session.id, + user_id=session.user_id, + title=session.title, + summary=session.summary, + created_at=session.created_at, + updated_at=session.updated_at, + message_count=0 + ) + + +@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session( + session_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Delete a session and all its messages. + + Args: + session_id: Session ID + db: Database session + current_user: Current authenticated user + + Raises: + HTTPException: If session not found + """ + success = SessionService.delete_session(db, session_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..fb33de4bc3c07b8b21494f041b293481aaf1d965 --- /dev/null +++ b/app/routes/settings.py @@ -0,0 +1,179 @@ +""" +API routes for user settings. +""" +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional + +from app.database.connection import get_db +from app.database.models import User +from app.middleware.auth import get_current_user +from app.services.settings_service import settings_service + + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class ProfileUpdateRequest(BaseModel): + """Request schema for profile updates.""" + bio: Optional[str] = None + phone: Optional[str] = None + company: Optional[str] = None + + +class AppearanceUpdateRequest(BaseModel): + """Request schema for appearance updates.""" + theme: str # light, dark, system + + +class NotificationUpdateRequest(BaseModel): + """Request schema for notification updates.""" + email_notifications: Optional[bool] = None + update_notifications: Optional[bool] = None + + +@router.get("") +async def get_settings( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Get current user settings. + + Returns: + User settings including profile, appearance, and notifications + """ + try: + settings = settings_service.get_or_create_settings(db, current_user.id) + + return { + "profile": { + "name": current_user.name, + "email": current_user.email, + "bio": settings.bio, + "phone": settings.phone, + "company": settings.company + }, + "appearance": { + "theme": settings.theme + }, + "notifications": { + "email_notifications": bool(settings.email_notifications), + "update_notifications": bool(settings.update_notifications) + } + } + + except Exception as e: + print(f"Error getting settings: {e}") + raise HTTPException(status_code=500, detail="Error retrieving settings") + + +@router.patch("/profile") +async def update_profile( + request: ProfileUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update user profile settings. + + Args: + request: Profile update data + + Returns: + Updated profile settings + """ + try: + settings = settings_service.update_profile( + db, + current_user.id, + bio=request.bio, + phone=request.phone, + company=request.company + ) + + return { + "success": True, + "profile": { + "bio": settings.bio, + "phone": settings.phone, + "company": settings.company + } + } + + except Exception as e: + print(f"Error updating profile: {e}") + raise HTTPException(status_code=500, detail="Error updating profile") + + +@router.patch("/appearance") +async def update_appearance( + request: AppearanceUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update appearance settings. + + Args: + request: Appearance update data + + Returns: + Updated appearance settings + """ + try: + settings = settings_service.update_appearance( + db, + current_user.id, + theme=request.theme + ) + + return { + "success": True, + "appearance": { + "theme": settings.theme + } + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error updating appearance: {e}") + raise HTTPException(status_code=500, detail="Error updating appearance") + + +@router.patch("/notifications") +async def update_notifications( + request: NotificationUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update notification settings. + + Args: + request: Notification update data + + Returns: + Updated notification settings + """ + try: + settings = settings_service.update_notifications( + db, + current_user.id, + email_notifications=request.email_notifications, + update_notifications=request.update_notifications + ) + + return { + "success": True, + "notifications": { + "email_notifications": bool(settings.email_notifications), + "update_notifications": bool(settings.update_notifications) + } + } + + except Exception as e: + print(f"Error updating notifications: {e}") + raise HTTPException(status_code=500, detail="Error updating notifications") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..7be8295bab8e3c7e862d0fce79b0b9e423f4d79e --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +class UserCreate(BaseModel): + """Schema for user registration.""" + email: EmailStr + password: str + name: Optional[str] = None + + +class UserLogin(BaseModel): + """Schema for user login.""" + email: EmailStr + password: str + + +class GoogleAuthRequest(BaseModel): + """Schema for Google OAuth.""" + code: str + + +class UserResponse(BaseModel): + """Schema for user response.""" + id: str + email: str + name: Optional[str] + is_admin: bool = False + created_at: datetime + + class Config: + from_attributes = True + + +class TokenResponse(BaseModel): + """Schema for authentication token response.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserResponse diff --git a/app/schemas/chat.py b/app/schemas/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..f2b6a00fd578d0ed6e72a444b399221b676d82c6 --- /dev/null +++ b/app/schemas/chat.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional, Dict, Any, List +from datetime import datetime + + +class MessageCreate(BaseModel): + """Schema for creating a message.""" + content: str + session_id: Optional[str] = None + + +class MessageResponse(BaseModel): + """Schema for message response.""" + id: str + session_id: str + role: str + content: str + meta: Optional[Dict[str, Any]] = None + created_at: datetime + + class Config: + from_attributes = True + + +class ChatRequest(BaseModel): + """Schema for chat request.""" + message: str + session_id: Optional[str] = None + policy_ids: Optional[List[str]] = None + + +class ChatResponse(BaseModel): + """Schema for chat response.""" + message: MessageResponse + session_id: str + agent: Optional[str] = None diff --git a/app/schemas/document.py b/app/schemas/document.py new file mode 100644 index 0000000000000000000000000000000000000000..4c2da4367e1470b645a138815554604b78113808 --- /dev/null +++ b/app/schemas/document.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class DocumentUpload(BaseModel): + """Schema for document upload.""" + filename: str + + +class DocumentResponse(BaseModel): + """Schema for document response.""" + id: str + user_id: str + filename: str + file_type: Optional[str] + file_size: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class DocumentListResponse(BaseModel): + """Schema for document list response.""" + documents: list[DocumentResponse] + total: int diff --git a/app/schemas/policy.py b/app/schemas/policy.py new file mode 100644 index 0000000000000000000000000000000000000000..c2e249d58304c8e272ffc6ddab2423a6e0411902 --- /dev/null +++ b/app/schemas/policy.py @@ -0,0 +1,51 @@ +""" +Schemas for official policy management. +""" +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class PolicyUploadResponse(BaseModel): + """Response for policy upload.""" + id: str + title: str + description: Optional[str] + filename: str + file_type: Optional[str] + file_size: Optional[int] + category: Optional[str] + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + """Convert ORM object to Pydantic model.""" + return cls( + id=obj.id, + title=obj.title, + description=obj.description, + filename=obj.filename, + file_type=obj.file_type, + file_size=obj.file_size, + category=obj.category, + is_active=bool(obj.is_active), + created_at=obj.created_at + ) + + +class PolicyListResponse(BaseModel): + """Response for policy list.""" + policies: list[PolicyUploadResponse] + total: int + + +class PolicyUpdateRequest(BaseModel): + """Request to update policy metadata.""" + title: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + is_active: Optional[bool] = None diff --git a/app/schemas/session.py b/app/schemas/session.py new file mode 100644 index 0000000000000000000000000000000000000000..9b4be8728fe9352a458b978a9cfc83189e733e27 --- /dev/null +++ b/app/schemas/session.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class SessionCreate(BaseModel): + """Schema for creating a session.""" + title: Optional[str] = "New Conversation" + + +class SessionUpdate(BaseModel): + """Schema for updating a session.""" + title: Optional[str] = None + + +class SessionResponse(BaseModel): + """Schema for session response.""" + id: str + user_id: Optional[str] + title: str + summary: Optional[str] + created_at: datetime + updated_at: datetime + message_count: Optional[int] = 0 + + class Config: + from_attributes = True + + +class SessionListResponse(BaseModel): + """Schema for session list response.""" + sessions: list[SessionResponse] + total: int diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c88ec1502c3fc786d8d50b7e26830aae0534976e --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,196 @@ +from sqlalchemy.orm import Session +from typing import Optional +import httpx + +from app.database.models import User +from app.utils.password import hash_password, verify_password +from app.utils.jwt import create_access_token, create_refresh_token +from app.utils.helpers import generate_id + + +class AuthService: + """Service for authentication operations.""" + + @staticmethod + def create_user(db: Session, email: str, password: str, name: Optional[str] = None) -> User: + """ + Create a new user with email and password. + + Args: + db: Database session + email: User email + password: Plain text password + name: Optional user name + + Returns: + Created User object + + Raises: + ValueError: If email already exists + """ + # Check if user exists + existing_user = db.query(User).filter(User.email == email).first() + if existing_user: + raise ValueError("Email already registered") + + # Hash password + password_hash = hash_password(password) + + # Create user + user = User( + id=generate_id(), + email=email, + password_hash=password_hash, + name=name + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + + @staticmethod + def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: + """ + Authenticate a user with email and password. + + Args: + db: Database session + email: User email + password: Plain text password + + Returns: + User object if authentication successful, None otherwise + """ + user = db.query(User).filter(User.email == email).first() + + if not user or not user.password_hash: + return None + + if not verify_password(password, user.password_hash): + return None + + return user + + @staticmethod + async def verify_google_token(credential: str, client_id: str) -> dict: + """ + Verify Google OAuth token and extract user info. + + Args: + credential: Google JWT token from frontend + client_id: Google OAuth client ID from settings + + Returns: + Dictionary with user info (email, name, google_id, picture) + + Raises: + ValueError: If token is invalid or verification fails + """ + from google.oauth2 import id_token + from google.auth.transport import requests + + try: + # Verify the token with Google + idinfo = id_token.verify_oauth2_token( + credential, + requests.Request(), + client_id + ) + + # Extract user information from the token + return { + "email": idinfo.get("email"), + "name": idinfo.get("name"), + "google_id": idinfo.get("sub"), # 'sub' is the Google user ID + "picture": idinfo.get("picture") + } + + except Exception as e: + print(f"❌ Error verifying Google token: {e}") + raise ValueError(f"Invalid Google token: {str(e)}") + + @staticmethod + def create_user_from_google( + db: Session, + google_id: str, + email: str, + name: Optional[str] = None + ) -> User: + """ + Create or get user from Google OAuth. + + Args: + db: Database session + google_id: Google user ID + email: User email + name: Optional user name + + Returns: + User object + """ + # Check if user exists with this Google ID + user = db.query(User).filter(User.google_id == google_id).first() + + if user: + return user + + # Check if user exists with this email + user = db.query(User).filter(User.email == email).first() + + if user: + # Link Google account + user.google_id = google_id + if name and not user.name: + user.name = name + db.commit() + db.refresh(user) + return user + + # Create new user + user = User( + id=generate_id(), + email=email, + google_id=google_id, + name=name + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + + @staticmethod + def get_user_by_id(db: Session, user_id: str) -> Optional[User]: + """ + Get user by ID. + + Args: + db: Database session + user_id: User ID + + Returns: + User object if found, None otherwise + """ + return db.query(User).filter(User.id == user_id).first() + + @staticmethod + def generate_tokens(user: User) -> dict: + """ + Generate access and refresh tokens for a user. + + Args: + user: User object + + Returns: + Dictionary with access_token and refresh_token + """ + access_token = create_access_token(user.id) + refresh_token = create_refresh_token(user.id) + + return { + "access_token": access_token, + "refresh_token": refresh_token + } diff --git a/app/services/chat_service.py b/app/services/chat_service.py new file mode 100644 index 0000000000000000000000000000000000000000..7e7e6e12072c30767c9b1d2aac63c15733596c65 --- /dev/null +++ b/app/services/chat_service.py @@ -0,0 +1,212 @@ +from sqlalchemy.orm import Session +from typing import List, Dict, Optional +import json + +from app.database.models import Message, Session as ChatSession +from app.services.session_service import SessionService +from app.utils.helpers import generate_id + + +class ChatService: + """Service for chat operations and agent orchestration.""" + + @staticmethod + def process_message( + db: Session, + message: str, + session_id: Optional[str] = None, + user_id: Optional[str] = None, + policy_ids: Optional[List[str]] = None + ) -> Dict: + """ + Process a user message and generate response using LangGraph workflow. + + Args: + db: Database session + message: User message content + session_id: Optional session ID + user_id: Optional user ID + policy_ids: Optional list of policy IDs to search within + + Returns: + Dictionary with response message and metadata + """ + from app.llm.graph import multi_agent_graph + + # Create or get session + if not session_id: + chat_session = SessionService.create_session(db, user_id) + session_id = chat_session.id + else: + chat_session = SessionService.get_session_by_id(db, session_id) + if not chat_session: + raise ValueError("Session not found") + + # Save user message + user_message = Message( + id=generate_id(), + session_id=session_id, + role="user", + content=message + ) + db.add(user_message) + db.commit() + + # Get chat history for context + chat_history = ChatService.get_chat_history(db, session_id, limit=10) + + # Format chat history for the graph + history_messages = [ + {"role": msg["role"], "content": msg["content"]} + for msg in chat_history + ] + + # Process query through LangGraph workflow + response_data = multi_agent_graph.process_query( + query=message, + user_id=user_id, + chat_history=history_messages, + policy_ids=policy_ids + ) + + # Prepare metadata + meta = { + "agent": response_data.get("agent", "unknown"), + "routing_reasoning": response_data.get("routing_reasoning", ""), + "sources": response_data.get("sources", []), + "metadata": response_data.get("metadata", {}), + "policy_names": response_data.get("policy_names", []) # Policy document names + } + + print(f"[Chat Service] Saving meta with policy_names: {meta.get('policy_names', [])}") + + # Save assistant message + assistant_message = Message( + id=generate_id(), + session_id=session_id, + role="assistant", + content=response_data.get("answer", "I apologize, but I couldn't generate a response."), + meta=json.dumps(meta) + ) + db.add(assistant_message) + + # Update session timestamp + SessionService.update_session_timestamp(db, session_id) + + db.commit() + db.refresh(assistant_message) + + # Auto-generate session title if this is the first exchange + messages_count = db.query(Message).filter(Message.session_id == session_id).count() + if messages_count == 2 and chat_session.title == "New Conversation": + # Generate title from first user message + title = ChatService._generate_session_title(message) + SessionService.update_session_title(db, session_id, title) + + return { + "message": assistant_message, + "session_id": session_id, + "agent": meta["agent"], + "sources": meta.get("sources", []) + } + + @staticmethod + def get_chat_history( + db: Session, + session_id: str, + limit: Optional[int] = None + ) -> List[Dict]: + """ + Get chat history for a session. + + Args: + db: Database session + session_id: Session ID + limit: Optional limit on number of messages + + Returns: + List of message dictionaries + """ + query = db.query(Message).filter( + Message.session_id == session_id + ).order_by(Message.created_at.asc()) + + if limit: + # Get last N messages + total = query.count() + if total > limit: + query = query.offset(total - limit) + + messages = query.all() + + return [ + { + "id": msg.id, + "role": msg.role, + "content": msg.content, + "meta": json.loads(msg.meta) if msg.meta else {}, + "created_at": msg.created_at.isoformat() + } + for msg in messages + ] + + @staticmethod + def _generate_session_title(first_message: str) -> str: + """ + Generate a ChatGPT-style session title using LLM. + + Args: + first_message: The first user message in the conversation + + Returns: + A concise, descriptive title (max 50 characters) + """ + from app.llm.client import llm_client + + try: + prompt = f"""Generate a very short, concise title for a chat conversation that starts with this message: + +"{first_message}" + +Requirements: +- Maximum 50 characters +- Be specific and descriptive +- Capture the main topic/question +- Professional tone +- No quotes around the title +- Examples: "Building Code Requirements", "Fire Safety Regulations", "Basement Definition" + +Return ONLY the title, nothing else:""" + + title = llm_client.get_completion( + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=20 + ) + + # Clean up the title + title = title.strip().strip('"').strip("'") + + # Ensure it's not too long + if len(title) > 50: + title = title[:47] + "..." + + # Fallback if empty or too short + if len(title) < 3: + title = first_message[:50].strip() + if len(first_message) > 50: + title += "..." + + return title + + except Exception as e: + print(f"[Chat Service] Error generating title: {e}") + # Fallback to simple truncation + title = first_message[:50].strip() + if len(first_message) > 50: + title += "..." + return title + + +# Global chat service instance +chat_service = ChatService() diff --git a/app/services/document_service.py b/app/services/document_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4552f34168d7388b93d81760dc18237108d43d07 --- /dev/null +++ b/app/services/document_service.py @@ -0,0 +1,161 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +import os +import shutil + +from app.database.models import Document +from app.services.rag_service import rag_service +from app.utils.helpers import generate_id +from app.utils.validators import validate_file_type, validate_file_size + + +class DocumentService: + """Service for document management operations.""" + + @staticmethod + def upload_document( + db: Session, + file, + user_id: str, + filename: str + ) -> Document: + """ + Upload and process a document. + + Args: + db: Database session + file: File object + user_id: User ID + filename: Original filename + + Returns: + Created Document object + + Raises: + ValueError: If file validation fails + """ + # Validate file type (PDF only for now) + if not validate_file_type(filename, ['pdf', 'txt', 'docx']): + raise ValueError("Invalid file type. Only PDF, TXT, and DOCX files are allowed.") + + # Get file size + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(0) # Reset to beginning + + # Validate file size (10MB limit) + if not validate_file_size(file_size, max_size_mb=10): + raise ValueError("File size exceeds 10MB limit.") + + # Generate document ID + doc_id = generate_id() + + # Determine file type + file_extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown' + + # Create upload directory if it doesn't exist + upload_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "uploads") + os.makedirs(upload_dir, exist_ok=True) + + # Save file + file_path = os.path.join(upload_dir, f"{doc_id}_{filename}") + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file, buffer) + + # Create document record + document = Document( + id=doc_id, + user_id=user_id, + filename=filename, + file_path=file_path, + file_type=file_extension, + file_size=file_size + ) + + db.add(document) + db.commit() + db.refresh(document) + + return document + + @staticmethod + def process_document_content( + db: Session, + document_id: str, + content: str + ) -> int: + """ + Process document content for RAG. + + Args: + db: Database session + document_id: Document ID + content: Extracted text content + + Returns: + Number of chunks created + """ + document = db.query(Document).filter(Document.id == document_id).first() + + if not document: + raise ValueError("Document not found") + + # Process with RAG service + num_chunks = rag_service.process_document( + document_id=document.id, + filename=document.filename, + content=content, + user_id=document.user_id + ) + + return num_chunks + + @staticmethod + def get_user_documents(db: Session, user_id: str) -> List[Document]: + """ + Get all documents for a user. + + Args: + db: Database session + user_id: User ID + + Returns: + List of Document objects + """ + return db.query(Document).filter( + Document.user_id == user_id + ).order_by(Document.created_at.desc()).all() + + @staticmethod + def delete_document(db: Session, document_id: str) -> bool: + """ + Delete a document and its chunks. + + Args: + db: Database session + document_id: Document ID + + Returns: + True if deleted, False if not found + """ + document = db.query(Document).filter(Document.id == document_id).first() + + if not document: + return False + + # Delete file from filesystem + if os.path.exists(document.file_path): + os.remove(document.file_path) + + # Delete chunks from vector database + rag_service.delete_document_chunks(document_id) + + # Delete database record + db.delete(document) + db.commit() + + return True + + +# Global document service instance +document_service = DocumentService() diff --git a/app/services/news_service.py b/app/services/news_service.py new file mode 100644 index 0000000000000000000000000000000000000000..314c3a2f29e00ab528bbd4570170c313f4980781 --- /dev/null +++ b/app/services/news_service.py @@ -0,0 +1,153 @@ +import requests +from bs4 import BeautifulSoup +from urllib.parse import quote, urlparse, parse_qs +from datetime import datetime +import dateparser + +from app.services.sentiment_service import analyze_sentiment + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/115.0.0.0 Safari/537.36" + ) +} + + +def clean_google_url(google_url: str) -> str: + """ + Extract actual URL from Google redirect-style /url?q=... links. + + Args: + google_url: Google redirect URL + + Returns: + Cleaned actual URL + """ + parsed = urlparse(google_url) + if parsed.path == "/url": + qs = parse_qs(parsed.query) + return qs.get("q", [google_url])[0] + return google_url + + +def fetch_google_news(query: str, state: str) -> list: + """ + Fetch news articles from Google News by scraping search results. + + Args: + query: Search query (e.g., 'construction law') + state: Location/state filter (e.g., 'gujarat') + + Returns: + List of news article dictionaries with title, link, snippet, sentiment, date + """ + search_query = quote(f"{query} {state}") + url = f"https://www.google.com/search?q={search_query}&tbm=nws" + + try: + res = requests.get(url, headers=HEADERS, timeout=10) + res.raise_for_status() + except requests.RequestException as e: + print(f"❌ Request failed: {e}") + return [] + + soup = BeautifulSoup(res.text, "html.parser") + + results = [] + articles = soup.find_all("div", class_="SoaBEf") + + for article in articles: + # Extract title + title_tag = article.find("div", class_="n0jPhd ynAwRc MBeuO nDgy9d") + title = title_tag.text.strip() if title_tag else "" + + # Extract URL and clean it + a_tag = article.find("a") + link = clean_google_url(a_tag["href"]) if a_tag and a_tag.get("href") else "" + + # Extract snippet + snippet_tag = article.find("div", class_="GI74Re nDgy9d") + snippet = snippet_tag.text.strip() if snippet_tag else "" + + # Extract date from the correct div + date_div = article.find("div", class_="OSrXXb") + date_text = "" + if date_div: + span = date_div.find("span") + if span: + date_text = span.text.strip() + + # Parse it to datetime + parsed_date = dateparser.parse(date_text) if date_text else None + + # Analyze sentiment using BOTH title and snippet for better accuracy + # Combine title and snippet as news snippets alone are often neutral + combined_text = f"{title}. {snippet}" + sentiment = analyze_sentiment(combined_text) + + print(f"[SENTIMENT] {sentiment}: {title[:50]}...") + + if title and link: + results.append({ + "title": title, + "link": link, + "snippet": snippet, + "sentiment": sentiment, + "published_date": parsed_date.isoformat() if parsed_date else None, + "source": "Google News" + }) + + # Sort by date (newest first) + results.sort(key=lambda x: x["published_date"] or "", reverse=True) + + print(f"✅ Extracted {len(results)} news articles") + return results + + +def fetch_google_news_with_rss_fallback(query: str, state: str) -> list: + """ + Compatibility wrapper: prefer RSS/structured sources in future; currently calls fetch_google_news. + + Args: + query: Search query + state: Location filter + + Returns: + List of news articles + """ + try: + return fetch_google_news(query, state) + except Exception as e: + print(f"Error fetching news: {e}") + return [] + + +def fetch_news_plus_extras(query: str, state: str, include_extras: bool = False) -> dict: + """ + Return combined news and optional extras (web results). + + Args: + query: Search query + state: Location filter + include_extras: Whether to include extra web results + + Returns: + Dictionary with count, news articles, and optional extras + + Structure: + { 'count': int, 'news': [...], 'extras': [...] } + """ + news = [] + try: + news = fetch_google_news(query, state) + except Exception as e: + print(f"Error in fetch_news_plus_extras: {e}") + news = [] + + extras = [] + # Placeholder: if include_extras is True we could call a web search or DuckDuckGo API. + # Keep extras empty to avoid optional dependencies causing import errors. + + return {"count": len(news), "news": news, "extras": extras} diff --git a/app/services/policy_service.py b/app/services/policy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..fe9c5063e52a800cfcea4f4f56759d682fec1f21 --- /dev/null +++ b/app/services/policy_service.py @@ -0,0 +1,233 @@ +""" +Service for managing official policies (admin only). +""" +from sqlalchemy.orm import Session +from typing import List, Optional +import os +import shutil + +from app.database.models import OfficialPolicy +from app.utils.helpers import generate_id +from app.utils.validators import validate_file_type, validate_file_size +from app.services.rag_service import rag_service + + +class PolicyService: + """Service for official policy management.""" + + @staticmethod + def upload_policy( + db: Session, + file, + title: str, + filename: str, + admin_user_id: str, + description: Optional[str] = None, + category: Optional[str] = None + ) -> OfficialPolicy: + """ + Upload an official policy document. + + Args: + db: Database session + file: File object + title: Policy title + filename: Original filename + admin_user_id: Admin user ID + description: Optional description + category: Optional category + + Returns: + Created OfficialPolicy object + + Raises: + ValueError: If file validation fails + """ + # Validate file type + if not validate_file_type(filename, ['pdf', 'txt', 'docx']): + raise ValueError("Invalid file type. Only PDF, TXT, and DOCX files are allowed.") + + # Get file size + file.seek(0, 2) + file_size = file.tell() + file.seek(0) + + # Validate file size (20MB limit for policies) + if not validate_file_size(file_size, max_size_mb=20): + raise ValueError("File size exceeds 20MB limit.") + + # Generate policy ID + policy_id = generate_id() + + # Determine file type + file_extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown' + + # Create upload directory + upload_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "policies") + os.makedirs(upload_dir, exist_ok=True) + + # Save file + file_path = os.path.join(upload_dir, f"{policy_id}_{filename}") + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file, buffer) + + # Create policy record + policy = OfficialPolicy( + id=policy_id, + title=title, + description=description, + filename=filename, + file_path=file_path, + file_type=file_extension, + file_size=file_size, + category=category, + uploaded_by=admin_user_id, + is_active=1 + ) + + db.add(policy) + db.commit() + db.refresh(policy) + + return policy + + @staticmethod + def process_policy_content( + db: Session, + policy_id: str, + content: str + ) -> int: + """ + Process policy content for RAG (store in vector DB with special collection). + + Args: + db: Database session + policy_id: Policy ID + content: Extracted text content + + Returns: + Number of chunks created + """ + policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first() + + if not policy: + raise ValueError("Policy not found") + + # Process with RAG service (using a special "official_policies" user_id) + num_chunks = rag_service.process_document( + document_id=policy.id, + filename=f"[POLICY] {policy.title}", + content=content, + user_id="official_policies" # Special ID for policies + ) + + return num_chunks + + @staticmethod + def get_all_policies(db: Session, active_only: bool = True) -> List[OfficialPolicy]: + """ + Get all official policies. + + Args: + db: Database session + active_only: Only return active policies + + Returns: + List of OfficialPolicy objects + """ + query = db.query(OfficialPolicy) + + if active_only: + query = query.filter(OfficialPolicy.is_active == 1) + + return query.order_by(OfficialPolicy.created_at.desc()).all() + + @staticmethod + def get_policy_by_id(db: Session, policy_id: str) -> Optional[OfficialPolicy]: + """ + Get policy by ID. + + Args: + db: Database session + policy_id: Policy ID + + Returns: + OfficialPolicy object or None + """ + return db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first() + + @staticmethod + def update_policy( + db: Session, + policy_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + category: Optional[str] = None, + is_active: Optional[bool] = None + ) -> Optional[OfficialPolicy]: + """ + Update policy metadata. + + Args: + db: Database session + policy_id: Policy ID + title: New title + description: New description + category: New category + is_active: New active status + + Returns: + Updated OfficialPolicy object or None + """ + policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first() + + if not policy: + return None + + if title is not None: + policy.title = title + if description is not None: + policy.description = description + if category is not None: + policy.category = category + if is_active is not None: + policy.is_active = 1 if is_active else 0 + + db.commit() + db.refresh(policy) + + return policy + + @staticmethod + def delete_policy(db: Session, policy_id: str) -> bool: + """ + Delete a policy and its chunks. + + Args: + db: Database session + policy_id: Policy ID + + Returns: + True if deleted, False if not found + """ + policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first() + + if not policy: + return False + + # Delete file from filesystem + if os.path.exists(policy.file_path): + os.remove(policy.file_path) + + # Delete chunks from vector database + rag_service.delete_document_chunks(policy_id) + + # Delete database record + db.delete(policy) + db.commit() + + return True + + +# Global policy service instance +policy_service = PolicyService() diff --git a/app/services/rag_service.py b/app/services/rag_service.py new file mode 100644 index 0000000000000000000000000000000000000000..cac94d72033aa16d84bf0c59fae0893af62216b3 --- /dev/null +++ b/app/services/rag_service.py @@ -0,0 +1,237 @@ +from typing import List, Dict, Optional +import chromadb +import os + +from app.utils.chunking import text_chunker +from app.utils.embeddings import embedding_generator +from app.utils.reranker import reranker + + +class RAGService: + """Service for RAG operations including document processing and retrieval.""" + + def __init__(self): + """Initialize RAG service with ChromaDB.""" + # Initialize ChromaDB client + chroma_path = os.path.join(os.path.dirname(__file__), "..", "..", "data", "chromadb") + os.makedirs(chroma_path, exist_ok=True) + + self.chroma_client = chromadb.PersistentClient(path=chroma_path) + + # Get or create collection + self.collection = self.chroma_client.get_or_create_collection( + name="construction_documents", + metadata={"description": "Construction documents and manuals"} + ) + + print(f"[RAG Service] Initialized with embedding model: {embedding_generator.model_name}") + + def chunk_text(self, text: str, chunk_size: int = 800, overlap: int = 200) -> List[str]: + """ + Chunk text using the modular text chunker utility. + + Args: + text: Text to chunk + chunk_size: Target size of each chunk in characters + overlap: Overlap between chunks in characters + + Returns: + List of text chunks + """ + return text_chunker.chunk_by_sentences(text, chunk_size, overlap) + + def process_document( + self, + document_id: str, + filename: str, + content: str, + user_id: str + ) -> int: + """ + Process document by chunking and storing in vector database. + + Args: + document_id: Unique document ID + filename: Document filename + content: Document text content + user_id: User ID who uploaded the document + + Returns: + Number of chunks created + """ + # Chunk the document + chunks = self.chunk_text(content) + + if not chunks: + return 0 + + # Generate embeddings using modular utility + embeddings = embedding_generator.generate_embeddings(chunks) + + # Prepare metadata + metadatas = [ + { + "document_id": document_id, + "filename": filename, + "user_id": user_id, + "chunk_index": i + } + for i in range(len(chunks)) + ] + + # Generate IDs for chunks + ids = [f"{document_id}_chunk_{i}" for i in range(len(chunks))] + + # Add to ChromaDB + self.collection.add( + embeddings=embeddings, + documents=chunks, + metadatas=metadatas, + ids=ids + ) + + return len(chunks) + + def semantic_search( + self, + query: str, + user_id: Optional[str] = None, + top_k: int = 5 + ) -> List[Dict]: + """ + Perform hybrid search (semantic + keyword BM25) with reranking. + + Args: + query: Search query + user_id: Optional user ID to filter documents + top_k: Number of results to return after reranking + + Returns: + List of relevant chunks with metadata + """ + # Build where filter + where_filter = {"user_id": user_id} if user_id else None + + # STEP 1: Get all documents for BM25 indexing + all_docs = self.collection.get(where=where_filter) + + if not all_docs or not all_docs['documents']: + return [] + + # STEP 2: Semantic search (ChromaDB embedding-based) + query_embedding = embedding_generator.generate_embedding(query) + semantic_results = self.collection.query( + query_embeddings=[query_embedding], + n_results=min(30, top_k * 3), + where=where_filter + ) + + semantic_chunks = [] + if semantic_results and semantic_results['documents']: + for i in range(len(semantic_results['documents'][0])): + semantic_chunks.append({ + "content": semantic_results['documents'][0][i], + "metadata": semantic_results['metadatas'][0][i], + "distance": semantic_results['distances'][0][i] if 'distances' in semantic_results else None + }) + + # STEP 3: Keyword search (BM25) + from app.utils.bm25_search import BM25Search, HybridSearch + + bm25 = BM25Search() + bm25_chunks = [ + { + "content": all_docs['documents'][i], + "metadata": all_docs['metadatas'][i] + } + for i in range(len(all_docs['documents'])) + ] + bm25.index_documents(bm25_chunks) + keyword_chunks = bm25.search(query, top_k=30) + + # STEP 4: Combine with hybrid scoring (70% semantic, 30% keyword) + hybrid = HybridSearch(semantic_weight=0.7, keyword_weight=0.3) + combined_chunks = hybrid.combine_results(semantic_chunks, keyword_chunks, top_k=30) + + if not combined_chunks: + return [] + + # STEP 5: Final reranking with cross-encoder + reranked = reranker.rerank(query, combined_chunks, top_k=top_k) + + print(f"[RAG Service] Hybrid: {len(semantic_chunks)} semantic + {len(keyword_chunks)} keyword → {len(reranked)} final") + + return reranked + + def search_policies( + self, + query: str, + policy_ids: List[str], + top_k: int = 10 + ) -> List[Dict]: + """ + Search within specific official policy documents with hybrid search and reranking. + + Args: + query: Search query + policy_ids: List of policy document IDs to search within + top_k: Number of results to return after reranking + + Returns: + List of relevant chunks with metadata from selected policies + """ + # Build filter for official policies + where_filter = { + "$and": [ + {"user_id": {"$eq": "official_policies"}}, + {"document_id": {"$in": policy_ids}} + ] + } + + print(f"[RAG Service] search_policies called with {len(policy_ids)} policies") + + # STEP 1: Semantic search + query_embedding = embedding_generator.generate_embedding(query) + initial_results = self.collection.query( + query_embeddings=[query_embedding], + n_results=min(30, top_k * 3), + where=where_filter + ) + + initial_chunks = [] + if initial_results and initial_results['documents']: + for i in range(len(initial_results['documents'][0])): + initial_chunks.append({ + "content": initial_results['documents'][0][i], + "metadata": initial_results['metadatas'][0][i], + "distance": initial_results['distances'][0][i] if 'distances' in initial_results else None + }) + + if not initial_chunks: + print(f"[RAG Service] No chunks found") + return [] + + # STEP 2: Rerank + reranked_chunks = reranker.rerank(query, initial_chunks, top_k=top_k) + + print(f"[RAG Service] Returning {len(reranked_chunks)} reranked chunks") + + return reranked_chunks + + def delete_document_chunks(self, document_id: str): + """ + Delete all chunks for a document. + + Args: + document_id: Document ID + """ + results = self.collection.get( + where={"document_id": document_id} + ) + + if results and results['ids']: + self.collection.delete(ids=results['ids']) + + +# Global RAG service instance +rag_service = RAGService() diff --git a/app/services/report_service.py b/app/services/report_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3a07506f74ca03e1acafdfd3406285a205b86ea0 --- /dev/null +++ b/app/services/report_service.py @@ -0,0 +1,143 @@ +""" +Service for AI-powered report content generation. +""" +from typing import Dict +from app.llm.client import llm_client + + +class ReportGenerationService: + """Service for generating report content using AI.""" + + @staticmethod + def generate_section_content( + section_name: str, + context: Dict[str, str] + ) -> str: + """ + Generate content for a specific report section using AI. + + Args: + section_name: Name/type of the section (e.g., 'summary', 'recommendations') + context: Dictionary with project/property details for context + + Returns: + Generated content for the section + """ + # Build context string + context_str = "\n".join([f"- {k}: {v}" for k, v in context.items() if v]) + + # Create section-specific prompts + prompts = { + "summary": f"""Generate a professional executive summary for a construction/property report based on this information: + +{context_str} + +Write a comprehensive 2-3 paragraph summary that: +- Highlights key project details +- Emphasizes unique selling points +- Uses professional, formal language +- Is suitable for stakeholders and investors + +Return ONLY the summary text, no titles or extra formatting:""", + + "recommendations": f"""Generate professional recommendations for a construction/property report based on this information: + +{context_str} + +Provide 3-5 specific, actionable recommendations that: +- Address investment potential +- Cover risk mitigation +- Suggest improvements or considerations +- Use bullet points (•) format +- Are data-driven and practical + +Return ONLY the recommendations:""", + + "legal_notes": f"""Generate legal compliance notes for a construction/property report based on this information: + +{context_str} + +Write a professional legal analysis covering: +- Regulatory compliance status +- Required permits and approvals +- Legal clearances +- Compliance recommendations +- 2-3 paragraphs, formal tone + +Return ONLY the legal notes:""", + + "risk_assessment": f"""Generate a risk assessment section for a construction/property report based on this information: + +{context_str} + +Provide a comprehensive risk analysis covering: +- Market risks +- Regulatory/legal risks +- Construction/execution risks +- Financial risks +- Risk mitigation strategies +- Use professional language +- 2-3 paragraphs + +Return ONLY the risk assessment:""", + + "financial_summary": f"""Generate a financial summary for a construction/property report based on this information: + +{context_str} + +Create a professional financial overview covering: +- Investment requirements +- Revenue projections +- Cost breakdowns +- ROI expectations +- Financial highlights +- 2-3 paragraphs, data-focused + +Return ONLY the financial summary:""", + + "market_opportunity": f"""Generate a market opportunity analysis for a construction/property report based on this information: + +{context_str} + +Write a compelling market analysis that: +- Describes market demand +- Highlights growth potential +- Identifies target segments +- Discusses competitive advantages +- 2-3 paragraphs, persuasive yet professional + +Return ONLY the market opportunity analysis:""", + + "default": f"""Generate professional content for the "{section_name}" section of a construction/property report based on this information: + +{context_str} + +Write 2-3 professional paragraphs that: +- Are relevant to the section title +- Use formal, business-appropriate language +- Include specific details from the context +- Are suitable for professional reports + +Return ONLY the content:""" + } + + # Get appropriate prompt + prompt = prompts.get(section_name.lower().replace(' ', '_'), prompts['default']) + + try: + # Generate content + content = llm_client.get_completion( + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=500 + ) + + return content.strip() + + except Exception as e: + print(f"[Report Generation] Error: {e}") + return f"Error generating content for {section_name}. Please try again or edit manually." + + +# Global service instance +report_generation_service = ReportGenerationService() diff --git a/app/services/sentiment_service.py b/app/services/sentiment_service.py new file mode 100644 index 0000000000000000000000000000000000000000..54528283e3909e96d006f70b5b73c431bd570117 --- /dev/null +++ b/app/services/sentiment_service.py @@ -0,0 +1,120 @@ +from transformers import AutoModelForSequenceClassification, AutoTokenizer, AutoConfig +import numpy as np +from scipy.special import softmax +from functools import lru_cache + +# Use a better sentiment model trained on Twitter/news (not movie reviews!) +MODEL = "cardiffnlp/twitter-roberta-base-sentiment-latest" + +@lru_cache() +def load_sentiment_model(): + """Load and cache the sentiment model and tokenizer.""" + tokenizer = AutoTokenizer.from_pretrained(MODEL) + config = AutoConfig.from_pretrained(MODEL) + model = AutoModelForSequenceClassification.from_pretrained(MODEL) + return tokenizer, config, model + +def analyze_sentiment(text: str) -> str: + """ + Analyze sentiment using Twitter-RoBERTa (better for news/social media). + + Args: + text: Text to analyze + + Returns: + Sentiment classification: 'Positive', 'Negative', or 'Neutral' + """ + if not text or not text.strip(): + return "Neutral" + + try: + tokenizer, config, model = load_sentiment_model() + + # Tokenize and get prediction + encoded_input = tokenizer(text[:512], return_tensors='pt', truncation=True, max_length=512) + output = model(**encoded_input) + scores = output[0][0].detach().numpy() + scores = softmax(scores) + + # Get label with highest score + # labels: ['negative', 'neutral', 'positive'] + ranking = np.argsort(scores)[::-1] + label_index = ranking[0] + confidence = scores[label_index] + + # Map to our format + labels = ['Negative', 'Neutral', 'Positive'] + result = labels[label_index] + + # Only return Positive/Negative if confidence > 50% + # Otherwise return Neutral + if confidence > 0.5: + return result + else: + return "Neutral" + + except Exception as e: + print(f"❌ Sentiment analysis failed: {e}") + # Fallback to keyword-based + return _keyword_sentiment(text) + + +def _keyword_sentiment(text: str) -> str: + """Fallback keyword-based sentiment for construction/legal news.""" + text_lower = text.lower() + + # Negative keywords for construction/legal news + negative_words = [ + 'illegal', 'scam', 'fraud', 'violation', 'criticise', 'criticize', + 'fine', 'penalty', 'halted', 'stopped', 'delay', 'problem', 'issue', + 'allege', 'complaint', 'reject', 'denied', 'unsafe', 'danger', + 'cost overrun', 'budget exceed', 'dispute', 'litigation', 'demolition', + 'encroachment', 'unauthorised', 'unauthorized', 'fail', 'failed' + ] + + # Positive keywords + positive_words = [ + 'approve', 'approved', 'success', 'complete', 'completed', 'inaugurate', + 'new project', 'development', 'growth', 'expansion', 'modern', 'upgrade', + 'improvement', 'benefit', 'efficient', 'green', 'sustainable', 'award', + 'milestone', 'breakthrough', 'innovation', 'reduce penalty' + ] + + neg_count = sum(1 for word in negative_words if word in text_lower) + pos_count = sum(1 for word in positive_words if word in text_lower) + + if neg_count > pos_count and neg_count > 0: + return "Negative" + elif pos_count > neg_count and pos_count > 0: + return "Positive" + else: + return "Neutral" + + +def get_sentiment_score(text: str) -> float: + """ + Get numerical sentiment score. + + Args: + text: Text to analyze + + Returns: + Score between -1 (negative) and 1 (positive) + """ + if not text or not text.strip(): + return 0.0 + + try: + tokenizer, config, model = load_sentiment_model() + + encoded_input = tokenizer(text[:512], return_tensors='pt', truncation=True, max_length=512) + output = model(**encoded_input) + scores = output[0][0].detach().numpy() + scores = softmax(scores) + + # Convert to -1 to 1 scale + # scores[0] = negative, scores[1] = neutral, scores[2] = positive + return float(scores[2] - scores[0]) + + except Exception: + return 0.0 diff --git a/app/services/session_service.py b/app/services/session_service.py new file mode 100644 index 0000000000000000000000000000000000000000..bf829c93fc2e385be96119fd16f091cbf447074a --- /dev/null +++ b/app/services/session_service.py @@ -0,0 +1,157 @@ +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +from datetime import datetime + +from app.database.models import Session as ChatSession, Message +from app.utils.helpers import generate_id + + +class SessionService: + """Service for session management operations.""" + + @staticmethod + def create_session( + db: Session, + user_id: Optional[str] = None, + title: str = "New Conversation" + ) -> ChatSession: + """ + Create a new chat session. + + Args: + db: Database session + user_id: Optional user ID (None for guest users) + title: Session title + + Returns: + Created ChatSession object + """ + session = ChatSession( + id=generate_id(), + user_id=user_id, + title=title + ) + + db.add(session) + db.commit() + db.refresh(session) + + return session + + @staticmethod + def get_user_sessions(db: Session, user_id: str) -> List[dict]: + """ + Get all sessions for a user with message counts. + + Args: + db: Database session + user_id: User ID + + Returns: + List of session dictionaries with message counts + """ + # Query sessions with message counts + sessions = db.query( + ChatSession, + func.count(Message.id).label('message_count') + ).outerjoin( + Message, ChatSession.id == Message.session_id + ).filter( + ChatSession.user_id == user_id + ).group_by( + ChatSession.id + ).order_by( + ChatSession.updated_at.desc() + ).all() + + # Format response + result = [] + for session, message_count in sessions: + result.append({ + "id": session.id, + "user_id": session.user_id, + "title": session.title, + "summary": session.summary, + "created_at": session.created_at, + "updated_at": session.updated_at, + "message_count": message_count + }) + + return result + + @staticmethod + def get_session_by_id(db: Session, session_id: str) -> Optional[ChatSession]: + """ + Get session by ID. + + Args: + db: Database session + session_id: Session ID + + Returns: + ChatSession object if found, None otherwise + """ + return db.query(ChatSession).filter(ChatSession.id == session_id).first() + + @staticmethod + def update_session_title(db: Session, session_id: str, title: str) -> Optional[ChatSession]: + """ + Update session title. + + Args: + db: Database session + session_id: Session ID + title: New title + + Returns: + Updated ChatSession object if found, None otherwise + """ + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + + if not session: + return None + + session.title = title + session.updated_at = datetime.utcnow() + db.commit() + db.refresh(session) + + return session + + @staticmethod + def delete_session(db: Session, session_id: str) -> bool: + """ + Delete a session and all its messages. + + Args: + db: Database session + session_id: Session ID + + Returns: + True if deleted, False if not found + """ + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + + if not session: + return False + + db.delete(session) + db.commit() + + return True + + @staticmethod + def update_session_timestamp(db: Session, session_id: str): + """ + Update session's updated_at timestamp. + + Args: + db: Database session + session_id: Session ID + """ + session = db.query(ChatSession).filter(ChatSession.id == session_id).first() + + if session: + session.updated_at = datetime.utcnow() + db.commit() diff --git a/app/services/settings_service.py b/app/services/settings_service.py new file mode 100644 index 0000000000000000000000000000000000000000..2b039096fa8574461496e7eb56fa1d53308d1be5 --- /dev/null +++ b/app/services/settings_service.py @@ -0,0 +1,136 @@ +""" +Service for user settings management. +""" +from sqlalchemy.orm import Session +from typing import Optional, Dict +from datetime import datetime + +from app.database.models import UserSettings + + +class SettingsService: + """Service for managing user settings and preferences.""" + + @staticmethod + def get_or_create_settings(db: Session, user_id: str) -> UserSettings: + """ + Get user settings or create default if not exists. + + Args: + db: Database session + user_id: User ID + + Returns: + UserSettings object + """ + settings = db.query(UserSettings).filter(UserSettings.user_id == user_id).first() + + if not settings: + # Create default settings + settings = UserSettings(user_id=user_id) + db.add(settings) + db.commit() + db.refresh(settings) + + return settings + + @staticmethod + def update_profile( + db: Session, + user_id: str, + bio: Optional[str] = None, + phone: Optional[str] = None, + company: Optional[str] = None + ) -> UserSettings: + """ + Update user profile settings. + + Args: + db: Database session + user_id: User ID + bio: User bio + phone: Phone number + company: Company name + + Returns: + Updated UserSettings object + """ + settings = SettingsService.get_or_create_settings(db, user_id) + + if bio is not None: + settings.bio = bio + if phone is not None: + settings.phone = phone + if company is not None: + settings.company = company + + settings.updated_at = datetime.utcnow() + db.commit() + db.refresh(settings) + + return settings + + @staticmethod + def update_appearance( + db: Session, + user_id: str, + theme: str + ) -> UserSettings: + """ + Update appearance settings. + + Args: + db: Database session + user_id: User ID + theme: Theme preference (light, dark, system) + + Returns: + Updated UserSettings object + """ + if theme not in ['light', 'dark', 'system']: + raise ValueError("Theme must be 'light', 'dark', or 'system'") + + settings = SettingsService.get_or_create_settings(db, user_id) + settings.theme = theme + settings.updated_at = datetime.utcnow() + + db.commit() + db.refresh(settings) + + return settings + + @staticmethod + def update_notifications( + db: Session, + user_id: str, + email_notifications: Optional[bool] = None, + update_notifications: Optional[bool] = None + ) -> UserSettings: + """ + Update notification settings. + + Args: + db: Database session + user_id: User ID + email_notifications: Enable/disable email notifications + update_notifications: Enable/disable update notifications + + Returns: + Updated UserSettings object + """ + settings = SettingsService.get_or_create_settings(db, user_id) + + if email_notifications is not None: + settings.email_notifications = 1 if email_notifications else 0 + if update_notifications is not None: + settings.update_notifications = 1 if update_notifications else 0 + + settings.updated_at = datetime.utcnow() + db.commit() + db.refresh(settings) + + return settings + + +# Global service instance +settings_service = SettingsService() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/bm25_search.py b/app/utils/bm25_search.py new file mode 100644 index 0000000000000000000000000000000000000000..2cf72bd5320db40434debbadc789753bf7eb8a51 --- /dev/null +++ b/app/utils/bm25_search.py @@ -0,0 +1,201 @@ +""" +BM25 keyword-based search for hybrid retrieval. +Provides exact keyword matching to complement semantic search. +""" +from typing import List, Dict +from rank_bm25 import BM25Okapi +import numpy as np + + +class BM25Search: + """BM25 keyword-based search engine.""" + + def __init__(self): + """Initialize BM25 search.""" + self.corpus = [] + self.tokenized_corpus = [] + self.bm25 = None + self.chunks = [] + print("[BM25] Initialized") + + def index_documents(self, chunks: List[Dict]): + """ + Index documents for BM25 search. + + Args: + chunks: List of chunk dictionaries with 'content' key + """ + self.chunks = chunks + self.corpus = [chunk['content'] for chunk in chunks] + + # Simple tokenization (lowercase + split) + self.tokenized_corpus = [ + doc.lower().split() for doc in self.corpus + ] + + # Build BM25 index + if self.tokenized_corpus: + self.bm25 = BM25Okapi(self.tokenized_corpus) + print(f"[BM25] Indexed {len(self.chunks)} documents") + + def search( + self, + query: str, + top_k: int = 30 + ) -> List[Dict]: + """ + Search documents using BM25. + + Args: + query: Search query + top_k: Number of results to return + + Returns: + List of chunks sorted by BM25 score + """ + if not self.bm25: + return [] + + # Tokenize query + tokenized_query = query.lower().split() + + # Get BM25 scores + scores = self.bm25.get_scores(tokenized_query) + + # Get top_k indices + top_indices = np.argsort(scores)[::-1][:top_k] + + # Return chunks with scores + results = [] + for idx in top_indices: + if scores[idx] > 0: # Only return non-zero scores + chunk = self.chunks[idx].copy() + chunk['bm25_score'] = float(scores[idx]) + results.append(chunk) + + return results + + def get_score(self, query: str, document: str) -> float: + """ + Get BM25 score for a single query-document pair. + + Args: + query: Search query + document: Document text + + Returns: + BM25 relevance score + """ + tokenized_query = query.lower().split() + tokenized_doc = document.lower().split() + + # Create temporary BM25 for single document + temp_bm25 = BM25Okapi([tokenized_doc]) + score = temp_bm25.get_scores(tokenized_query)[0] + + return float(score) + + +class HybridSearch: + """Combines semantic and keyword search.""" + + def __init__( + self, + semantic_weight: float = 0.7, + keyword_weight: float = 0.3 + ): + """ + Initialize hybrid search. + + Args: + semantic_weight: Weight for semantic search (0-1) + keyword_weight: Weight for keyword search (0-1) + """ + self.semantic_weight = semantic_weight + self.keyword_weight = keyword_weight + self.bm25 = BM25Search() + print(f"[Hybrid Search] Initialized (semantic: {semantic_weight}, keyword: {keyword_weight})") + + def combine_results( + self, + semantic_results: List[Dict], + keyword_results: List[Dict], + top_k: int = 10 + ) -> List[Dict]: + """ + Combine and rerank results from semantic and keyword search. + + Args: + semantic_results: Results from semantic search (with 'distance' scores) + keyword_results: Results from BM25 search (with 'bm25_score') + top_k: Number of final results + + Returns: + Combined and reranked results + """ + # Normalize scores to 0-1 range + def normalize_scores(results, score_key): + if not results: + return results + + scores = [r.get(score_key, 0) for r in results] + min_score = min(scores) + max_score = max(scores) + + if max_score == min_score: + return results + + for r in results: + r[f'{score_key}_normalized'] = ( + (r.get(score_key, 0) - min_score) / (max_score - min_score) + ) + return results + + # For semantic search, lower distance = higher relevance + # Need to invert: score = 1 - normalized_distance + for r in semantic_results: + if 'distance' in r: + r['semantic_score'] = r['distance'] # Will normalize below + + semantic_results = normalize_scores(semantic_results, 'semantic_score') + keyword_results = normalize_scores(keyword_results, 'bm25_score') + + # Invert semantic scores (lower distance = better) + for r in semantic_results: + if 'semantic_score_normalized' in r: + r['semantic_score_normalized'] = 1 - r['semantic_score_normalized'] + + # Merge results by chunk ID or content + merged = {} + + for chunk in semantic_results: + chunk_id = chunk.get('metadata', {}).get('document_id', '') + '_' + str(chunk.get('metadata', {}).get('chunk_index', '')) + merged[chunk_id] = chunk.copy() + merged[chunk_id]['hybrid_score'] = ( + self.semantic_weight * chunk.get('semantic_score_normalized', 0) + ) + + for chunk in keyword_results: + chunk_id = chunk.get('metadata', {}).get('document_id', '') + '_' + str(chunk.get('metadata', {}).get('chunk_index', '')) + if chunk_id in merged: + merged[chunk_id]['hybrid_score'] += ( + self.keyword_weight * chunk.get('bm25_score_normalized', 0) + ) + else: + merged[chunk_id] = chunk.copy() + merged[chunk_id]['hybrid_score'] = ( + self.keyword_weight * chunk.get('bm25_score_normalized', 0) + ) + + # Sort by hybrid score + results = sorted( + merged.values(), + key=lambda x: x.get('hybrid_score', 0), + reverse=True + ) + + return results[:top_k] + + +# Global hybrid search instance +hybrid_search = HybridSearch() diff --git a/app/utils/chunking.py b/app/utils/chunking.py new file mode 100644 index 0000000000000000000000000000000000000000..bbe616ba148aa0746e1e1c332985c31d0537aa8e --- /dev/null +++ b/app/utils/chunking.py @@ -0,0 +1,164 @@ +""" +Text chunking utilities for document processing. +""" +from typing import List +import re + + +class TextChunker: + """Handles intelligent text chunking with various strategies.""" + + @staticmethod + def chunk_by_sentences( + text: str, + chunk_size: int = 800, + overlap: int = 200 + ) -> List[str]: + """ + Chunk text by sentences with overlap. + + Args: + text: Text to chunk + chunk_size: Target size of each chunk in characters + overlap: Overlap between chunks in characters + + Returns: + List of text chunks + """ + if not text or len(text.strip()) == 0: + return [] + + # Split into sentences (improved regex for better sentence detection) + sentences = re.split(r'(?<=[.!?])\s+', text) + + chunks = [] + current_chunk = [] + current_size = 0 + + for sentence in sentences: + sentence_size = len(sentence) + + # If adding this sentence exceeds chunk_size, save current chunk + if current_size + sentence_size > chunk_size and current_chunk: + chunk_text = ' '.join(current_chunk) + chunks.append(chunk_text) + + # Calculate overlap: keep last few sentences + overlap_text = [] + overlap_size = 0 + for s in reversed(current_chunk): + if overlap_size + len(s) <= overlap: + overlap_text.insert(0, s) + overlap_size += len(s) + else: + break + + current_chunk = overlap_text + current_size = overlap_size + + current_chunk.append(sentence) + current_size += sentence_size + + # Add remaining chunk + if current_chunk: + chunks.append(' '.join(current_chunk)) + + return [c.strip() for c in chunks if c.strip()] + + @staticmethod + def chunk_by_paragraphs( + text: str, + max_chunk_size: int = 1000 + ) -> List[str]: + """ + Chunk text by paragraphs, combining small paragraphs. + + Args: + text: Text to chunk + max_chunk_size: Maximum size of each chunk + + Returns: + List of text chunks + """ + if not text or len(text.strip()) == 0: + return [] + + # Split by double newlines (paragraphs) + paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] + + chunks = [] + current_chunk = [] + current_size = 0 + + for para in paragraphs: + para_size = len(para) + + # If paragraph alone exceeds max size, split it by sentences + if para_size > max_chunk_size: + # Save current chunk if exists + if current_chunk: + chunks.append('\n\n'.join(current_chunk)) + current_chunk = [] + current_size = 0 + + # Split large paragraph by sentences + sentence_chunks = TextChunker.chunk_by_sentences( + para, + chunk_size=max_chunk_size, + overlap=100 + ) + chunks.extend(sentence_chunks) + continue + + # If adding this paragraph exceeds max size, save current chunk + if current_size + para_size > max_chunk_size and current_chunk: + chunks.append('\n\n'.join(current_chunk)) + current_chunk = [] + current_size = 0 + + current_chunk.append(para) + current_size += para_size + 2 # +2 for \n\n + + # Add remaining chunk + if current_chunk: + chunks.append('\n\n'.join(current_chunk)) + + return [c.strip() for c in chunks if c.strip()] + + @staticmethod + def chunk_with_metadata( + text: str, + chunk_size: int = 800, + overlap: int = 200, + strategy: str = "sentences" + ) -> List[dict]: + """ + Chunk text and return with metadata. + + Args: + text: Text to chunk + chunk_size: Target chunk size + overlap: Overlap size + strategy: Chunking strategy ("sentences" or "paragraphs") + + Returns: + List of dictionaries with chunk text and metadata + """ + if strategy == "paragraphs": + chunks = TextChunker.chunk_by_paragraphs(text, chunk_size) + else: + chunks = TextChunker.chunk_by_sentences(text, chunk_size, overlap) + + return [ + { + "text": chunk, + "index": i, + "size": len(chunk), + "strategy": strategy + } + for i, chunk in enumerate(chunks) + ] + + +# Global chunker instance +text_chunker = TextChunker() diff --git a/app/utils/document_extractor.py b/app/utils/document_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..b6cce08b5157d512beb934e7dc8e022edb0d9d94 --- /dev/null +++ b/app/utils/document_extractor.py @@ -0,0 +1,118 @@ +""" +Document text extraction utilities for various file formats. +""" +import os +from typing import Optional +import PyPDF2 +import docx +from unstructured.partition.auto import partition + + +class DocumentExtractor: + """Extract text content from various document formats.""" + + @staticmethod + def extract_text(file_path: str, file_type: str) -> str: + """ + Extract text from a document file. + + Args: + file_path: Path to the document file + file_type: File extension (pdf, txt, docx) + + Returns: + Extracted text content + + Raises: + ValueError: If file type is not supported + Exception: If extraction fails + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + file_type = file_type.lower() + + try: + if file_type == 'txt': + return DocumentExtractor._extract_txt(file_path) + elif file_type == 'pdf': + return DocumentExtractor._extract_pdf(file_path) + elif file_type == 'docx': + return DocumentExtractor._extract_docx(file_path) + else: + raise ValueError(f"Unsupported file type: {file_type}") + + except Exception as e: + print(f"Error extracting text from {file_path}: {e}") + raise + + @staticmethod + def _extract_txt(file_path: str) -> str: + """Extract text from TXT file.""" + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + + @staticmethod + def _extract_pdf(file_path: str) -> str: + """Extract text from PDF file using PyPDF2.""" + text_content = [] + + try: + with open(file_path, 'rb') as f: + pdf_reader = PyPDF2.PdfReader(f) + + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + text = page.extract_text() + if text.strip(): + text_content.append(text) + + return "\n\n".join(text_content) + + except Exception as e: + print(f"PyPDF2 extraction failed, trying unstructured library: {e}") + # Fallback to unstructured library + return DocumentExtractor._extract_with_unstructured(file_path) + + @staticmethod + def _extract_docx(file_path: str) -> str: + """Extract text from DOCX file.""" + try: + doc = docx.Document(file_path) + text_content = [] + + # Extract paragraphs + for paragraph in doc.paragraphs: + if paragraph.text.strip(): + text_content.append(paragraph.text) + + # Extract tables + for table in doc.tables: + for row in table.rows: + row_text = " | ".join(cell.text.strip() for cell in row.cells) + if row_text.strip(): + text_content.append(row_text) + + return "\n\n".join(text_content) + + except Exception as e: + print(f"python-docx extraction failed: {e}") + raise + + @staticmethod + def _extract_with_unstructured(file_path: str) -> str: + """ + Extract text using unstructured library (fallback method). + This handles complex PDFs with tables and images better. + """ + try: + elements = partition(filename=file_path) + text_content = [str(element) for element in elements] + return "\n\n".join(text_content) + except Exception as e: + print(f"Unstructured extraction failed: {e}") + raise + + +# Global extractor instance +document_extractor = DocumentExtractor() diff --git a/app/utils/embeddings.py b/app/utils/embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..43c379ebd9fc67d8c2cf88fdf3cf0b19aa17108c --- /dev/null +++ b/app/utils/embeddings.py @@ -0,0 +1,96 @@ +""" +Embedding generation utilities using sentence transformers. +""" +from typing import List, Union +from sentence_transformers import SentenceTransformer +import numpy as np + + +class EmbeddingGenerator: + """Handles text embedding generation.""" + + def __init__(self, model_name: str = 'all-MiniLM-L6-v2'): + """ + Initialize embedding generator. + + Args: + model_name: Name of the sentence transformer model + """ + self.model_name = model_name + self.model = SentenceTransformer(model_name) + print(f"[Embeddings] Loaded model: {model_name}") + + def generate_embedding(self, text: str) -> List[float]: + """ + Generate embedding for a single text. + + Args: + text: Input text + + Returns: + Embedding vector as list of floats + """ + embedding = self.model.encode([text])[0] + return embedding.tolist() + + def generate_embeddings(self, texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for multiple texts (batch processing). + + Args: + texts: List of input texts + + Returns: + List of embedding vectors + """ + if not texts: + return [] + + embeddings = self.model.encode(texts, show_progress_bar=len(texts) > 10) + return embeddings.tolist() + + def compute_similarity( + self, + embedding1: Union[List[float], np.ndarray], + embedding2: Union[List[float], np.ndarray] + ) -> float: + """ + Compute cosine similarity between two embeddings. + + Args: + embedding1: First embedding vector + embedding2: Second embedding vector + + Returns: + Cosine similarity score (0-1) + """ + # Convert to numpy arrays if needed + emb1 = np.array(embedding1) if isinstance(embedding1, list) else embedding1 + emb2 = np.array(embedding2) if isinstance(embedding2, list) else embedding2 + + # Compute cosine similarity + dot_product = np.dot(emb1, emb2) + norm1 = np.linalg.norm(emb1) + norm2 = np.linalg.norm(emb2) + + if norm1 == 0 or norm2 == 0: + return 0.0 + + return float(dot_product / (norm1 * norm2)) + + def get_model_info(self) -> dict: + """ + Get information about the loaded model. + + Returns: + Dictionary with model information + """ + return { + "model_name": self.model_name, + "embedding_dimension": self.model.get_sentence_embedding_dimension(), + "max_seq_length": self.model.max_seq_length + } + + +# Global embedding generator instance +embedding_generator = EmbeddingGenerator() diff --git a/app/utils/helpers.py b/app/utils/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..6392283bad3508a30a833a226bc0a8f32b7a26aa --- /dev/null +++ b/app/utils/helpers.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime +import re + + +def generate_id() -> str: + """Generate a unique UUID string.""" + return str(uuid.uuid4()) + + +def format_timestamp(dt: datetime) -> str: + """ + Format a datetime object to ISO string. + + Args: + dt: Datetime object + + Returns: + ISO formatted string + """ + return dt.isoformat() + + +def parse_json_safely(json_str: str) -> dict: + """ + Safely parse JSON string. + + Args: + json_str: JSON string to parse + + Returns: + Parsed dictionary or empty dict on error + """ + import json + try: + return json.loads(json_str) if json_str else {} + except json.JSONDecodeError: + return {} diff --git a/app/utils/jwt.py b/app/utils/jwt.py new file mode 100644 index 0000000000000000000000000000000000000000..640186772c3b1f954494c117a644212fd0c8c39a --- /dev/null +++ b/app/utils/jwt.py @@ -0,0 +1,75 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt + +from app.config.settings import settings + + +def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token. + + Args: + user_id: User ID to encode in token + expires_delta: Optional custom expiration time + + Returns: + Encoded JWT token string + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = { + "sub": user_id, + "exp": expire, + "type": "access" + } + + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(user_id: str) -> str: + """ + Create a JWT refresh token. + + Args: + user_id: User ID to encode in token + + Returns: + Encoded JWT refresh token string + """ + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = { + "sub": user_id, + "exp": expire, + "type": "refresh" + } + + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Optional[str]: + """ + Verify and decode a JWT token. + + Args: + token: JWT token string to verify + + Returns: + User ID if token is valid, None otherwise + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user_id: str = payload.get("sub") + + if user_id is None: + return None + + return user_id + except JWTError: + return None diff --git a/app/utils/password.py b/app/utils/password.py new file mode 100644 index 0000000000000000000000000000000000000000..de72913f1655781d59d9ce80854019d3eb79d097 --- /dev/null +++ b/app/utils/password.py @@ -0,0 +1,48 @@ +"""Password hashing and verification""" +import bcrypt + + +def hash_password(password: str) -> str: + """ + Hash a plain text password using bcrypt + + Args: + password: Plain text password + + Returns: + Hashed password as string + """ + # Bcrypt has a 72-byte limit, truncate if necessary + password_bytes = password.encode('utf-8') + if len(password_bytes) > 72: + password_bytes = password_bytes[:72] + + # Generate salt and hash + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + + # Return as string + return hashed.decode('utf-8') + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a plain text password against a hashed password + + Args: + plain_password: Plain text password + hashed_password: Hashed password to verify against + + Returns: + True if password matches, False otherwise + """ + # Truncate to 72 bytes to match hashing behavior + password_bytes = plain_password.encode('utf-8') + if len(password_bytes) > 72: + password_bytes = password_bytes[:72] + + # Convert hashed password to bytes if it's a string + if isinstance(hashed_password, str): + hashed_password = hashed_password.encode('utf-8') + + return bcrypt.checkpw(password_bytes, hashed_password) diff --git a/app/utils/reranker.py b/app/utils/reranker.py new file mode 100644 index 0000000000000000000000000000000000000000..652e75e78bd89966ac67119dbaf95533061245be --- /dev/null +++ b/app/utils/reranker.py @@ -0,0 +1,93 @@ +""" +Reranking module for improving search result relevance. +Uses cross-encoder models to rerank retrieved chunks based on query relevance. +""" +from typing import List, Dict, Tuple +from sentence_transformers import CrossEncoder + + +class Reranker: + """Reranks search results using cross-encoder models.""" + + def __init__(self, model_name: str = 'cross-encoder/ms-marco-MiniLM-L-6-v2'): + """ + Initialize reranker with cross-encoder model. + + Args: + model_name: HuggingFace model name for cross-encoder + """ + self.model_name = model_name + self.model = CrossEncoder(model_name) + print(f"[Reranker] Loaded model: {model_name}") + + def rerank( + self, + query: str, + chunks: List[Dict], + top_k: int = 10 + ) -> List[Dict]: + """ + Rerank chunks based on relevance to query. + + Args: + query: Search query + chunks: List of chunk dictionaries with 'content' key + top_k: Number of top results to return after reranking + + Returns: + Reranked list of chunks (top_k most relevant) + """ + if not chunks: + return [] + + # Prepare query-document pairs + pairs = [(query, chunk['content']) for chunk in chunks] + + # Get relevance scores + scores = self.model.predict(pairs) + + # Combine chunks with scores and sort + chunks_with_scores = [ + {**chunk, 'rerank_score': float(score)} + for chunk, score in zip(chunks, scores) + ] + + # Sort by rerank score (highest first) + reranked = sorted( + chunks_with_scores, + key=lambda x: x['rerank_score'], + reverse=True + ) + + # Return top_k + return reranked[:top_k] + + def rerank_with_scores( + self, + query: str, + chunks: List[Dict] + ) -> List[Tuple[Dict, float]]: + """ + Rerank and return chunks with their relevance scores. + + Args: + query: Search query + chunks: List of chunk dictionaries + + Returns: + List of (chunk, score) tuples sorted by relevance + """ + if not chunks: + return [] + + pairs = [(query, chunk['content']) for chunk in chunks] + scores = self.model.predict(pairs) + + results = list(zip(chunks, scores)) + results.sort(key=lambda x: x[1], reverse=True) + + return results + + +# Global reranker instance +reranker = Reranker() diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..50c4d093d5c9dfe48c1ecf6a7ec0a2397b89754d --- /dev/null +++ b/app/utils/validators.py @@ -0,0 +1,69 @@ +import re +from typing import List + + +def validate_email(email: str) -> bool: + """ + Validate email format. + + Args: + email: Email string to validate + + Returns: + True if valid email format, False otherwise + """ + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + +def validate_password(password: str) -> tuple[bool, str]: + """ + Validate password strength. + + Args: + password: Password string to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if len(password) < 6: + return False, "Password must be at least 6 characters long" + + # Bcrypt has a 72-byte limit + if len(password.encode('utf-8')) > 72: + return False, "Password is too long (max 72 bytes)" + + return True, "" + + +def validate_file_type(filename: str, allowed_types: List[str]) -> bool: + """ + Validate file type by extension. + + Args: + filename: Name of the file + allowed_types: List of allowed extensions (e.g., ['pdf', 'docx']) + + Returns: + True if file type is allowed, False otherwise + """ + if '.' not in filename: + return False + + extension = filename.rsplit('.', 1)[1].lower() + return extension in allowed_types + + +def validate_file_size(file_size: int, max_size_mb: int = 10) -> bool: + """ + Validate file size. + + Args: + file_size: File size in bytes + max_size_mb: Maximum allowed size in MB + + Returns: + True if file size is within limit, False otherwise + """ + max_size_bytes = max_size_mb * 1024 * 1024 + return file_size <= max_size_bytes diff --git a/make_admin.py b/make_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..0139121171f79e6f96b6aca4e9b04aeaa062625d --- /dev/null +++ b/make_admin.py @@ -0,0 +1,6 @@ +import sqlite3 +conn = sqlite3.connect('data/database.db') +conn.execute("UPDATE users SET is_admin = 1 WHERE email = 'admin@example.com'") +conn.commit() +print("✅ User made admin!") +conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7706b6aed3c96ad9fc27a327932f56a361c7f09b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +groq==0.4.0 +chromadb==0.4.18 +sentence-transformers==2.2.2 +unstructured==0.11.0 +tavily-python==0.3.0 +httpx==0.25.2 +pydantic[email] +numpy<2.0 +huggingface-hub<0.20.0 +PyPDF2==3.0.1 +python-docx==1.1.0 +langgraph==0.0.34 +langchain-core>=0.1.38,<0.2.0