Kushal commited on
Commit
f3997d4
·
1 Parent(s): f3c8bda

Initial deployment: FastAPI backend with Docker

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +9 -0
  2. Dockerfile +35 -0
  3. README.md +8 -5
  4. app/__init__.py +0 -0
  5. app/config/__init__.py +0 -0
  6. app/config/settings.py +54 -0
  7. app/database/__init__.py +0 -0
  8. app/database/connection.py +33 -0
  9. app/database/models.py +122 -0
  10. app/llm/__init__.py +0 -0
  11. app/llm/agents/__init__.py +0 -0
  12. app/llm/agents/general.py +61 -0
  13. app/llm/agents/policy.py +173 -0
  14. app/llm/agents/rag.py +86 -0
  15. app/llm/agents/router.py +62 -0
  16. app/llm/agents/search.py +91 -0
  17. app/llm/client.py +84 -0
  18. app/llm/graph.py +450 -0
  19. app/llm/prompts/general.txt +26 -0
  20. app/llm/prompts/policy.txt +78 -0
  21. app/llm/prompts/rag.txt +60 -0
  22. app/llm/prompts/router.txt +32 -0
  23. app/llm/prompts/search.txt +17 -0
  24. app/llm/state.py +36 -0
  25. app/main.py +81 -0
  26. app/middleware/__init__.py +0 -0
  27. app/middleware/admin_auth.py +28 -0
  28. app/middleware/auth.py +95 -0
  29. app/middleware/error_handler.py +29 -0
  30. app/routes/__init__.py +0 -0
  31. app/routes/admin_policies.py +188 -0
  32. app/routes/auth.py +216 -0
  33. app/routes/chat.py +101 -0
  34. app/routes/documents.py +128 -0
  35. app/routes/news.py +156 -0
  36. app/routes/reports.py +58 -0
  37. app/routes/sessions.py +140 -0
  38. app/routes/settings.py +179 -0
  39. app/schemas/__init__.py +0 -0
  40. app/schemas/auth.py +41 -0
  41. app/schemas/chat.py +36 -0
  42. app/schemas/document.py +27 -0
  43. app/schemas/policy.py +51 -0
  44. app/schemas/session.py +33 -0
  45. app/services/__init__.py +0 -0
  46. app/services/auth_service.py +196 -0
  47. app/services/chat_service.py +212 -0
  48. app/services/document_service.py +161 -0
  49. app/services/news_service.py +153 -0
  50. app/services/policy_service.py +233 -0
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ __pycache__
3
+ *.pyc
4
+ .env
5
+ data/
6
+ .git
7
+ *.md
8
+ test/
9
+ scripts/
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements first (Docker layer caching)
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Pre-download sentence-transformer models during build
16
+ # This avoids slow cold-start downloads at runtime
17
+ RUN python -c "\
18
+ from sentence_transformers import SentenceTransformer, CrossEncoder; \
19
+ SentenceTransformer('all-MiniLM-L6-v2'); \
20
+ CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"
21
+
22
+ # Copy application code
23
+ COPY . .
24
+
25
+ # Create data directories
26
+ RUN mkdir -p data/chromadb
27
+
28
+ # Default port (HF Spaces sets PORT=7860, local default is 8000)
29
+ ENV PORT=7860
30
+
31
+ # Expose port
32
+ EXPOSE ${PORT}
33
+
34
+ # Start uvicorn with configurable port
35
+ CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT}
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Buildersai
3
- emoji: 🚀
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: BuildersAI
3
+ emoji: 🏗️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Builder's AI - Backend API
12
+
13
+ Construction AI Assistant API with Multi-Agent RAG System.
app/__init__.py ADDED
File without changes
app/config/__init__.py ADDED
File without changes
app/config/settings.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from typing import List, Optional
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ """Application settings loaded from environment variables."""
7
+
8
+ # App Configuration
9
+ SECRET_KEY: str
10
+ DEBUG: bool = False
11
+ PORT: int = 8000
12
+
13
+ # Database
14
+ DATABASE_URL: str = "sqlite:///data/database.db"
15
+
16
+ # AI Services
17
+ GROQ_API_KEY: str
18
+ GROQ_MODEL: str = "llama-3.1-8b-instant"
19
+ TAVILY_API_KEY: str
20
+
21
+ # JWT Configuration
22
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
23
+ REFRESH_TOKEN_EXPIRE_DAYS: int = 7
24
+ ALGORITHM: str = "HS256"
25
+
26
+ # Google OAuth
27
+ GOOGLE_CLIENT_ID: Optional[str] = ""
28
+ GOOGLE_CLIENT_SECRET: Optional[str] = ""
29
+ GOOGLE_REDIRECT_URI: Optional[str] = ""
30
+
31
+ # CORS
32
+ ALLOWED_ORIGINS: str
33
+
34
+ @property
35
+ def cors_origins(self) -> List[str]:
36
+ """Parse CORS origins from comma-separated string."""
37
+ return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
38
+
39
+ # Backward compatibility properties (lowercase)
40
+ @property
41
+ def secret_key(self) -> str:
42
+ return self.SECRET_KEY
43
+
44
+ @property
45
+ def algorithm(self) -> str:
46
+ return self.ALGORITHM
47
+
48
+ class Config:
49
+ env_file = ".env"
50
+ case_sensitive = True
51
+
52
+
53
+ # Global settings instance
54
+ settings = Settings()
app/database/__init__.py ADDED
File without changes
app/database/connection.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine, Column, String, Integer, Text, DateTime, ForeignKey
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker, relationship
4
+ from datetime import datetime
5
+ import uuid
6
+
7
+ from app.config.settings import settings
8
+
9
+ # Create SQLAlchemy engine
10
+ engine = create_engine(
11
+ settings.DATABASE_URL,
12
+ connect_args={"check_same_thread": False} # Needed for SQLite
13
+ )
14
+
15
+ # Session factory
16
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
17
+
18
+ # Base class for models
19
+ Base = declarative_base()
20
+
21
+
22
+ def get_db():
23
+ """Dependency to get database session."""
24
+ db = SessionLocal()
25
+ try:
26
+ yield db
27
+ finally:
28
+ db.close()
29
+
30
+
31
+ def init_db():
32
+ """Initialize database tables."""
33
+ Base.metadata.create_all(bind=engine)
app/database/models.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, CheckConstraint
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ import uuid
5
+
6
+ from app.database.connection import Base
7
+
8
+
9
+ def generate_id():
10
+ """Generate a unique ID."""
11
+ return str(uuid.uuid4())
12
+
13
+
14
+ class User(Base):
15
+ """User model for authentication."""
16
+ __tablename__ = "users"
17
+
18
+ id = Column(String, primary_key=True, default=generate_id)
19
+ email = Column(String, unique=True, nullable=False, index=True)
20
+ name = Column(String, nullable=True)
21
+ password_hash = Column(String, nullable=True) # Nullable for OAuth users
22
+ google_id = Column(String, unique=True, nullable=True, index=True)
23
+ is_admin = Column(Integer, default=0) # 0 = regular user, 1 = admin
24
+ created_at = Column(DateTime, default=datetime.utcnow)
25
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
26
+
27
+ # Relationships
28
+ sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
29
+ documents = relationship("Document", back_populates="user", cascade="all, delete-orphan")
30
+
31
+
32
+ class Session(Base):
33
+ """Chat session model."""
34
+ __tablename__ = "sessions"
35
+
36
+ id = Column(String, primary_key=True, default=generate_id)
37
+ user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
38
+ title = Column(String, default="New Conversation")
39
+ summary = Column(Text, nullable=True)
40
+ created_at = Column(DateTime, default=datetime.utcnow)
41
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
42
+
43
+ # Relationships
44
+ user = relationship("User", back_populates="sessions")
45
+ messages = relationship("Message", back_populates="session", cascade="all, delete-orphan")
46
+
47
+
48
+ class Message(Base):
49
+ """Chat message model."""
50
+ __tablename__ = "messages"
51
+
52
+ id = Column(String, primary_key=True, default=generate_id)
53
+ session_id = Column(String, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
54
+ role = Column(String, nullable=False) # 'user' or 'assistant'
55
+ content = Column(Text, nullable=False)
56
+ meta = Column(Text, nullable=True) # JSON string for metadata (agent, sources, etc.)
57
+ created_at = Column(DateTime, default=datetime.utcnow)
58
+
59
+ # Relationships
60
+ session = relationship("Session", back_populates="messages")
61
+
62
+ # Constraint
63
+ __table_args__ = (
64
+ CheckConstraint("role IN ('user', 'assistant')", name="check_role"),
65
+ )
66
+
67
+
68
+ class Document(Base):
69
+ """Uploaded document model."""
70
+ __tablename__ = "documents"
71
+
72
+ id = Column(String, primary_key=True, default=generate_id)
73
+ user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
74
+ filename = Column(String, nullable=False)
75
+ file_path = Column(String, nullable=False)
76
+ file_type = Column(String, nullable=True)
77
+ file_size = Column(Integer, nullable=True)
78
+ created_at = Column(DateTime, default=datetime.utcnow)
79
+
80
+ # Relationships
81
+ user = relationship("User", back_populates="documents")
82
+
83
+
84
+ class OfficialPolicy(Base):
85
+ """Official policy document model (admin-only upload, visible to all)."""
86
+ __tablename__ = "official_policies"
87
+
88
+ id = Column(String, primary_key=True, default=generate_id)
89
+ title = Column(String, nullable=False)
90
+ description = Column(Text, nullable=True)
91
+ filename = Column(String, nullable=False)
92
+ file_path = Column(String, nullable=False)
93
+ file_type = Column(String, nullable=True)
94
+ file_size = Column(Integer, nullable=True)
95
+ category = Column(String, nullable=True) # e.g., "OSHA", "Safety", "Building Codes"
96
+ uploaded_by = Column(String, ForeignKey("users.id"), nullable=False)
97
+ is_active = Column(Integer, default=1) # 0 = inactive, 1 = active
98
+ created_at = Column(DateTime, default=datetime.utcnow)
99
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
100
+
101
+
102
+ class UserSettings(Base):
103
+ """User settings and preferences model."""
104
+ __tablename__ = "user_settings"
105
+
106
+ id = Column(String, primary_key=True, default=generate_id)
107
+ user_id = Column(String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
108
+
109
+ # Profile settings
110
+ bio = Column(Text, nullable=True)
111
+ phone = Column(String, nullable=True)
112
+ company = Column(String, nullable=True)
113
+
114
+ # Appearance settings
115
+ theme = Column(String, default="system") # light, dark, system
116
+
117
+ # Notification settings
118
+ email_notifications = Column(Integer, default=1) # 0 = off, 1 = on
119
+ update_notifications = Column(Integer, default=1) # 0 = off, 1 = on
120
+
121
+ created_at = Column(DateTime, default=datetime.utcnow)
122
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
app/llm/__init__.py ADDED
File without changes
app/llm/agents/__init__.py ADDED
File without changes
app/llm/agents/general.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import os
3
+
4
+ from app.llm.client import llm_client
5
+
6
+
7
+ class GeneralAgent:
8
+ """Agent for general construction questions and conversations."""
9
+
10
+ def __init__(self):
11
+ """Initialize general agent with prompt template."""
12
+ prompt_path = os.path.join(
13
+ os.path.dirname(__file__),
14
+ "..",
15
+ "prompts",
16
+ "general.txt"
17
+ )
18
+ with open(prompt_path, "r") as f:
19
+ self.system_prompt = f.read()
20
+
21
+ def answer(self, query: str, chat_history: List[Dict] = None) -> Dict[str, any]:
22
+ """
23
+ Generate answer for general construction queries.
24
+
25
+ Args:
26
+ query: User query string
27
+ chat_history: Optional chat history for context
28
+
29
+ Returns:
30
+ Dictionary with 'answer' and 'agent' keys
31
+ """
32
+ try:
33
+ messages = [{"role": "system", "content": self.system_prompt}]
34
+
35
+ # Add chat history if provided
36
+ if chat_history:
37
+ messages.extend(chat_history[-6:]) # Last 3 exchanges
38
+
39
+ messages.append({"role": "user", "content": query})
40
+
41
+ answer = llm_client.get_completion(
42
+ messages=messages,
43
+ temperature=0.7,
44
+ max_tokens=1024
45
+ )
46
+
47
+ return {
48
+ "answer": answer,
49
+ "agent": "general"
50
+ }
51
+
52
+ except Exception as e:
53
+ print(f"General agent error: {e}")
54
+ return {
55
+ "answer": "I apologize, but I encountered an error. Please try again.",
56
+ "agent": "general"
57
+ }
58
+
59
+
60
+ # Global general agent instance
61
+ general_agent = GeneralAgent()
app/llm/agents/policy.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import os
3
+
4
+ from app.llm.client import llm_client
5
+ from app.services.rag_service import rag_service
6
+
7
+
8
+ class PolicyAgent:
9
+ """Agent for construction policy and regulatory queries using official policy documents."""
10
+
11
+ def __init__(self):
12
+ """Initialize policy agent with prompt template."""
13
+ prompt_path = os.path.join(
14
+ os.path.dirname(__file__),
15
+ "..",
16
+ "prompts",
17
+ "policy.txt"
18
+ )
19
+ with open(prompt_path, "r") as f:
20
+ self.system_prompt = f.read()
21
+
22
+ def answer(self, query: str, context_chunks: List[Dict] = None) -> Dict[str, any]:
23
+ """
24
+ Generate answer for policy/regulatory queries using official policy documents.
25
+
26
+ Args:
27
+ query: User query string
28
+ context_chunks: Retrieved policy chunks from RAG search
29
+
30
+ Returns:
31
+ Dictionary with 'answer', 'agent', and 'sources' keys
32
+ """
33
+ try:
34
+ # If no context provided, search all official policies (retrieve more chunks for better coverage)
35
+ if context_chunks is None:
36
+ context_chunks = rag_service.collection.query(
37
+ query_embeddings=[rag_service.embedding_generator.generate_embedding(query)],
38
+ n_results=10, # Increased from 5 to 10 for better coverage
39
+ where={"user_id": "official_policies"}
40
+ )
41
+
42
+ # Format results
43
+ if context_chunks and context_chunks['documents']:
44
+ context_chunks = [
45
+ {
46
+ "content": context_chunks['documents'][0][i],
47
+ "metadata": context_chunks['metadatas'][0][i]
48
+ }
49
+ for i in range(len(context_chunks['documents'][0]))
50
+ ]
51
+ else:
52
+ context_chunks = []
53
+
54
+ # Build context from chunks
55
+ if not context_chunks:
56
+ return {
57
+ "answer": "I don't have any official policy documents to answer this question. Please ensure policies are uploaded in the Admin Panel.",
58
+ "agent": "policy",
59
+ "sources": []
60
+ }
61
+
62
+ # Format context with clear chunk numbering
63
+ context_sections = []
64
+ for i, chunk in enumerate(context_chunks, 1):
65
+ doc_id = chunk['metadata'].get('document_id', 'unknown')
66
+ filename = chunk['metadata'].get('filename', 'Official Policy')
67
+ chunk_idx = chunk['metadata'].get('chunk_index', '?')
68
+
69
+ context_sections.append(
70
+ f"=== EXCERPT {i} ===\n"
71
+ f"Document: {filename}\n"
72
+ f"Document ID: {doc_id}\n"
73
+ f"Section: Chunk {chunk_idx}\n"
74
+ f"---\n"
75
+ f"{chunk['content']}\n"
76
+ )
77
+
78
+ context_text = "\n".join(context_sections)
79
+
80
+ # Create strict user message
81
+ user_message = f"""DOCUMENT EXCERPTS FROM OFFICIAL POLICY:
82
+
83
+ {context_text}
84
+
85
+ ========================================
86
+ USER QUESTION: {query}
87
+ ========================================
88
+
89
+ REMEMBER:
90
+ - Answer using ONLY the excerpts above
91
+ - Include clause/section numbers if present in the text
92
+ - Quote exact definitions or requirements
93
+ - If the answer is not in the excerpts, say "The provided document sections do not contain this information"
94
+ - Do NOT use external knowledge from other building codes
95
+
96
+ Now provide your answer:"""
97
+
98
+ messages = [
99
+ {"role": "system", "content": self.system_prompt},
100
+ {"role": "user", "content": user_message}
101
+ ]
102
+
103
+ answer = llm_client.get_completion(
104
+ messages=messages,
105
+ temperature=0.1, # Very low temperature for maximum accuracy and minimal creativity
106
+ max_tokens=2000
107
+ )
108
+
109
+ # Extract sources and policy names
110
+ sources = []
111
+ policy_names = set()
112
+
113
+ # Get policy titles from database
114
+ from app.database.connection import SessionLocal
115
+ from app.database.models import OfficialPolicy
116
+
117
+ db = SessionLocal()
118
+ try:
119
+ # Collect unique document IDs
120
+ doc_ids = set()
121
+ for chunk in context_chunks:
122
+ doc_id = chunk["metadata"].get("document_id", "")
123
+ if doc_id:
124
+ doc_ids.add(doc_id)
125
+
126
+ # Fetch policy titles from database
127
+ policy_title_map = {}
128
+ if doc_ids:
129
+ policies = db.query(OfficialPolicy).filter(
130
+ OfficialPolicy.id.in_(doc_ids)
131
+ ).all()
132
+ policy_title_map = {p.id: p.title for p in policies}
133
+
134
+ # Build sources and collect policy names
135
+ for chunk in context_chunks:
136
+ doc_id = chunk["metadata"].get("document_id", "")
137
+ policy_title = policy_title_map.get(doc_id, chunk["metadata"].get("filename", "Official Policy"))
138
+
139
+ sources.append({
140
+ "content": chunk["content"][:300] + "...",
141
+ "document_id": doc_id,
142
+ "filename": chunk["metadata"].get("filename", "Official Policy"),
143
+ "title": policy_title,
144
+ "chunk_index": chunk["metadata"].get("chunk_index", 0)
145
+ })
146
+
147
+ # Collect unique policy titles (not filenames)
148
+ if policy_title:
149
+ policy_names.add(policy_title)
150
+
151
+ finally:
152
+ db.close()
153
+
154
+ print(f"[Policy Agent] Returning policy_names: {list(policy_names)}")
155
+
156
+ return {
157
+ "answer": answer,
158
+ "agent": "policy",
159
+ "sources": sources,
160
+ "policy_names": list(policy_names) # List of policy titles used
161
+ }
162
+
163
+ except Exception as e:
164
+ print(f"Policy agent error: {e}")
165
+ return {
166
+ "answer": "I encountered an error while processing your policy question. Please try again.",
167
+ "agent": "policy",
168
+ "sources": []
169
+ }
170
+
171
+
172
+ # Global policy agent instance
173
+ policy_agent = PolicyAgent()
app/llm/agents/rag.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import os
3
+
4
+ from app.llm.client import llm_client
5
+
6
+
7
+ class RAGAgent:
8
+ """Agent for document-based question answering using RAG."""
9
+
10
+ def __init__(self):
11
+ """Initialize RAG agent with prompt template."""
12
+ prompt_path = os.path.join(
13
+ os.path.dirname(__file__),
14
+ "..",
15
+ "prompts",
16
+ "rag.txt"
17
+ )
18
+ with open(prompt_path, "r") as f:
19
+ self.system_prompt = f.read()
20
+
21
+ def answer(self, query: str, context_chunks: List[Dict]) -> Dict[str, any]:
22
+ """
23
+ Generate answer based on retrieved document chunks.
24
+
25
+ Args:
26
+ query: User query string
27
+ context_chunks: List of retrieved document chunks with metadata
28
+
29
+ Returns:
30
+ Dictionary with 'answer', 'sources', and 'agent' keys
31
+ """
32
+ try:
33
+ # Format context for LLM
34
+ context = self._format_context(context_chunks)
35
+
36
+ messages = [
37
+ {"role": "system", "content": self.system_prompt},
38
+ {"role": "user", "content": f"Context:\n{context}\n\nQuery: {query}"}
39
+ ]
40
+
41
+ answer = llm_client.get_completion(
42
+ messages=messages,
43
+ temperature=0.3, # Lower temperature for factual accuracy
44
+ max_tokens=1024
45
+ )
46
+
47
+ # Extract unique sources
48
+ sources = list({
49
+ chunk.get("metadata", {}).get("filename", "Unknown")
50
+ for chunk in context_chunks
51
+ })
52
+
53
+ return {
54
+ "answer": answer,
55
+ "sources": sources,
56
+ "agent": "rag"
57
+ }
58
+
59
+ except Exception as e:
60
+ print(f"RAG agent error: {e}")
61
+ return {
62
+ "answer": "I encountered an error while processing your question. Please try again.",
63
+ "sources": [],
64
+ "agent": "rag"
65
+ }
66
+
67
+ def _format_context(self, chunks: List[Dict]) -> str:
68
+ """Format document chunks for LLM context."""
69
+ if not chunks:
70
+ return "No relevant documents found."
71
+
72
+ formatted = []
73
+ for i, chunk in enumerate(chunks, 1):
74
+ metadata = chunk.get("metadata", {})
75
+ content = chunk.get("content", "")
76
+ filename = metadata.get("filename", "Unknown")
77
+
78
+ formatted.append(
79
+ f"[Document {i}: {filename}]\n{content}\n"
80
+ )
81
+
82
+ return "\n---\n".join(formatted)
83
+
84
+
85
+ # Global RAG agent instance
86
+ rag_agent = RAGAgent()
app/llm/agents/router.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import json
3
+ import os
4
+
5
+ from app.llm.client import llm_client
6
+
7
+
8
+ class RouterAgent:
9
+ """Agent that routes queries to appropriate specialized agents."""
10
+
11
+ def __init__(self):
12
+ """Initialize router agent with prompt template."""
13
+ prompt_path = os.path.join(
14
+ os.path.dirname(__file__),
15
+ "..",
16
+ "prompts",
17
+ "router.txt"
18
+ )
19
+ with open(prompt_path, "r") as f:
20
+ self.system_prompt = f.read()
21
+
22
+ def route(self, query: str, chat_history: List[Dict] = None) -> Dict[str, str]:
23
+ """
24
+ Route a query to the appropriate agent.
25
+
26
+ Args:
27
+ query: User query string
28
+ chat_history: Optional chat history for context
29
+
30
+ Returns:
31
+ Dictionary with 'agent' and 'reasoning' keys
32
+ """
33
+ messages = [
34
+ {"role": "system", "content": self.system_prompt},
35
+ {"role": "user", "content": query}
36
+ ]
37
+
38
+ try:
39
+ response = llm_client.get_completion(
40
+ messages=messages,
41
+ temperature=0.3,
42
+ max_tokens=256,
43
+ json_mode=True
44
+ )
45
+
46
+ result = json.loads(response)
47
+
48
+ # Validate response
49
+ if "agent" not in result or result["agent"] not in ["search", "rag", "policy", "general"]:
50
+ # Default to general if invalid
51
+ return {"agent": "general", "reasoning": "Default routing"}
52
+
53
+ return result
54
+
55
+ except Exception as e:
56
+ print(f"Router agent error: {e}")
57
+ # Default to general agent on error
58
+ return {"agent": "general", "reasoning": "Error in routing, using general agent"}
59
+
60
+
61
+ # Global router agent instance
62
+ router_agent = RouterAgent()
app/llm/agents/search.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import os
3
+ from tavily import TavilyClient
4
+
5
+ from app.llm.client import llm_client
6
+ from app.config.settings import settings
7
+
8
+
9
+ class SearchAgent:
10
+ """Agent that performs web searches and generates answers."""
11
+
12
+ def __init__(self):
13
+ """Initialize search agent with Tavily client and prompt."""
14
+ self.tavily_client = TavilyClient(api_key=settings.TAVILY_API_KEY)
15
+
16
+ prompt_path = os.path.join(
17
+ os.path.dirname(__file__),
18
+ "..",
19
+ "prompts",
20
+ "search.txt"
21
+ )
22
+ with open(prompt_path, "r") as f:
23
+ self.system_prompt = f.read()
24
+
25
+ def search_and_answer(self, query: str) -> Dict[str, any]:
26
+ """
27
+ Perform web search and generate answer.
28
+
29
+ Args:
30
+ query: User query string
31
+
32
+ Returns:
33
+ Dictionary with 'answer' and 'sources' keys
34
+ """
35
+ try:
36
+ # Perform Tavily search
37
+ search_results = self.tavily_client.search(
38
+ query=query,
39
+ search_depth="basic",
40
+ max_results=5
41
+ )
42
+
43
+ # Format search results for LLM
44
+ context = self._format_search_results(search_results.get("results", []))
45
+
46
+ # Generate answer using LLM
47
+ messages = [
48
+ {"role": "system", "content": self.system_prompt},
49
+ {"role": "user", "content": f"Query: {query}\n\nSearch Results:\n{context}"}
50
+ ]
51
+
52
+ answer = llm_client.get_completion(
53
+ messages=messages,
54
+ temperature=0.7,
55
+ max_tokens=1024
56
+ )
57
+
58
+ # Extract sources
59
+ sources = [
60
+ {"title": r.get("title"), "url": r.get("url")}
61
+ for r in search_results.get("results", [])
62
+ ]
63
+
64
+ return {
65
+ "answer": answer,
66
+ "sources": sources,
67
+ "agent": "search"
68
+ }
69
+
70
+ except Exception as e:
71
+ print(f"Search agent error: {e}")
72
+ return {
73
+ "answer": "I encountered an error while searching for information. Please try again.",
74
+ "sources": [],
75
+ "agent": "search"
76
+ }
77
+
78
+ def _format_search_results(self, results: List[Dict]) -> str:
79
+ """Format search results for LLM context."""
80
+ formatted = []
81
+ for i, result in enumerate(results, 1):
82
+ formatted.append(
83
+ f"{i}. {result.get('title', 'No title')}\n"
84
+ f" URL: {result.get('url', 'No URL')}\n"
85
+ f" Content: {result.get('content', 'No content')}\n"
86
+ )
87
+ return "\n".join(formatted)
88
+
89
+
90
+ # Global search agent instance
91
+ search_agent = SearchAgent()
app/llm/client.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from groq import Groq
2
+ from typing import List, Dict, Optional
3
+ import json
4
+
5
+ from app.config.settings import settings
6
+
7
+
8
+ class LLMClient:
9
+ """Client for interacting with Groq LLM API."""
10
+
11
+ def __init__(self):
12
+ """Initialize Groq client."""
13
+ self.client = Groq(api_key=settings.GROQ_API_KEY)
14
+ self.model = settings.GROQ_MODEL
15
+
16
+ def get_completion(
17
+ self,
18
+ messages: List[Dict[str, str]],
19
+ temperature: float = 0.7,
20
+ max_tokens: int = 1024,
21
+ json_mode: bool = False
22
+ ) -> str:
23
+ """
24
+ Get completion from Groq LLM.
25
+
26
+ Args:
27
+ messages: List of message dictionaries with 'role' and 'content'
28
+ temperature: Sampling temperature (0-2)
29
+ max_tokens: Maximum tokens in response
30
+ json_mode: Whether to request JSON output
31
+
32
+ Returns:
33
+ Response content string
34
+ """
35
+ try:
36
+ response_format = {"type": "json_object"} if json_mode else None
37
+
38
+ chat_completion = self.client.chat.completions.create(
39
+ messages=messages,
40
+ model=self.model,
41
+ temperature=temperature,
42
+ max_tokens=max_tokens,
43
+ response_format=response_format
44
+ )
45
+
46
+ return chat_completion.choices[0].message.content
47
+
48
+ except Exception as e:
49
+ print(f"Error getting LLM completion: {e}")
50
+ raise
51
+
52
+ def get_completion_with_retry(
53
+ self,
54
+ messages: List[Dict[str, str]],
55
+ temperature: float = 0.7,
56
+ max_tokens: int = 1024,
57
+ json_mode: bool = False,
58
+ max_retries: int = 3
59
+ ) -> str:
60
+ """
61
+ Get completion with retry logic.
62
+
63
+ Args:
64
+ messages: List of message dictionaries
65
+ temperature: Sampling temperature
66
+ max_tokens: Maximum tokens
67
+ json_mode: Whether to request JSON output
68
+ max_retries: Maximum number of retries
69
+
70
+ Returns:
71
+ Response content string
72
+ """
73
+ for attempt in range(max_retries):
74
+ try:
75
+ return self.get_completion(messages, temperature, max_tokens, json_mode)
76
+ except Exception as e:
77
+ if attempt == max_retries - 1:
78
+ raise
79
+ print(f"Retry {attempt + 1}/{max_retries} after error: {e}")
80
+ continue
81
+
82
+
83
+ # Global LLM client instance
84
+ llm_client = LLMClient()
app/llm/graph.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph-based multi-agent workflow for Builder's AI.
3
+ This implements a graph-based orchestration of multiple specialized agents.
4
+ """
5
+ from typing import Dict, List, Optional
6
+ from langgraph.graph import StateGraph, END
7
+ import json
8
+
9
+ from app.llm.state import AgentState
10
+ from app.llm.agents.router import router_agent
11
+ from app.llm.agents.search import search_agent
12
+ from app.llm.agents.rag import rag_agent
13
+ from app.llm.agents.policy import policy_agent
14
+ from app.llm.agents.general import general_agent
15
+ from app.services.rag_service import rag_service
16
+ from app.utils.embeddings import embedding_generator
17
+
18
+
19
+ class MultiAgentGraph:
20
+ """LangGraph-based multi-agent workflow orchestrator."""
21
+
22
+ def __init__(self):
23
+ """Initialize the multi-agent graph."""
24
+ self.graph = self._build_graph()
25
+ print("[Multi-Agent Graph] Initialized")
26
+
27
+ def _build_graph(self) -> StateGraph:
28
+ """
29
+ Build the LangGraph workflow.
30
+
31
+ Returns:
32
+ Compiled StateGraph
33
+ """
34
+ # Create workflow graph
35
+ workflow = StateGraph(AgentState)
36
+
37
+ # Add nodes
38
+ workflow.add_node("router", self._router_node)
39
+ workflow.add_node("search_agent", self._search_node)
40
+ workflow.add_node("rag_agent", self._rag_node)
41
+ workflow.add_node("policy_agent", self._policy_node)
42
+ workflow.add_node("general_agent", self._general_node)
43
+
44
+ # Set entry point
45
+ workflow.set_entry_point("router")
46
+
47
+ # Add conditional edges from router to specialized agents
48
+ workflow.add_conditional_edges(
49
+ "router",
50
+ self._route_query,
51
+ {
52
+ "search": "search_agent",
53
+ "rag": "rag_agent",
54
+ "policy": "policy_agent",
55
+ "general": "general_agent"
56
+ }
57
+ )
58
+
59
+ # All agent nodes end the workflow
60
+ workflow.add_edge("search_agent", END)
61
+ workflow.add_edge("rag_agent", END)
62
+ workflow.add_edge("policy_agent", END)
63
+ workflow.add_edge("general_agent", END)
64
+
65
+ # Compile the graph
66
+ return workflow.compile()
67
+
68
+ def _router_node(self, state: AgentState) -> AgentState:
69
+ """
70
+ Router node: Determines which specialized agent should handle the query.
71
+
72
+ Args:
73
+ state: Current agent state
74
+
75
+ Returns:
76
+ Updated state with routing decision
77
+ """
78
+ print(f"[Router Node] Processing query: {state['query'][:50]}...")
79
+
80
+ try:
81
+ # Use router agent to determine the appropriate agent
82
+ routing = router_agent.route(
83
+ query=state["query"],
84
+ chat_history=state.get("chat_history", [])
85
+ )
86
+
87
+ agent_type = routing.get("agent", "general")
88
+ reasoning = routing.get("reasoning", "")
89
+
90
+ print(f"[Router Node] Routing to: {agent_type} - {reasoning}")
91
+
92
+ return {
93
+ **state,
94
+ "agent_type": agent_type,
95
+ "routing_reasoning": reasoning
96
+ }
97
+
98
+ except Exception as e:
99
+ print(f"[Router Node] Error: {e}")
100
+ return {
101
+ **state,
102
+ "agent_type": "general",
103
+ "routing_reasoning": f"Error in routing: {str(e)}",
104
+ "error": str(e)
105
+ }
106
+
107
+ def _route_query(self, state: AgentState) -> str:
108
+ """
109
+ Conditional edge function to route to the appropriate agent.
110
+
111
+ Args:
112
+ state: Current agent state
113
+
114
+ Returns:
115
+ Agent type string
116
+ """
117
+ return state.get("agent_type", "general")
118
+
119
+ def _search_node(self, state: AgentState) -> AgentState:
120
+ """
121
+ Search agent node: Performs web search and generates answer.
122
+
123
+ Args:
124
+ state: Current agent state
125
+
126
+ Returns:
127
+ Updated state with search results and answer
128
+ """
129
+ print("[Search Node] Executing web search...")
130
+
131
+ try:
132
+ response = search_agent.search_and_answer(state["query"])
133
+
134
+ return {
135
+ **state,
136
+ "answer": response.get("answer", ""),
137
+ "sources": response.get("sources", []),
138
+ "search_results": response.get("sources", []),
139
+ "metadata": {
140
+ "agent": "search",
141
+ "routing_reasoning": state.get("routing_reasoning", "")
142
+ }
143
+ }
144
+
145
+ except Exception as e:
146
+ print(f"[Search Node] Error: {e}")
147
+ return {
148
+ **state,
149
+ "answer": "I encountered an error while searching. Please try again.",
150
+ "sources": [],
151
+ "error": str(e)
152
+ }
153
+
154
+ def _rag_node(self, state: AgentState) -> AgentState:
155
+ """
156
+ RAG agent node: Retrieves relevant documents and generates answer.
157
+
158
+ Args:
159
+ state: Current agent state
160
+
161
+ Returns:
162
+ Updated state with RAG context and answer
163
+ """
164
+ print("[RAG Node] Performing semantic search...")
165
+
166
+ try:
167
+ # Check if policy IDs are provided
168
+ policy_ids = state.get("policy_ids")
169
+
170
+ if policy_ids:
171
+ # Search within selected policies
172
+ print(f"[RAG Node] Searching within {len(policy_ids)} selected policies")
173
+ print(f"[RAG Node] Policy IDs: {policy_ids}")
174
+
175
+ context_chunks = rag_service.search_policies(
176
+ query=state["query"],
177
+ policy_ids=policy_ids,
178
+ top_k=10 # Increased for better coverage
179
+ )
180
+
181
+ print(f"[RAG Node] Found {len(context_chunks)} chunks from policies")
182
+ else:
183
+ # Regular document search
184
+ print(f"[RAG Node] Searching user documents for user_id: {state.get('user_id')}")
185
+ context_chunks = rag_service.semantic_search(
186
+ query=state["query"],
187
+ user_id=state.get("user_id"),
188
+ top_k=10 # Increased for better coverage
189
+ )
190
+ print(f"[RAG Node] Found {len(context_chunks)} chunks from user docs")
191
+
192
+ if not context_chunks:
193
+ print("[RAG Node] No relevant documents found")
194
+ no_doc_message = (
195
+ "I don't have any content in the selected policies to answer this question."
196
+ if policy_ids
197
+ else "I don't have any uploaded documents to answer this question. Please upload construction documents or ask a general question."
198
+ )
199
+ return {
200
+ **state,
201
+ "answer": no_doc_message,
202
+ "sources": [],
203
+ "context_chunks": [],
204
+ "metadata": {
205
+ "agent": "rag",
206
+ "note": "No documents available",
207
+ "policy_mode": bool(policy_ids)
208
+ }
209
+ }
210
+
211
+ # Generate answer using RAG agent
212
+ response = rag_agent.answer(state["query"], context_chunks)
213
+
214
+ # Determine agent label: "policy" if searching official policies, "rag" if user docs
215
+ agent_label = "policy" if policy_ids else "rag"
216
+
217
+ return {
218
+ **state,
219
+ "answer": response.get("answer", ""),
220
+ "sources": response.get("sources", []),
221
+ "context_chunks": context_chunks,
222
+ "metadata": {
223
+ "agent": agent_label, # "policy" or "rag"
224
+ "chunks_retrieved": len(context_chunks),
225
+ "routing_reasoning": state.get("routing_reasoning", ""),
226
+ "policy_mode": bool(policy_ids),
227
+ "policy_count": len(policy_ids) if policy_ids else 0
228
+ }
229
+ }
230
+
231
+ except Exception as e:
232
+ print(f"[RAG Node] Error: {e}")
233
+ return {
234
+ **state,
235
+ "answer": "I encountered an error while processing your document query. Please try again.",
236
+ "sources": [],
237
+ "error": str(e)
238
+ }
239
+
240
+ def _policy_node(self, state: AgentState) -> AgentState:
241
+ """
242
+ Policy agent node: Handles regulatory and compliance queries using official policy documents.
243
+
244
+ Args:
245
+ state: Current agent state
246
+
247
+ Returns:
248
+ Updated state with policy answer
249
+ """
250
+ print("[Policy Node] Processing policy query...")
251
+
252
+ try:
253
+ # Check if policies are selected
254
+ policy_ids = state.get("policy_ids", [])
255
+
256
+ if not policy_ids:
257
+ print("[Policy Node] No policies selected, redirecting to RAG agent")
258
+ return {
259
+ **state,
260
+ "answer": "Please select at least one policy document from the sidebar to get policy-specific answers.",
261
+ "sources": [],
262
+ "metadata": {
263
+ "agent": "policy",
264
+ "note": "No policies selected",
265
+ "routing_reasoning": state.get("routing_reasoning", "")
266
+ }
267
+ }
268
+
269
+ # Search selected official policies for relevant information
270
+ policy_filter = {
271
+ "$and": [
272
+ {"user_id": {"$eq": "official_policies"}},
273
+ {"document_id": {"$in": policy_ids}}
274
+ ]
275
+ }
276
+
277
+ context_chunks = rag_service.collection.query(
278
+ query_embeddings=[embedding_generator.generate_embedding(state["query"])],
279
+ n_results=10,
280
+ where=policy_filter
281
+ )
282
+
283
+ # Format chunks
284
+ if context_chunks and context_chunks['documents']:
285
+ formatted_chunks = [
286
+ {
287
+ "content": context_chunks['documents'][0][i],
288
+ "metadata": context_chunks['metadatas'][0][i]
289
+ }
290
+ for i in range(len(context_chunks['documents'][0]))
291
+ ]
292
+ else:
293
+ formatted_chunks = []
294
+
295
+ if not formatted_chunks:
296
+ return {
297
+ **state,
298
+ "answer": "I couldn't find relevant information in the selected policy documents. Please try rephrasing your question or selecting different policies.",
299
+ "sources": [],
300
+ "metadata": {
301
+ "agent": "policy",
302
+ "note": "No relevant content found in selected policies"
303
+ }
304
+ }
305
+
306
+ # Use policy agent with context
307
+ response = policy_agent.answer(state["query"], formatted_chunks)
308
+
309
+ print(f"[Policy Node] Response policy_names: {response.get('policy_names', [])}")
310
+
311
+ return {
312
+ **state,
313
+ "answer": response.get("answer", ""),
314
+ "sources": response.get("sources", []),
315
+ "policy_names": response.get("policy_names", []), # Pass policy names through
316
+ "metadata": {
317
+ "agent": "policy",
318
+ "routing_reasoning": state.get("routing_reasoning", ""),
319
+ "chunks_retrieved": len(formatted_chunks),
320
+ "policy_names": response.get("policy_names", []) # Include in metadata too
321
+ }
322
+ }
323
+
324
+ except Exception as e:
325
+ print(f"[Policy Node] Error: {e}")
326
+ return {
327
+ **state,
328
+ "answer": "I encountered an error while processing your policy question. Please try again.",
329
+ "sources": [],
330
+ "error": str(e)
331
+ }
332
+
333
+ def _general_node(self, state: AgentState) -> AgentState:
334
+ """
335
+ General agent node: Handles general construction questions and conversations.
336
+
337
+ Args:
338
+ state: Current agent state
339
+
340
+ Returns:
341
+ Updated state with general answer
342
+ """
343
+ print("[General Node] Processing general query...")
344
+
345
+ try:
346
+ # Format chat history for the agent
347
+ chat_history = state.get("chat_history", [])
348
+
349
+ response = general_agent.answer(
350
+ query=state["query"],
351
+ chat_history=chat_history
352
+ )
353
+
354
+ return {
355
+ **state,
356
+ "answer": response.get("answer", ""),
357
+ "sources": [],
358
+ "metadata": {
359
+ "agent": "general",
360
+ "routing_reasoning": state.get("routing_reasoning", "")
361
+ }
362
+ }
363
+
364
+ except Exception as e:
365
+ print(f"[General Node] Error: {e}")
366
+ return {
367
+ **state,
368
+ "answer": "I apologize, but I encountered an error. Please try again.",
369
+ "sources": [],
370
+ "error": str(e)
371
+ }
372
+
373
+ def process_query(
374
+ self,
375
+ query: str,
376
+ user_id: Optional[str] = None,
377
+ chat_history: Optional[List[Dict]] = None,
378
+ policy_ids: Optional[List[str]] = None
379
+ ) -> Dict:
380
+ """
381
+ Process a user query through the multi-agent graph.
382
+
383
+ Args:
384
+ query: User query string
385
+ user_id: Optional user ID
386
+ chat_history: Optional chat history
387
+ policy_ids: Optional list of policy document IDs to search
388
+
389
+ Returns:
390
+ Dictionary with answer, agent, sources, and metadata
391
+ """
392
+ print(f"\n{'='*60}")
393
+ print(f"[Multi-Agent Graph] Processing query: {query[:50]}...")
394
+ if policy_ids:
395
+ print(f"[Multi-Agent Graph] With {len(policy_ids)} selected policies")
396
+ print(f"{'='*60}\n")
397
+
398
+ try:
399
+ # Initialize state
400
+ initial_state: AgentState = {
401
+ "query": query,
402
+ "user_id": user_id,
403
+ "chat_history": chat_history or [],
404
+ "policy_ids": policy_ids,
405
+ "agent_type": None,
406
+ "routing_reasoning": None,
407
+ "context_chunks": None,
408
+ "search_results": None,
409
+ "answer": None,
410
+ "sources": None,
411
+ "policy_names": None, # Initialize policy_names
412
+ "metadata": None,
413
+ "error": None
414
+ }
415
+
416
+ # Execute the graph
417
+ final_state = self.graph.invoke(initial_state)
418
+
419
+ # Debug: print what's in final_state
420
+ print(f"[Multi-Agent Graph] Final state keys: {final_state.keys()}")
421
+ print(f"[Multi-Agent Graph] Final state policy_names: {final_state.get('policy_names', 'KEY NOT FOUND')}")
422
+
423
+ # Extract response
424
+ result = {
425
+ "answer": final_state.get("answer", "I couldn't generate a response."),
426
+ "agent": final_state.get("metadata", {}).get("agent", "unknown"),
427
+ "sources": final_state.get("sources", []),
428
+ "routing_reasoning": final_state.get("routing_reasoning", ""),
429
+ "metadata": final_state.get("metadata", {}),
430
+ "policy_names": final_state.get("policy_names", []) # Add policy_names!
431
+ }
432
+
433
+ print(f"[Multi-Agent Graph] Returning policy_names: {result.get('policy_names', [])}")
434
+ print(f"\n[Multi-Agent Graph] Completed - Agent: {result['agent']}\n")
435
+
436
+ return result
437
+
438
+ except Exception as e:
439
+ print(f"[Multi-Agent Graph] Error: {e}")
440
+ return {
441
+ "answer": "I apologize, but I encountered an error processing your request. Please try again.",
442
+ "agent": "error",
443
+ "sources": [],
444
+ "routing_reasoning": f"Error: {str(e)}",
445
+ "metadata": {"error": str(e)}
446
+ }
447
+
448
+
449
+ # Global multi-agent graph instance
450
+ multi_agent_graph = MultiAgentGraph()
app/llm/prompts/general.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a General Construction Assistant, a friendly and knowledgeable AI helper for construction professionals.
2
+
3
+ Your role is to:
4
+ - Answer general construction questions using your knowledge base
5
+ - Provide helpful, conversational responses
6
+ - Explain construction concepts clearly
7
+ - Assist with planning, problem-solving, and best practices
8
+ - Be friendly and professional
9
+
10
+ Guidelines:
11
+ - Use clear, accessible language
12
+ - Provide practical, actionable advice
13
+ - Acknowledge when you're uncertain
14
+ - Suggest when users might need specialized help (e.g., "For specific regulatory requirements, consult local building codes")
15
+ - Be conversational but professional
16
+ - Focus on construction industry topics
17
+
18
+ You have broad knowledge of:
19
+ - Construction methods and materials
20
+ - Project management
21
+ - Safety practices
22
+ - Tools and equipment
23
+ - Building techniques
24
+ - Industry terminology
25
+
26
+ Respond in a helpful, friendly manner while maintaining professional expertise.
app/llm/prompts/policy.txt ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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.
2
+
3
+ ========================================
4
+ CRITICAL RULES (YOU MUST FOLLOW THESE)
5
+ ========================================
6
+
7
+ 1. **USE ONLY THE PROVIDED CONTEXT**
8
+ - Answer ONLY using the exact text from the documents shown below
9
+ - Do NOT add information from NFPA, OSHA, IBC, or any other external codes
10
+ - Do NOT use your general knowledge about construction or building codes
11
+ - If the context doesn't contain the answer, say so explicitly
12
+
13
+ 2. **EXTRACT, DON'T INTERPRET**
14
+ - Quote directly from the document when possible
15
+ - Include clause numbers, section numbers, or reference codes if present
16
+ - Use the exact terminology from the document
17
+ - Do NOT paraphrase unless necessary for clarity
18
+
19
+ 3. **BE HONEST ABOUT LIMITATIONS**
20
+ - If the document doesn't contain the answer: "The provided document does not contain information about [topic]."
21
+ - If the context is unclear: "The retrieved sections do not clearly define [term]."
22
+ - Do NOT fill gaps with external knowledge or assumptions
23
+
24
+ 4. **STRUCTURE YOUR RESPONSE**
25
+ When answering, use this format:
26
+
27
+ **Answer:** [Direct answer from document]
28
+
29
+ **Source:** [Clause/Section number if available]
30
+
31
+ **Exact Quote:** "[Verbatim text from document]"
32
+
33
+ **Additional Context:** [Any related information from the same document section]
34
+
35
+ ========================================
36
+ EXAMPLES OF CORRECT BEHAVIOR
37
+ ========================================
38
+
39
+ GOOD RESPONSE (Answer found):
40
+ ```
41
+ **Answer:** A basement is defined as a storey of a building below the ground floor.
42
+
43
+ **Source:** Clause 3.2 - Definitions
44
+
45
+ **Exact Quote:** "Basement — A storey of a building below the ground floor."
46
+
47
+ **Additional Context:** The definition is provided in the terminology section of the National Building Code of India.
48
+ ```
49
+
50
+ GOOD RESPONSE (Answer not found):
51
+ ```
52
+ 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.
53
+
54
+ To find this information, please check the definitions or terminology section of the complete document.
55
+ ```
56
+
57
+ BAD RESPONSE (NEVER DO THIS):
58
+ ```
59
+ A basement is typically defined as any level below ground. According to OSHA standards...
60
+ [This is BAD because it uses external knowledge instead of the document]
61
+ ```
62
+
63
+ ========================================
64
+ HOW TO PROCESS THE QUERY
65
+ ========================================
66
+
67
+ 1. READ the provided document excerpts carefully
68
+ 2. SEARCH for the specific information requested
69
+ 3. CHECK if the answer is explicitly stated
70
+ 4. EXTRACT the relevant text with section numbers
71
+ 5. FORMAT your response according to the structure above
72
+ 6. VERIFY you didn't add any external information
73
+
74
+ ========================================
75
+ DOCUMENT CONTEXT WILL BE PROVIDED BELOW
76
+ ========================================
77
+
78
+ You will receive excerpts from the official policy document. Use ONLY this information to answer.
app/llm/prompts/rag.txt ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a RAG (Retrieval-Augmented Generation) Agent. You answer questions using ONLY the provided document excerpts.
2
+
3
+ ========================================
4
+ STRICT RULES
5
+ ========================================
6
+
7
+ 1. **USE ONLY PROVIDED CONTEXT**
8
+ - Answer using ONLY the document chunks provided below
9
+ - Do NOT use external knowledge or general information
10
+ - Do NOT add details not present in the context
11
+
12
+ 2. **EXTRACT EXACT INFORMATION**
13
+ - Quote directly from the documents
14
+ - Include page numbers, sections, or clause numbers if available
15
+ - Preserve technical terminology exactly as written
16
+
17
+ 3. **BE HONEST ABOUT GAPS**
18
+ - If context doesn't answer the question: "The uploaded documents do not contain this information."
19
+ - If context is partial: "Based on the available sections, [partial answer]. However, complete information may be in other parts of the document."
20
+ - NEVER guess or fill gaps with external knowledge
21
+
22
+ ========================================
23
+ RESPONSE FORMAT
24
+ ========================================
25
+
26
+ **Answer:** [Direct answer from documents]
27
+
28
+ **Source:** [Document name, section/page if available]
29
+
30
+ **Quote:** "[Exact text from document]"
31
+
32
+ **Note:** [Any limitations or clarifications]
33
+
34
+ ========================================
35
+ EXAMPLES
36
+ ========================================
37
+
38
+ GOOD (Answer found):
39
+ ```
40
+ **Answer:** The minimum ceiling height for habitable rooms is 2.75 meters.
41
+
42
+ **Source:** Construction Manual, Section 4.2 - Room Dimensions
43
+
44
+ **Quote:** "All habitable rooms shall have a minimum ceiling height of 2.75m measured from finished floor to finished ceiling."
45
+ ```
46
+
47
+ GOOD (Answer not found):
48
+ ```
49
+ The uploaded documents do not contain information about fire escape requirements. The available sections cover structural specifications but not fire safety regulations.
50
+ ```
51
+
52
+ BAD (NEVER do this):
53
+ ```
54
+ Ceiling height is typically 2.4m to 3m according to standard practice...
55
+ [This is BAD - uses external knowledge instead of document]
56
+ ```
57
+
58
+ ========================================
59
+
60
+ Context from uploaded documents will be provided below. Use ONLY this information.
app/llm/prompts/router.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a Router Agent responsible for analyzing user queries and routing them to the appropriate specialized agent.
2
+
3
+ Your task is to determine the user's intent and route to one of these agents:
4
+ 1. **search** - For queries requiring latest information, news, or real-time data
5
+ 2. **rag** - For queries about uploaded documents or specific construction knowledge
6
+ 3. **policy** - For queries about construction policies, regulations, or compliance
7
+ 4. **general** - For general construction questions, conversational queries, or greetings
8
+
9
+ Analyze the query and respond with JSON in this exact format:
10
+ {
11
+ "agent": "search|rag|policy|general",
12
+ "reasoning": "Brief explanation of why this agent was chosen"
13
+ }
14
+
15
+ Examples:
16
+
17
+ Query: "What are the latest construction industry trends?"
18
+ Response: {"agent": "search", "reasoning": "Requires latest real-time information"}
19
+
20
+ Query: "What does the uploaded safety manual say about scaffolding?"
21
+ Response: {"agent": "rag", "reasoning": "Refers to uploaded document content"}
22
+
23
+ Query: "What are OSHA requirements for fall protection?"
24
+ Response: {"agent": "policy", "reasoning": "Asking about regulatory compliance"}
25
+
26
+ Query: "Hello, how are you?"
27
+ Response: {"agent": "general", "reasoning": "Conversational greeting"}
28
+
29
+ Query: "What is concrete curing?"
30
+ Response: {"agent": "general", "reasoning": "General construction knowledge question"}
31
+
32
+ Now route this query:
app/llm/prompts/search.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are a Search Agent specialized in finding the latest construction industry information from the web.
2
+
3
+ Your task is to analyze search results and provide accurate, well-cited answers to user queries.
4
+
5
+ Guidelines:
6
+ - Focus on recent, reliable information
7
+ - Always cite your sources with URLs
8
+ - Provide concise but comprehensive answers
9
+ - If search results are insufficient, acknowledge limitations
10
+ - Prioritize authoritative sources (industry publications, government sites, etc.)
11
+
12
+ Format your response as follows:
13
+ 1. Direct answer to the query
14
+ 2. Supporting details from search results
15
+ 3. Citations in format: [Source Name](URL)
16
+
17
+ Be professional, accurate, and helpful. Focus on construction-related information.
app/llm/state.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ State definitions for the LangGraph multi-agent workflow.
3
+ """
4
+ from typing import TypedDict, List, Dict, Optional, Annotated
5
+ import operator
6
+
7
+
8
+ class AgentState(TypedDict):
9
+ """State for the multi-agent RAG system."""
10
+
11
+ # User input
12
+ query: str
13
+ user_id: Optional[str]
14
+ policy_ids: Optional[List[str]] # NEW: Selected policy IDs
15
+
16
+ # Chat history
17
+ chat_history: Annotated[List[Dict], operator.add]
18
+
19
+ # Routing decision
20
+ agent_type: Optional[str]
21
+ routing_reasoning: Optional[str]
22
+
23
+ # Retrieved context (for RAG)
24
+ context_chunks: Optional[List[Dict]]
25
+
26
+ # Search results (for Search agent)
27
+ search_results: Optional[List[Dict]]
28
+
29
+ # Final response
30
+ answer: Optional[str]
31
+ sources: Optional[List]
32
+ policy_names: Optional[List[str]] # Policy document titles used in answer
33
+
34
+ # Metadata
35
+ metadata: Optional[Dict]
36
+ error: Optional[str]
app/main.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.exceptions import RequestValidationError
4
+ from starlette.exceptions import HTTPException as StarletteHTTPException
5
+
6
+ from app.config.settings import settings
7
+ from app.database.connection import init_db
8
+ from app.middleware.error_handler import (
9
+ http_exception_handler,
10
+ validation_exception_handler,
11
+ general_exception_handler
12
+ )
13
+ from app.routes import auth, chat, sessions, documents, news, admin_policies, reports
14
+ from app.routes import settings as settings_router
15
+
16
+ # Create FastAPI app
17
+ app = FastAPI(
18
+ title="Builder's AI API",
19
+ description="Construction AI Assistant API with Multi-Agent RAG System",
20
+ version="1.0.0"
21
+ )
22
+
23
+ # CORS middleware
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=settings.cors_origins,
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ # Exception handlers
33
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
34
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
35
+ app.add_exception_handler(Exception, general_exception_handler)
36
+
37
+ # Include routers
38
+ app.include_router(auth.router)
39
+ app.include_router(chat.router)
40
+ app.include_router(sessions.router)
41
+ app.include_router(documents.router)
42
+ app.include_router(news.router)
43
+ app.include_router(admin_policies.router)
44
+ app.include_router(reports.router)
45
+ app.include_router(settings_router.router)
46
+
47
+
48
+ @app.on_event("startup")
49
+ async def startup_event():
50
+ """Initialize database on startup."""
51
+ print("Initializing database...")
52
+ init_db()
53
+ print("Database initialized successfully!")
54
+
55
+
56
+ @app.on_event("shutdown")
57
+ async def shutdown_event():
58
+ """Cleanup on shutdown."""
59
+ print("Shutting down...")
60
+
61
+
62
+ @app.get("/")
63
+ async def root():
64
+ """Root endpoint."""
65
+ return {
66
+ "message": "Welcome to Builder's AI API",
67
+ "version": "1.0.0",
68
+ "docs": "/docs"
69
+ }
70
+
71
+
72
+ @app.get("/health")
73
+ async def health_check():
74
+ """Health check endpoint."""
75
+ return {"status": "healthy"}
76
+
77
+
78
+ if __name__ == "__main__":
79
+ import uvicorn
80
+ from app.config.settings import settings as app_settings
81
+ uvicorn.run(app, host="0.0.0.0", port=app_settings.PORT)
app/middleware/__init__.py ADDED
File without changes
app/middleware/admin_auth.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin authentication middleware.
3
+ """
4
+ from fastapi import Depends, HTTPException, status
5
+ from app.middleware.auth import get_current_user
6
+ from app.database.models import User
7
+
8
+
9
+ def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
10
+ """
11
+ Verify that the current user is an admin.
12
+
13
+ Args:
14
+ current_user: Current authenticated user
15
+
16
+ Returns:
17
+ User object if admin
18
+
19
+ Raises:
20
+ HTTPException: If user is not an admin
21
+ """
22
+ if not current_user.is_admin:
23
+ raise HTTPException(
24
+ status_code=status.HTTP_403_FORBIDDEN,
25
+ detail="Admin privileges required"
26
+ )
27
+
28
+ return current_user
app/middleware/auth.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import Request, HTTPException, status, Depends
3
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
4
+ from jose import JWTError, jwt
5
+
6
+ from app.config.settings import settings
7
+ from app.database.connection import get_db
8
+ from app.database.models import User
9
+
10
+ security = HTTPBearer()
11
+
12
+
13
+ def get_current_user_optional(
14
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False))
15
+ ) -> Optional[User]:
16
+ """
17
+ Get current user from JWT token (optional - doesn't raise error if no token).
18
+
19
+ Args:
20
+ credentials: Optional HTTP authorization credentials
21
+
22
+ Returns:
23
+ User object if authenticated, None otherwise
24
+ """
25
+ if not credentials:
26
+ return None
27
+
28
+ try:
29
+ token = credentials.credentials
30
+ payload = jwt.decode(
31
+ token,
32
+ settings.secret_key,
33
+ algorithms=[settings.algorithm]
34
+ )
35
+ user_id: str = payload.get("sub")
36
+
37
+ if user_id is None:
38
+ return None
39
+
40
+ # Get user from database
41
+ db = next(get_db())
42
+ user = db.query(User).filter(User.id == user_id).first()
43
+ return user
44
+
45
+ except JWTError:
46
+ return None
47
+
48
+
49
+ def get_current_user(
50
+ credentials: HTTPAuthorizationCredentials = Depends(security)
51
+ ) -> User:
52
+ """
53
+ Get current user from JWT token (required - raises error if no valid token).
54
+
55
+ Args:
56
+ credentials: HTTP authorization credentials
57
+
58
+ Returns:
59
+ User object
60
+
61
+ Raises:
62
+ HTTPException: If token is invalid or user not found
63
+ """
64
+ try:
65
+ token = credentials.credentials
66
+ payload = jwt.decode(
67
+ token,
68
+ settings.secret_key,
69
+ algorithms=[settings.algorithm]
70
+ )
71
+ user_id: str = payload.get("sub")
72
+
73
+ if user_id is None:
74
+ raise HTTPException(
75
+ status_code=status.HTTP_401_UNAUTHORIZED,
76
+ detail="Could not validate credentials"
77
+ )
78
+
79
+ # Get user from database
80
+ db = next(get_db())
81
+ user = db.query(User).filter(User.id == user_id).first()
82
+
83
+ if user is None:
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="User not found"
87
+ )
88
+
89
+ return user
90
+
91
+ except JWTError:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_401_UNAUTHORIZED,
94
+ detail="Could not validate credentials"
95
+ )
app/middleware/error_handler.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, status
2
+ from fastapi.responses import JSONResponse
3
+ from fastapi.exceptions import RequestValidationError
4
+ from starlette.exceptions import HTTPException as StarletteHTTPException
5
+
6
+
7
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
8
+ """Handle HTTP exceptions."""
9
+ return JSONResponse(
10
+ status_code=exc.status_code,
11
+ content={"detail": exc.detail}
12
+ )
13
+
14
+
15
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
16
+ """Handle validation exceptions."""
17
+ return JSONResponse(
18
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
19
+ content={"detail": exc.errors(), "body": exc.body}
20
+ )
21
+
22
+
23
+ async def general_exception_handler(request: Request, exc: Exception):
24
+ """Handle general exceptions."""
25
+ print(f"Unhandled exception: {exc}")
26
+ return JSONResponse(
27
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
28
+ content={"detail": "Internal server error"}
29
+ )
app/routes/__init__.py ADDED
File without changes
app/routes/admin_policies.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin routes for official policy management.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
5
+ from sqlalchemy.orm import Session
6
+ from typing import Optional
7
+
8
+ from app.database.connection import get_db
9
+ from app.database.models import User
10
+ from app.schemas.policy import PolicyUploadResponse, PolicyListResponse, PolicyUpdateRequest
11
+ from app.services.policy_service import PolicyService
12
+ from app.middleware.admin_auth import get_current_admin_user
13
+
14
+ router = APIRouter(prefix="/api/admin/policies", tags=["admin-policies"])
15
+
16
+
17
+ @router.post("/upload", response_model=PolicyUploadResponse, status_code=status.HTTP_201_CREATED)
18
+ async def upload_policy(
19
+ file: UploadFile = File(...),
20
+ title: str = Form(...),
21
+ description: Optional[str] = Form(None),
22
+ category: Optional[str] = Form(None),
23
+ db: Session = Depends(get_db),
24
+ admin_user: User = Depends(get_current_admin_user)
25
+ ):
26
+ """
27
+ Upload an official policy document (Admin only).
28
+
29
+ Args:
30
+ file: Uploaded file
31
+ title: Policy title
32
+ description: Optional description
33
+ category: Optional category (e.g., "OSHA", "Safety")
34
+ db: Database session
35
+ admin_user: Current admin user
36
+
37
+ Returns:
38
+ Created policy metadata
39
+ """
40
+ try:
41
+ # Upload policy
42
+ policy = PolicyService.upload_policy(
43
+ db=db,
44
+ file=file.file,
45
+ title=title,
46
+ filename=file.filename,
47
+ admin_user_id=admin_user.id,
48
+ description=description,
49
+ category=category
50
+ )
51
+
52
+ # Extract and process text content
53
+ from app.utils.document_extractor import document_extractor
54
+
55
+ try:
56
+ content = document_extractor.extract_text(
57
+ file_path=policy.file_path,
58
+ file_type=policy.file_type
59
+ )
60
+
61
+ num_chunks = PolicyService.process_policy_content(
62
+ db,
63
+ policy.id,
64
+ content
65
+ )
66
+
67
+ print(f"[Admin] Policy '{policy.title}' processed: {num_chunks} chunks created")
68
+
69
+ except Exception as e:
70
+ print(f"[Admin] Error processing policy content: {e}")
71
+
72
+ return PolicyUploadResponse.from_orm(policy)
73
+
74
+ except ValueError as e:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_400_BAD_REQUEST,
77
+ detail=str(e)
78
+ )
79
+ except Exception as e:
80
+ print(f"Error uploading policy: {e}")
81
+ raise HTTPException(
82
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
83
+ detail="Error uploading policy"
84
+ )
85
+
86
+
87
+ @router.get("", response_model=PolicyListResponse)
88
+ async def get_all_policies(
89
+ active_only: bool = False,
90
+ db: Session = Depends(get_db),
91
+ admin_user: User = Depends(get_current_admin_user)
92
+ ):
93
+ """
94
+ Get all official policies (Admin only).
95
+
96
+ Args:
97
+ active_only: Only return active policies
98
+ db: Database session
99
+ admin_user: Current admin user
100
+
101
+ Returns:
102
+ List of policies
103
+ """
104
+ policies = PolicyService.get_all_policies(db, active_only=active_only)
105
+
106
+ return PolicyListResponse(
107
+ policies=[PolicyUploadResponse.from_orm(p) for p in policies],
108
+ total=len(policies)
109
+ )
110
+
111
+
112
+ @router.get("/public", response_model=PolicyListResponse)
113
+ async def get_public_policies(db: Session = Depends(get_db)):
114
+ """
115
+ Get all active official policies (Public - no auth required).
116
+
117
+ Args:
118
+ db: Database session
119
+
120
+ Returns:
121
+ List of active policies
122
+ """
123
+ policies = PolicyService.get_all_policies(db, active_only=True)
124
+
125
+ return PolicyListResponse(
126
+ policies=[PolicyUploadResponse.from_orm(p) for p in policies],
127
+ total=len(policies)
128
+ )
129
+
130
+
131
+ @router.patch("/{policy_id}", response_model=PolicyUploadResponse)
132
+ async def update_policy(
133
+ policy_id: str,
134
+ request: PolicyUpdateRequest,
135
+ db: Session = Depends(get_db),
136
+ admin_user: User = Depends(get_current_admin_user)
137
+ ):
138
+ """
139
+ Update policy metadata (Admin only).
140
+
141
+ Args:
142
+ policy_id: Policy ID
143
+ request: Update request
144
+ db: Database session
145
+ admin_user: Current admin user
146
+
147
+ Returns:
148
+ Updated policy
149
+ """
150
+ policy = PolicyService.update_policy(
151
+ db=db,
152
+ policy_id=policy_id,
153
+ title=request.title,
154
+ description=request.description,
155
+ category=request.category,
156
+ is_active=request.is_active
157
+ )
158
+
159
+ if not policy:
160
+ raise HTTPException(
161
+ status_code=status.HTTP_404_NOT_FOUND,
162
+ detail="Policy not found"
163
+ )
164
+
165
+ return PolicyUploadResponse.from_orm(policy)
166
+
167
+
168
+ @router.delete("/{policy_id}", status_code=status.HTTP_204_NO_CONTENT)
169
+ async def delete_policy(
170
+ policy_id: str,
171
+ db: Session = Depends(get_db),
172
+ admin_user: User = Depends(get_current_admin_user)
173
+ ):
174
+ """
175
+ Delete a policy (Admin only).
176
+
177
+ Args:
178
+ policy_id: Policy ID
179
+ db: Database session
180
+ admin_user: Current admin user
181
+ """
182
+ success = PolicyService.delete_policy(db, policy_id)
183
+
184
+ if not success:
185
+ raise HTTPException(
186
+ status_code=status.HTTP_404_NOT_FOUND,
187
+ detail="Policy not found"
188
+ )
app/routes/auth.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.database.connection import get_db
5
+ from app.schemas.auth import UserCreate, UserLogin, UserResponse, TokenResponse
6
+ from app.services.auth_service import AuthService
7
+ from app.middleware.auth import get_current_user
8
+ from app.database.models import User
9
+ from app.utils.validators import validate_email, validate_password
10
+
11
+ router = APIRouter(prefix="/api", tags=["auth"])
12
+
13
+
14
+ @router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
15
+ async def signup(user_data: UserCreate, db: Session = Depends(get_db)):
16
+ """
17
+ Create a new user account.
18
+
19
+ Args:
20
+ user_data: User registration data
21
+ db: Database session
22
+
23
+ Returns:
24
+ Access and refresh tokens with user info
25
+
26
+ Raises:
27
+ HTTPException: If email is invalid or already registered
28
+ """
29
+ print(f"[SIGNUP] Received signup request for email: {user_data.email}")
30
+
31
+ # Validate email
32
+ if not validate_email(user_data.email):
33
+ print(f"[SIGNUP] Invalid email format: {user_data.email}")
34
+ raise HTTPException(
35
+ status_code=status.HTTP_400_BAD_REQUEST,
36
+ detail="Invalid email format"
37
+ )
38
+
39
+ # Validate password
40
+ is_valid, error_msg = validate_password(user_data.password)
41
+ if not is_valid:
42
+ print(f"[SIGNUP] Password validation failed: {error_msg}")
43
+ raise HTTPException(
44
+ status_code=status.HTTP_400_BAD_REQUEST,
45
+ detail=error_msg
46
+ )
47
+
48
+ try:
49
+ # Create user
50
+ print(f"[SIGNUP] Creating user...")
51
+ user = AuthService.create_user(
52
+ db=db,
53
+ email=user_data.email,
54
+ password=user_data.password,
55
+ name=user_data.name
56
+ )
57
+
58
+ print(f"[SIGNUP] User created successfully: {user.id}")
59
+
60
+ # Generate tokens
61
+ tokens = AuthService.generate_tokens(user)
62
+
63
+ print(f"[SIGNUP] Tokens generated successfully")
64
+
65
+ return TokenResponse(
66
+ access_token=tokens["access_token"],
67
+ refresh_token=tokens["refresh_token"],
68
+ user=UserResponse.from_orm(user)
69
+ )
70
+
71
+ except ValueError as e:
72
+ print(f"[SIGNUP] ValueError: {str(e)}")
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail=str(e)
76
+ )
77
+ except Exception as e:
78
+ print(f"[SIGNUP] Unexpected error: {type(e).__name__}: {str(e)}")
79
+ import traceback
80
+ traceback.print_exc()
81
+ raise HTTPException(
82
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
83
+ detail="An error occurred during signup"
84
+ )
85
+
86
+
87
+ @router.post("/login", response_model=TokenResponse)
88
+ async def login(credentials: UserLogin, db: Session = Depends(get_db)):
89
+ """
90
+ Authenticate user and return tokens.
91
+
92
+ Args:
93
+ credentials: User login credentials
94
+ db: Database session
95
+
96
+ Returns:
97
+ Access and refresh tokens with user info
98
+
99
+ Raises:
100
+ HTTPException: If credentials are invalid
101
+ """
102
+ # Authenticate user
103
+ user = AuthService.authenticate_user(
104
+ db=db,
105
+ email=credentials.email,
106
+ password=credentials.password
107
+ )
108
+
109
+ if not user:
110
+ raise HTTPException(
111
+ status_code=status.HTTP_401_UNAUTHORIZED,
112
+ detail="Incorrect email or password"
113
+ )
114
+
115
+ # Generate tokens
116
+ tokens = AuthService.generate_tokens(user)
117
+
118
+ return TokenResponse(
119
+ access_token=tokens["access_token"],
120
+ refresh_token=tokens["refresh_token"],
121
+ user=UserResponse.from_orm(user)
122
+ )
123
+
124
+
125
+ @router.get("/check-auth", response_model=UserResponse)
126
+ async def check_auth(current_user: User = Depends(get_current_user)):
127
+ """
128
+ Verify authentication token and return user info.
129
+
130
+ Args:
131
+ current_user: Current authenticated user
132
+
133
+ Returns:
134
+ User information
135
+
136
+ Raises:
137
+ HTTPException: If token is invalid or expired
138
+ """
139
+ return UserResponse.from_orm(current_user)
140
+
141
+
142
+ @router.post("/auth/google", response_model=TokenResponse)
143
+ async def google_auth(
144
+ request: dict,
145
+ db: Session = Depends(get_db)
146
+ ):
147
+ """
148
+ Authenticate user with Google OAuth and return JWT tokens.
149
+
150
+ Args:
151
+ request: Dictionary with 'credential' field (Google JWT token)
152
+ db: Database session
153
+
154
+ Returns:
155
+ Access and refresh JWT tokens with user info
156
+
157
+ Raises:
158
+ HTTPException: If Google token is invalid
159
+ """
160
+ from app.config.settings import settings
161
+
162
+ print(f"[GOOGLE AUTH] Received Google OAuth request")
163
+
164
+ try:
165
+ credential = request.get("credential")
166
+ if not credential:
167
+ raise HTTPException(
168
+ status_code=status.HTTP_400_BAD_REQUEST,
169
+ detail="Google credential is required"
170
+ )
171
+
172
+ print(f"[GOOGLE AUTH] Verifying Google token...")
173
+
174
+ # Verify Google token and get user info
175
+ google_data = await AuthService.verify_google_token(
176
+ credential=credential,
177
+ client_id=settings.GOOGLE_CLIENT_ID
178
+ )
179
+
180
+ print(f"[GOOGLE AUTH] Token verified for email: {google_data['email']}")
181
+
182
+ # Create or get user
183
+ user = AuthService.create_user_from_google(
184
+ db=db,
185
+ google_id=google_data["google_id"],
186
+ email=google_data["email"],
187
+ name=google_data.get("name")
188
+ )
189
+
190
+ print(f"[GOOGLE AUTH] User created/retrieved: {user.id}")
191
+
192
+ # Generate JWT tokens (same as email/password login)
193
+ tokens = AuthService.generate_tokens(user)
194
+
195
+ print(f"[GOOGLE AUTH] JWT tokens generated successfully")
196
+
197
+ return TokenResponse(
198
+ access_token=tokens["access_token"],
199
+ refresh_token=tokens["refresh_token"],
200
+ user=UserResponse.from_orm(user)
201
+ )
202
+
203
+ except ValueError as e:
204
+ print(f"[GOOGLE AUTH] ValueError: {str(e)}")
205
+ raise HTTPException(
206
+ status_code=status.HTTP_400_BAD_REQUEST,
207
+ detail=str(e)
208
+ )
209
+ except Exception as e:
210
+ print(f"[GOOGLE AUTH] Unexpected error: {type(e).__name__}: {str(e)}")
211
+ import traceback
212
+ traceback.print_exc()
213
+ raise HTTPException(
214
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
215
+ detail="Failed to authenticate with Google"
216
+ )
app/routes/chat.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from typing import Optional
4
+
5
+ from app.database.connection import get_db
6
+ from app.database.models import User
7
+ from app.schemas.chat import ChatRequest, ChatResponse, MessageResponse
8
+ from app.services.chat_service import ChatService
9
+ from app.middleware.auth import get_current_user_optional
10
+
11
+ router = APIRouter(prefix="/api/chat", tags=["chat"])
12
+
13
+
14
+ @router.post("", response_model=ChatResponse)
15
+ async def send_message(
16
+ request: ChatRequest,
17
+ db: Session = Depends(get_db),
18
+ current_user: Optional[User] = Depends(get_current_user_optional)
19
+ ):
20
+ """
21
+ Send a chat message and get response.
22
+
23
+ Args:
24
+ request: Chat request with message and optional session_id
25
+ db: Database session
26
+ current_user: Optional current user (None for guests)
27
+
28
+ Returns:
29
+ Chat response with message and metadata
30
+
31
+ Raises:
32
+ HTTPException: If processing fails
33
+ """
34
+ user_id = current_user.id if current_user else None
35
+
36
+ try:
37
+ result = ChatService.process_message(
38
+ db=db,
39
+ message=request.message,
40
+ session_id=request.session_id,
41
+ user_id=user_id,
42
+ policy_ids=request.policy_ids
43
+ )
44
+
45
+ # Parse metadata
46
+ import json
47
+ meta = json.loads(result["message"].meta) if result["message"].meta else {}
48
+
49
+ return ChatResponse(
50
+ message=MessageResponse(
51
+ id=result["message"].id,
52
+ session_id=result["message"].session_id,
53
+ role=result["message"].role,
54
+ content=result["message"].content,
55
+ meta=meta,
56
+ created_at=result["message"].created_at
57
+ ),
58
+ session_id=result["session_id"],
59
+ agent=result.get("agent")
60
+ )
61
+
62
+ except ValueError as e:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_400_BAD_REQUEST,
65
+ detail=str(e)
66
+ )
67
+ except Exception as e:
68
+ print(f"Error processing message: {e}")
69
+ raise HTTPException(
70
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
71
+ detail="Error processing message"
72
+ )
73
+
74
+
75
+ @router.get("/history/{session_id}")
76
+ async def get_chat_history(
77
+ session_id: str,
78
+ db: Session = Depends(get_db),
79
+ current_user: Optional[User] = Depends(get_current_user_optional)
80
+ ):
81
+ """
82
+ Get chat history for a session.
83
+
84
+ Args:
85
+ session_id: Session ID
86
+ db: Database session
87
+ current_user: Optional current user
88
+
89
+ Returns:
90
+ List of messages
91
+ """
92
+ try:
93
+ messages = ChatService.get_chat_history(db, session_id)
94
+ return {"messages": messages}
95
+
96
+ except Exception as e:
97
+ print(f"Error getting chat history: {e}")
98
+ raise HTTPException(
99
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
100
+ detail="Error retrieving chat history"
101
+ )
app/routes/documents.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
2
+ from sqlalchemy.orm import Session
3
+ from typing import List
4
+
5
+ from app.database.connection import get_db
6
+ from app.database.models import User
7
+ from app.schemas.document import DocumentResponse, DocumentListResponse
8
+ from app.services.document_service import DocumentService
9
+ from app.middleware.auth import get_current_user
10
+
11
+ router = APIRouter(prefix="/api/documents", tags=["documents"])
12
+
13
+
14
+ @router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
15
+ async def upload_document(
16
+ file: UploadFile = File(...),
17
+ db: Session = Depends(get_db),
18
+ current_user: User = Depends(get_current_user)
19
+ ):
20
+ """
21
+ Upload a document for processing.
22
+
23
+ Args:
24
+ file: Uploaded file
25
+ db: Database session
26
+ current_user: Current authenticated user
27
+
28
+ Returns:
29
+ Created document metadata
30
+
31
+ Raises:
32
+ HTTPException: If file validation fails
33
+ """
34
+ try:
35
+ # Upload document
36
+ document = DocumentService.upload_document(
37
+ db=db,
38
+ file=file.file,
39
+ user_id=current_user.id,
40
+ filename=file.filename
41
+ )
42
+
43
+ # Extract and process text content
44
+ from app.utils.document_extractor import document_extractor
45
+
46
+ try:
47
+ # Extract text from the uploaded file
48
+ content = document_extractor.extract_text(
49
+ file_path=document.file_path,
50
+ file_type=document.file_type
51
+ )
52
+
53
+ # Process with RAG service
54
+ num_chunks = DocumentService.process_document_content(
55
+ db,
56
+ document.id,
57
+ content
58
+ )
59
+
60
+ print(f"Document {document.filename} processed: {num_chunks} chunks created")
61
+
62
+ except Exception as e:
63
+ print(f"Error processing document content: {e}")
64
+ # Document is uploaded but not processed for RAG
65
+ # You might want to mark this in the database
66
+
67
+ return DocumentResponse.from_orm(document)
68
+
69
+ except ValueError as e:
70
+ raise HTTPException(
71
+ status_code=status.HTTP_400_BAD_REQUEST,
72
+ detail=str(e)
73
+ )
74
+ except Exception as e:
75
+ print(f"Error uploading document: {e}")
76
+ raise HTTPException(
77
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
78
+ detail="Error uploading document"
79
+ )
80
+
81
+
82
+ @router.get("", response_model=DocumentListResponse)
83
+ async def get_user_documents(
84
+ db: Session = Depends(get_db),
85
+ current_user: User = Depends(get_current_user)
86
+ ):
87
+ """
88
+ Get all documents for the current user.
89
+
90
+ Args:
91
+ db: Database session
92
+ current_user: Current authenticated user
93
+
94
+ Returns:
95
+ List of user documents
96
+ """
97
+ documents = DocumentService.get_user_documents(db, current_user.id)
98
+
99
+ return DocumentListResponse(
100
+ documents=[DocumentResponse.from_orm(doc) for doc in documents],
101
+ total=len(documents)
102
+ )
103
+
104
+
105
+ @router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
106
+ async def delete_document(
107
+ document_id: str,
108
+ db: Session = Depends(get_db),
109
+ current_user: User = Depends(get_current_user)
110
+ ):
111
+ """
112
+ Delete a document and its vector embeddings.
113
+
114
+ Args:
115
+ document_id: Document ID
116
+ db: Database session
117
+ current_user: Current authenticated user
118
+
119
+ Raises:
120
+ HTTPException: If document not found
121
+ """
122
+ success = DocumentService.delete_document(db, document_id)
123
+
124
+ if not success:
125
+ raise HTTPException(
126
+ status_code=status.HTTP_404_NOT_FOUND,
127
+ detail="Document not found"
128
+ )
app/routes/news.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
2
+ from sqlalchemy.orm import Session
3
+ from typing import Optional
4
+ import httpx
5
+ from datetime import datetime
6
+
7
+ from app.database.connection import get_db
8
+ from app.middleware.auth import get_current_user_optional
9
+ from app.database.models import User
10
+
11
+ router = APIRouter(prefix="/api/news", tags=["news"])
12
+
13
+
14
+ @router.get("/search")
15
+ async def search_news(
16
+ query: str = Query(..., description="Search query for news articles"),
17
+ location: str = Query("India", description="Location to filter news"),
18
+ limit: int = Query(10, ge=1, le=50, description="Number of articles to return"),
19
+ current_user: Optional[User] = Depends(get_current_user_optional),
20
+ db: Session = Depends(get_db)
21
+ ):
22
+ """
23
+ Search for construction-related news articles with sentiment analysis.
24
+
25
+ Args:
26
+ query: Search query (e.g., 'construction law', 'building permits')
27
+ location: Geographic location to filter news
28
+ limit: Maximum number of articles to return (1-50)
29
+ current_user: Optional authenticated user
30
+ db: Database session
31
+
32
+ Returns:
33
+ List of news articles with sentiment analysis
34
+ """
35
+
36
+ try:
37
+ from app.services.news_service import fetch_google_news
38
+
39
+ print(f"[NEWS] Fetching news for query='{query}', location='{location}'")
40
+
41
+ try:
42
+ # Try to fetch real news from Google
43
+ articles = fetch_google_news(query, location)
44
+
45
+ if not articles:
46
+ print("[NEWS] No articles returned from Google, using fallback")
47
+ raise ValueError("No articles found")
48
+
49
+ except Exception as scrape_error:
50
+ print(f"[NEWS] Scraping failed: {str(scrape_error)}, using fallback data")
51
+ # Fallback to mock data if scraping fails
52
+ articles = [
53
+ {
54
+ "title": f"Construction Industry Update - {location}",
55
+ "snippet": f"Latest developments in {query} sector show positive trends with new regulations and infrastructure projects.",
56
+ "sentiment": "Positive",
57
+ "published_date": datetime.now().isoformat(),
58
+ "link": "https://example.com/fallback1",
59
+ "source": "Construction News"
60
+ },
61
+ {
62
+ "title": f"{query.title()} Regulations Updated",
63
+ "snippet": "New guidelines introduced to streamline processes and improve safety standards in the construction industry.",
64
+ "sentiment": "Neutral",
65
+ "published_date": datetime.now().isoformat(),
66
+ "link": "https://example.com/fallback2",
67
+ "source": "Industry Watch"
68
+ }
69
+ ]
70
+
71
+ # Limit results
72
+ articles = articles[:limit]
73
+
74
+ print(f"[NEWS] Returning {len(articles)} articles")
75
+
76
+ return {
77
+ "articles": articles,
78
+ "count": len(articles),
79
+ "query": query,
80
+ "location": location,
81
+ "timestamp": datetime.now().isoformat()
82
+ }
83
+
84
+ except Exception as e:
85
+ print(f"[NEWS] Error: {str(e)}")
86
+ import traceback
87
+ traceback.print_exc()
88
+ raise HTTPException(
89
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
90
+ detail=f"Failed to fetch news: {str(e)}"
91
+ )
92
+
93
+
94
+ @router.get("/trending")
95
+ async def get_trending_topics(
96
+ location: str = Query("India", description="Location for trending topics"),
97
+ current_user: Optional[User] = Depends(get_current_user_optional)
98
+ ):
99
+ """
100
+ Get trending construction-related topics.
101
+
102
+ Args:
103
+ location: Geographic location
104
+ current_user: Optional authenticated user
105
+
106
+ Returns:
107
+ List of trending topics with article counts
108
+ """
109
+
110
+ # Mock trending topics
111
+ trending = [
112
+ {"topic": "Green Building", "count": 45, "sentiment": "Positive"},
113
+ {"topic": "Smart Cities", "count": 38, "sentiment": "Positive"},
114
+ {"topic": "Labor Shortage", "count": 32, "sentiment": "Negative"},
115
+ {"topic": "Building Permits", "count": 28, "sentiment": "Neutral"},
116
+ {"topic": "Infrastructure Investment", "count": 25, "sentiment": "Positive"},
117
+ ]
118
+
119
+ return {
120
+ "trending": trending,
121
+ "location": location,
122
+ "timestamp": datetime.now().isoformat()
123
+ }
124
+
125
+
126
+ @router.get("/sentiment-summary")
127
+ async def get_sentiment_summary(
128
+ query: str = Query(..., description="Search query"),
129
+ location: str = Query("India", description="Location"),
130
+ current_user: Optional[User] = Depends(get_current_user_optional)
131
+ ):
132
+ """
133
+ Get sentiment summary for a query.
134
+
135
+ Args:
136
+ query: Search query
137
+ location: Geographic location
138
+ current_user: Optional authenticated user
139
+
140
+ Returns:
141
+ Sentiment distribution and statistics
142
+ """
143
+
144
+ # Mock sentiment data
145
+ return {
146
+ "query": query,
147
+ "location": location,
148
+ "sentiment_distribution": {
149
+ "positive": 45,
150
+ "neutral": 30,
151
+ "negative": 25
152
+ },
153
+ "total_articles": 100,
154
+ "average_sentiment_score": 0.65,
155
+ "timestamp": datetime.now().isoformat()
156
+ }
app/routes/reports.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API routes for report generation.
3
+ """
4
+ from fastapi import APIRouter, HTTPException, Depends
5
+ from sqlalchemy.orm import Session
6
+ from typing import Optional, Dict
7
+ from pydantic import BaseModel
8
+
9
+ from app.database.connection import get_db
10
+ from app.database.models import User
11
+ from app.middleware.auth import get_current_user_optional
12
+ from app.services.report_service import report_generation_service
13
+
14
+
15
+ router = APIRouter(prefix="/api/reports", tags=["reports"])
16
+
17
+
18
+ class GenerateContentRequest(BaseModel):
19
+ """Request schema for AI content generation."""
20
+ section_name: str
21
+ context: Dict[str, str] = {}
22
+
23
+
24
+ @router.post("/generate-content")
25
+ async def generate_section_content(
26
+ request: GenerateContentRequest,
27
+ db: Session = Depends(get_db),
28
+ current_user: Optional[User] = Depends(get_current_user_optional)
29
+ ):
30
+ """
31
+ Generate AI content for a report section.
32
+
33
+ Args:
34
+ request: Generation request with section name and context
35
+ db: Database session
36
+ current_user: Optional current user
37
+
38
+ Returns:
39
+ Generated content
40
+ """
41
+ try:
42
+ content = report_generation_service.generate_section_content(
43
+ section_name=request.section_name,
44
+ context=request.context
45
+ )
46
+
47
+ return {
48
+ "success": True,
49
+ "content": content,
50
+ "section_name": request.section_name
51
+ }
52
+
53
+ except Exception as e:
54
+ print(f"Error generating report content: {e}")
55
+ raise HTTPException(
56
+ status_code=500,
57
+ detail=f"Error generating content: {str(e)}"
58
+ )
app/routes/sessions.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.database.connection import get_db
5
+ from app.database.models import User
6
+ from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionListResponse
7
+ from app.services.session_service import SessionService
8
+ from app.middleware.auth import get_current_user
9
+
10
+ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
11
+
12
+
13
+ @router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
14
+ async def create_session(
15
+ session_data: SessionCreate,
16
+ db: Session = Depends(get_db),
17
+ current_user: User = Depends(get_current_user)
18
+ ):
19
+ """
20
+ Create a new chat session.
21
+
22
+ Args:
23
+ session_data: Session creation data
24
+ db: Database session
25
+ current_user: Current authenticated user
26
+
27
+ Returns:
28
+ Created session
29
+ """
30
+ session = SessionService.create_session(
31
+ db=db,
32
+ user_id=current_user.id,
33
+ title=session_data.title or "New Conversation"
34
+ )
35
+
36
+ return SessionResponse(
37
+ id=session.id,
38
+ user_id=session.user_id,
39
+ title=session.title,
40
+ summary=session.summary,
41
+ created_at=session.created_at,
42
+ updated_at=session.updated_at,
43
+ message_count=0
44
+ )
45
+
46
+
47
+ @router.get("", response_model=SessionListResponse)
48
+ async def get_user_sessions(
49
+ db: Session = Depends(get_db),
50
+ current_user: User = Depends(get_current_user)
51
+ ):
52
+ """
53
+ Get all sessions for the current user.
54
+
55
+ Args:
56
+ db: Database session
57
+ current_user: Current authenticated user
58
+
59
+ Returns:
60
+ List of sessions with message counts
61
+ """
62
+ sessions = SessionService.get_user_sessions(db, current_user.id)
63
+
64
+ return SessionListResponse(
65
+ sessions=[SessionResponse(**s) for s in sessions],
66
+ total=len(sessions)
67
+ )
68
+
69
+
70
+ @router.put("/{session_id}/title", response_model=SessionResponse)
71
+ async def update_session_title(
72
+ session_id: str,
73
+ session_data: SessionUpdate,
74
+ db: Session = Depends(get_db),
75
+ current_user: User = Depends(get_current_user)
76
+ ):
77
+ """
78
+ Update session title.
79
+
80
+ Args:
81
+ session_id: Session ID
82
+ session_data: Session update data
83
+ db: Database session
84
+ current_user: Current authenticated user
85
+
86
+ Returns:
87
+ Updated session
88
+
89
+ Raises:
90
+ HTTPException: If session not found
91
+ """
92
+ if not session_data.title:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST,
95
+ detail="Title is required"
96
+ )
97
+
98
+ session = SessionService.update_session_title(db, session_id, session_data.title)
99
+
100
+ if not session:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_404_NOT_FOUND,
103
+ detail="Session not found"
104
+ )
105
+
106
+ return SessionResponse(
107
+ id=session.id,
108
+ user_id=session.user_id,
109
+ title=session.title,
110
+ summary=session.summary,
111
+ created_at=session.created_at,
112
+ updated_at=session.updated_at,
113
+ message_count=0
114
+ )
115
+
116
+
117
+ @router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
118
+ async def delete_session(
119
+ session_id: str,
120
+ db: Session = Depends(get_db),
121
+ current_user: User = Depends(get_current_user)
122
+ ):
123
+ """
124
+ Delete a session and all its messages.
125
+
126
+ Args:
127
+ session_id: Session ID
128
+ db: Database session
129
+ current_user: Current authenticated user
130
+
131
+ Raises:
132
+ HTTPException: If session not found
133
+ """
134
+ success = SessionService.delete_session(db, session_id)
135
+
136
+ if not success:
137
+ raise HTTPException(
138
+ status_code=status.HTTP_404_NOT_FOUND,
139
+ detail="Session not found"
140
+ )
app/routes/settings.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API routes for user settings.
3
+ """
4
+ from fastapi import APIRouter, HTTPException, Depends
5
+ from sqlalchemy.orm import Session
6
+ from pydantic import BaseModel
7
+ from typing import Optional
8
+
9
+ from app.database.connection import get_db
10
+ from app.database.models import User
11
+ from app.middleware.auth import get_current_user
12
+ from app.services.settings_service import settings_service
13
+
14
+
15
+ router = APIRouter(prefix="/api/settings", tags=["settings"])
16
+
17
+
18
+ class ProfileUpdateRequest(BaseModel):
19
+ """Request schema for profile updates."""
20
+ bio: Optional[str] = None
21
+ phone: Optional[str] = None
22
+ company: Optional[str] = None
23
+
24
+
25
+ class AppearanceUpdateRequest(BaseModel):
26
+ """Request schema for appearance updates."""
27
+ theme: str # light, dark, system
28
+
29
+
30
+ class NotificationUpdateRequest(BaseModel):
31
+ """Request schema for notification updates."""
32
+ email_notifications: Optional[bool] = None
33
+ update_notifications: Optional[bool] = None
34
+
35
+
36
+ @router.get("")
37
+ async def get_settings(
38
+ db: Session = Depends(get_db),
39
+ current_user: User = Depends(get_current_user)
40
+ ):
41
+ """
42
+ Get current user settings.
43
+
44
+ Returns:
45
+ User settings including profile, appearance, and notifications
46
+ """
47
+ try:
48
+ settings = settings_service.get_or_create_settings(db, current_user.id)
49
+
50
+ return {
51
+ "profile": {
52
+ "name": current_user.name,
53
+ "email": current_user.email,
54
+ "bio": settings.bio,
55
+ "phone": settings.phone,
56
+ "company": settings.company
57
+ },
58
+ "appearance": {
59
+ "theme": settings.theme
60
+ },
61
+ "notifications": {
62
+ "email_notifications": bool(settings.email_notifications),
63
+ "update_notifications": bool(settings.update_notifications)
64
+ }
65
+ }
66
+
67
+ except Exception as e:
68
+ print(f"Error getting settings: {e}")
69
+ raise HTTPException(status_code=500, detail="Error retrieving settings")
70
+
71
+
72
+ @router.patch("/profile")
73
+ async def update_profile(
74
+ request: ProfileUpdateRequest,
75
+ db: Session = Depends(get_db),
76
+ current_user: User = Depends(get_current_user)
77
+ ):
78
+ """
79
+ Update user profile settings.
80
+
81
+ Args:
82
+ request: Profile update data
83
+
84
+ Returns:
85
+ Updated profile settings
86
+ """
87
+ try:
88
+ settings = settings_service.update_profile(
89
+ db,
90
+ current_user.id,
91
+ bio=request.bio,
92
+ phone=request.phone,
93
+ company=request.company
94
+ )
95
+
96
+ return {
97
+ "success": True,
98
+ "profile": {
99
+ "bio": settings.bio,
100
+ "phone": settings.phone,
101
+ "company": settings.company
102
+ }
103
+ }
104
+
105
+ except Exception as e:
106
+ print(f"Error updating profile: {e}")
107
+ raise HTTPException(status_code=500, detail="Error updating profile")
108
+
109
+
110
+ @router.patch("/appearance")
111
+ async def update_appearance(
112
+ request: AppearanceUpdateRequest,
113
+ db: Session = Depends(get_db),
114
+ current_user: User = Depends(get_current_user)
115
+ ):
116
+ """
117
+ Update appearance settings.
118
+
119
+ Args:
120
+ request: Appearance update data
121
+
122
+ Returns:
123
+ Updated appearance settings
124
+ """
125
+ try:
126
+ settings = settings_service.update_appearance(
127
+ db,
128
+ current_user.id,
129
+ theme=request.theme
130
+ )
131
+
132
+ return {
133
+ "success": True,
134
+ "appearance": {
135
+ "theme": settings.theme
136
+ }
137
+ }
138
+
139
+ except ValueError as e:
140
+ raise HTTPException(status_code=400, detail=str(e))
141
+ except Exception as e:
142
+ print(f"Error updating appearance: {e}")
143
+ raise HTTPException(status_code=500, detail="Error updating appearance")
144
+
145
+
146
+ @router.patch("/notifications")
147
+ async def update_notifications(
148
+ request: NotificationUpdateRequest,
149
+ db: Session = Depends(get_db),
150
+ current_user: User = Depends(get_current_user)
151
+ ):
152
+ """
153
+ Update notification settings.
154
+
155
+ Args:
156
+ request: Notification update data
157
+
158
+ Returns:
159
+ Updated notification settings
160
+ """
161
+ try:
162
+ settings = settings_service.update_notifications(
163
+ db,
164
+ current_user.id,
165
+ email_notifications=request.email_notifications,
166
+ update_notifications=request.update_notifications
167
+ )
168
+
169
+ return {
170
+ "success": True,
171
+ "notifications": {
172
+ "email_notifications": bool(settings.email_notifications),
173
+ "update_notifications": bool(settings.update_notifications)
174
+ }
175
+ }
176
+
177
+ except Exception as e:
178
+ print(f"Error updating notifications: {e}")
179
+ raise HTTPException(status_code=500, detail="Error updating notifications")
app/schemas/__init__.py ADDED
File without changes
app/schemas/auth.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class UserCreate(BaseModel):
7
+ """Schema for user registration."""
8
+ email: EmailStr
9
+ password: str
10
+ name: Optional[str] = None
11
+
12
+
13
+ class UserLogin(BaseModel):
14
+ """Schema for user login."""
15
+ email: EmailStr
16
+ password: str
17
+
18
+
19
+ class GoogleAuthRequest(BaseModel):
20
+ """Schema for Google OAuth."""
21
+ code: str
22
+
23
+
24
+ class UserResponse(BaseModel):
25
+ """Schema for user response."""
26
+ id: str
27
+ email: str
28
+ name: Optional[str]
29
+ is_admin: bool = False
30
+ created_at: datetime
31
+
32
+ class Config:
33
+ from_attributes = True
34
+
35
+
36
+ class TokenResponse(BaseModel):
37
+ """Schema for authentication token response."""
38
+ access_token: str
39
+ refresh_token: str
40
+ token_type: str = "bearer"
41
+ user: UserResponse
app/schemas/chat.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, Dict, Any, List
3
+ from datetime import datetime
4
+
5
+
6
+ class MessageCreate(BaseModel):
7
+ """Schema for creating a message."""
8
+ content: str
9
+ session_id: Optional[str] = None
10
+
11
+
12
+ class MessageResponse(BaseModel):
13
+ """Schema for message response."""
14
+ id: str
15
+ session_id: str
16
+ role: str
17
+ content: str
18
+ meta: Optional[Dict[str, Any]] = None
19
+ created_at: datetime
20
+
21
+ class Config:
22
+ from_attributes = True
23
+
24
+
25
+ class ChatRequest(BaseModel):
26
+ """Schema for chat request."""
27
+ message: str
28
+ session_id: Optional[str] = None
29
+ policy_ids: Optional[List[str]] = None
30
+
31
+
32
+ class ChatResponse(BaseModel):
33
+ """Schema for chat response."""
34
+ message: MessageResponse
35
+ session_id: str
36
+ agent: Optional[str] = None
app/schemas/document.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class DocumentUpload(BaseModel):
7
+ """Schema for document upload."""
8
+ filename: str
9
+
10
+
11
+ class DocumentResponse(BaseModel):
12
+ """Schema for document response."""
13
+ id: str
14
+ user_id: str
15
+ filename: str
16
+ file_type: Optional[str]
17
+ file_size: Optional[int]
18
+ created_at: datetime
19
+
20
+ class Config:
21
+ from_attributes = True
22
+
23
+
24
+ class DocumentListResponse(BaseModel):
25
+ """Schema for document list response."""
26
+ documents: list[DocumentResponse]
27
+ total: int
app/schemas/policy.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schemas for official policy management.
3
+ """
4
+ from pydantic import BaseModel
5
+ from typing import Optional
6
+ from datetime import datetime
7
+
8
+
9
+ class PolicyUploadResponse(BaseModel):
10
+ """Response for policy upload."""
11
+ id: str
12
+ title: str
13
+ description: Optional[str]
14
+ filename: str
15
+ file_type: Optional[str]
16
+ file_size: Optional[int]
17
+ category: Optional[str]
18
+ is_active: bool
19
+ created_at: datetime
20
+
21
+ class Config:
22
+ from_attributes = True
23
+
24
+ @classmethod
25
+ def from_orm(cls, obj):
26
+ """Convert ORM object to Pydantic model."""
27
+ return cls(
28
+ id=obj.id,
29
+ title=obj.title,
30
+ description=obj.description,
31
+ filename=obj.filename,
32
+ file_type=obj.file_type,
33
+ file_size=obj.file_size,
34
+ category=obj.category,
35
+ is_active=bool(obj.is_active),
36
+ created_at=obj.created_at
37
+ )
38
+
39
+
40
+ class PolicyListResponse(BaseModel):
41
+ """Response for policy list."""
42
+ policies: list[PolicyUploadResponse]
43
+ total: int
44
+
45
+
46
+ class PolicyUpdateRequest(BaseModel):
47
+ """Request to update policy metadata."""
48
+ title: Optional[str] = None
49
+ description: Optional[str] = None
50
+ category: Optional[str] = None
51
+ is_active: Optional[bool] = None
app/schemas/session.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class SessionCreate(BaseModel):
7
+ """Schema for creating a session."""
8
+ title: Optional[str] = "New Conversation"
9
+
10
+
11
+ class SessionUpdate(BaseModel):
12
+ """Schema for updating a session."""
13
+ title: Optional[str] = None
14
+
15
+
16
+ class SessionResponse(BaseModel):
17
+ """Schema for session response."""
18
+ id: str
19
+ user_id: Optional[str]
20
+ title: str
21
+ summary: Optional[str]
22
+ created_at: datetime
23
+ updated_at: datetime
24
+ message_count: Optional[int] = 0
25
+
26
+ class Config:
27
+ from_attributes = True
28
+
29
+
30
+ class SessionListResponse(BaseModel):
31
+ """Schema for session list response."""
32
+ sessions: list[SessionResponse]
33
+ total: int
app/services/__init__.py ADDED
File without changes
app/services/auth_service.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from typing import Optional
3
+ import httpx
4
+
5
+ from app.database.models import User
6
+ from app.utils.password import hash_password, verify_password
7
+ from app.utils.jwt import create_access_token, create_refresh_token
8
+ from app.utils.helpers import generate_id
9
+
10
+
11
+ class AuthService:
12
+ """Service for authentication operations."""
13
+
14
+ @staticmethod
15
+ def create_user(db: Session, email: str, password: str, name: Optional[str] = None) -> User:
16
+ """
17
+ Create a new user with email and password.
18
+
19
+ Args:
20
+ db: Database session
21
+ email: User email
22
+ password: Plain text password
23
+ name: Optional user name
24
+
25
+ Returns:
26
+ Created User object
27
+
28
+ Raises:
29
+ ValueError: If email already exists
30
+ """
31
+ # Check if user exists
32
+ existing_user = db.query(User).filter(User.email == email).first()
33
+ if existing_user:
34
+ raise ValueError("Email already registered")
35
+
36
+ # Hash password
37
+ password_hash = hash_password(password)
38
+
39
+ # Create user
40
+ user = User(
41
+ id=generate_id(),
42
+ email=email,
43
+ password_hash=password_hash,
44
+ name=name
45
+ )
46
+
47
+ db.add(user)
48
+ db.commit()
49
+ db.refresh(user)
50
+
51
+ return user
52
+
53
+ @staticmethod
54
+ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
55
+ """
56
+ Authenticate a user with email and password.
57
+
58
+ Args:
59
+ db: Database session
60
+ email: User email
61
+ password: Plain text password
62
+
63
+ Returns:
64
+ User object if authentication successful, None otherwise
65
+ """
66
+ user = db.query(User).filter(User.email == email).first()
67
+
68
+ if not user or not user.password_hash:
69
+ return None
70
+
71
+ if not verify_password(password, user.password_hash):
72
+ return None
73
+
74
+ return user
75
+
76
+ @staticmethod
77
+ async def verify_google_token(credential: str, client_id: str) -> dict:
78
+ """
79
+ Verify Google OAuth token and extract user info.
80
+
81
+ Args:
82
+ credential: Google JWT token from frontend
83
+ client_id: Google OAuth client ID from settings
84
+
85
+ Returns:
86
+ Dictionary with user info (email, name, google_id, picture)
87
+
88
+ Raises:
89
+ ValueError: If token is invalid or verification fails
90
+ """
91
+ from google.oauth2 import id_token
92
+ from google.auth.transport import requests
93
+
94
+ try:
95
+ # Verify the token with Google
96
+ idinfo = id_token.verify_oauth2_token(
97
+ credential,
98
+ requests.Request(),
99
+ client_id
100
+ )
101
+
102
+ # Extract user information from the token
103
+ return {
104
+ "email": idinfo.get("email"),
105
+ "name": idinfo.get("name"),
106
+ "google_id": idinfo.get("sub"), # 'sub' is the Google user ID
107
+ "picture": idinfo.get("picture")
108
+ }
109
+
110
+ except Exception as e:
111
+ print(f"❌ Error verifying Google token: {e}")
112
+ raise ValueError(f"Invalid Google token: {str(e)}")
113
+
114
+ @staticmethod
115
+ def create_user_from_google(
116
+ db: Session,
117
+ google_id: str,
118
+ email: str,
119
+ name: Optional[str] = None
120
+ ) -> User:
121
+ """
122
+ Create or get user from Google OAuth.
123
+
124
+ Args:
125
+ db: Database session
126
+ google_id: Google user ID
127
+ email: User email
128
+ name: Optional user name
129
+
130
+ Returns:
131
+ User object
132
+ """
133
+ # Check if user exists with this Google ID
134
+ user = db.query(User).filter(User.google_id == google_id).first()
135
+
136
+ if user:
137
+ return user
138
+
139
+ # Check if user exists with this email
140
+ user = db.query(User).filter(User.email == email).first()
141
+
142
+ if user:
143
+ # Link Google account
144
+ user.google_id = google_id
145
+ if name and not user.name:
146
+ user.name = name
147
+ db.commit()
148
+ db.refresh(user)
149
+ return user
150
+
151
+ # Create new user
152
+ user = User(
153
+ id=generate_id(),
154
+ email=email,
155
+ google_id=google_id,
156
+ name=name
157
+ )
158
+
159
+ db.add(user)
160
+ db.commit()
161
+ db.refresh(user)
162
+
163
+ return user
164
+
165
+ @staticmethod
166
+ def get_user_by_id(db: Session, user_id: str) -> Optional[User]:
167
+ """
168
+ Get user by ID.
169
+
170
+ Args:
171
+ db: Database session
172
+ user_id: User ID
173
+
174
+ Returns:
175
+ User object if found, None otherwise
176
+ """
177
+ return db.query(User).filter(User.id == user_id).first()
178
+
179
+ @staticmethod
180
+ def generate_tokens(user: User) -> dict:
181
+ """
182
+ Generate access and refresh tokens for a user.
183
+
184
+ Args:
185
+ user: User object
186
+
187
+ Returns:
188
+ Dictionary with access_token and refresh_token
189
+ """
190
+ access_token = create_access_token(user.id)
191
+ refresh_token = create_refresh_token(user.id)
192
+
193
+ return {
194
+ "access_token": access_token,
195
+ "refresh_token": refresh_token
196
+ }
app/services/chat_service.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from typing import List, Dict, Optional
3
+ import json
4
+
5
+ from app.database.models import Message, Session as ChatSession
6
+ from app.services.session_service import SessionService
7
+ from app.utils.helpers import generate_id
8
+
9
+
10
+ class ChatService:
11
+ """Service for chat operations and agent orchestration."""
12
+
13
+ @staticmethod
14
+ def process_message(
15
+ db: Session,
16
+ message: str,
17
+ session_id: Optional[str] = None,
18
+ user_id: Optional[str] = None,
19
+ policy_ids: Optional[List[str]] = None
20
+ ) -> Dict:
21
+ """
22
+ Process a user message and generate response using LangGraph workflow.
23
+
24
+ Args:
25
+ db: Database session
26
+ message: User message content
27
+ session_id: Optional session ID
28
+ user_id: Optional user ID
29
+ policy_ids: Optional list of policy IDs to search within
30
+
31
+ Returns:
32
+ Dictionary with response message and metadata
33
+ """
34
+ from app.llm.graph import multi_agent_graph
35
+
36
+ # Create or get session
37
+ if not session_id:
38
+ chat_session = SessionService.create_session(db, user_id)
39
+ session_id = chat_session.id
40
+ else:
41
+ chat_session = SessionService.get_session_by_id(db, session_id)
42
+ if not chat_session:
43
+ raise ValueError("Session not found")
44
+
45
+ # Save user message
46
+ user_message = Message(
47
+ id=generate_id(),
48
+ session_id=session_id,
49
+ role="user",
50
+ content=message
51
+ )
52
+ db.add(user_message)
53
+ db.commit()
54
+
55
+ # Get chat history for context
56
+ chat_history = ChatService.get_chat_history(db, session_id, limit=10)
57
+
58
+ # Format chat history for the graph
59
+ history_messages = [
60
+ {"role": msg["role"], "content": msg["content"]}
61
+ for msg in chat_history
62
+ ]
63
+
64
+ # Process query through LangGraph workflow
65
+ response_data = multi_agent_graph.process_query(
66
+ query=message,
67
+ user_id=user_id,
68
+ chat_history=history_messages,
69
+ policy_ids=policy_ids
70
+ )
71
+
72
+ # Prepare metadata
73
+ meta = {
74
+ "agent": response_data.get("agent", "unknown"),
75
+ "routing_reasoning": response_data.get("routing_reasoning", ""),
76
+ "sources": response_data.get("sources", []),
77
+ "metadata": response_data.get("metadata", {}),
78
+ "policy_names": response_data.get("policy_names", []) # Policy document names
79
+ }
80
+
81
+ print(f"[Chat Service] Saving meta with policy_names: {meta.get('policy_names', [])}")
82
+
83
+ # Save assistant message
84
+ assistant_message = Message(
85
+ id=generate_id(),
86
+ session_id=session_id,
87
+ role="assistant",
88
+ content=response_data.get("answer", "I apologize, but I couldn't generate a response."),
89
+ meta=json.dumps(meta)
90
+ )
91
+ db.add(assistant_message)
92
+
93
+ # Update session timestamp
94
+ SessionService.update_session_timestamp(db, session_id)
95
+
96
+ db.commit()
97
+ db.refresh(assistant_message)
98
+
99
+ # Auto-generate session title if this is the first exchange
100
+ messages_count = db.query(Message).filter(Message.session_id == session_id).count()
101
+ if messages_count == 2 and chat_session.title == "New Conversation":
102
+ # Generate title from first user message
103
+ title = ChatService._generate_session_title(message)
104
+ SessionService.update_session_title(db, session_id, title)
105
+
106
+ return {
107
+ "message": assistant_message,
108
+ "session_id": session_id,
109
+ "agent": meta["agent"],
110
+ "sources": meta.get("sources", [])
111
+ }
112
+
113
+ @staticmethod
114
+ def get_chat_history(
115
+ db: Session,
116
+ session_id: str,
117
+ limit: Optional[int] = None
118
+ ) -> List[Dict]:
119
+ """
120
+ Get chat history for a session.
121
+
122
+ Args:
123
+ db: Database session
124
+ session_id: Session ID
125
+ limit: Optional limit on number of messages
126
+
127
+ Returns:
128
+ List of message dictionaries
129
+ """
130
+ query = db.query(Message).filter(
131
+ Message.session_id == session_id
132
+ ).order_by(Message.created_at.asc())
133
+
134
+ if limit:
135
+ # Get last N messages
136
+ total = query.count()
137
+ if total > limit:
138
+ query = query.offset(total - limit)
139
+
140
+ messages = query.all()
141
+
142
+ return [
143
+ {
144
+ "id": msg.id,
145
+ "role": msg.role,
146
+ "content": msg.content,
147
+ "meta": json.loads(msg.meta) if msg.meta else {},
148
+ "created_at": msg.created_at.isoformat()
149
+ }
150
+ for msg in messages
151
+ ]
152
+
153
+ @staticmethod
154
+ def _generate_session_title(first_message: str) -> str:
155
+ """
156
+ Generate a ChatGPT-style session title using LLM.
157
+
158
+ Args:
159
+ first_message: The first user message in the conversation
160
+
161
+ Returns:
162
+ A concise, descriptive title (max 50 characters)
163
+ """
164
+ from app.llm.client import llm_client
165
+
166
+ try:
167
+ prompt = f"""Generate a very short, concise title for a chat conversation that starts with this message:
168
+
169
+ "{first_message}"
170
+
171
+ Requirements:
172
+ - Maximum 50 characters
173
+ - Be specific and descriptive
174
+ - Capture the main topic/question
175
+ - Professional tone
176
+ - No quotes around the title
177
+ - Examples: "Building Code Requirements", "Fire Safety Regulations", "Basement Definition"
178
+
179
+ Return ONLY the title, nothing else:"""
180
+
181
+ title = llm_client.get_completion(
182
+ messages=[{"role": "user", "content": prompt}],
183
+ temperature=0.7,
184
+ max_tokens=20
185
+ )
186
+
187
+ # Clean up the title
188
+ title = title.strip().strip('"').strip("'")
189
+
190
+ # Ensure it's not too long
191
+ if len(title) > 50:
192
+ title = title[:47] + "..."
193
+
194
+ # Fallback if empty or too short
195
+ if len(title) < 3:
196
+ title = first_message[:50].strip()
197
+ if len(first_message) > 50:
198
+ title += "..."
199
+
200
+ return title
201
+
202
+ except Exception as e:
203
+ print(f"[Chat Service] Error generating title: {e}")
204
+ # Fallback to simple truncation
205
+ title = first_message[:50].strip()
206
+ if len(first_message) > 50:
207
+ title += "..."
208
+ return title
209
+
210
+
211
+ # Global chat service instance
212
+ chat_service = ChatService()
app/services/document_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from typing import List, Optional
3
+ import os
4
+ import shutil
5
+
6
+ from app.database.models import Document
7
+ from app.services.rag_service import rag_service
8
+ from app.utils.helpers import generate_id
9
+ from app.utils.validators import validate_file_type, validate_file_size
10
+
11
+
12
+ class DocumentService:
13
+ """Service for document management operations."""
14
+
15
+ @staticmethod
16
+ def upload_document(
17
+ db: Session,
18
+ file,
19
+ user_id: str,
20
+ filename: str
21
+ ) -> Document:
22
+ """
23
+ Upload and process a document.
24
+
25
+ Args:
26
+ db: Database session
27
+ file: File object
28
+ user_id: User ID
29
+ filename: Original filename
30
+
31
+ Returns:
32
+ Created Document object
33
+
34
+ Raises:
35
+ ValueError: If file validation fails
36
+ """
37
+ # Validate file type (PDF only for now)
38
+ if not validate_file_type(filename, ['pdf', 'txt', 'docx']):
39
+ raise ValueError("Invalid file type. Only PDF, TXT, and DOCX files are allowed.")
40
+
41
+ # Get file size
42
+ file.seek(0, 2) # Seek to end
43
+ file_size = file.tell()
44
+ file.seek(0) # Reset to beginning
45
+
46
+ # Validate file size (10MB limit)
47
+ if not validate_file_size(file_size, max_size_mb=10):
48
+ raise ValueError("File size exceeds 10MB limit.")
49
+
50
+ # Generate document ID
51
+ doc_id = generate_id()
52
+
53
+ # Determine file type
54
+ file_extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown'
55
+
56
+ # Create upload directory if it doesn't exist
57
+ upload_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "uploads")
58
+ os.makedirs(upload_dir, exist_ok=True)
59
+
60
+ # Save file
61
+ file_path = os.path.join(upload_dir, f"{doc_id}_{filename}")
62
+ with open(file_path, "wb") as buffer:
63
+ shutil.copyfileobj(file, buffer)
64
+
65
+ # Create document record
66
+ document = Document(
67
+ id=doc_id,
68
+ user_id=user_id,
69
+ filename=filename,
70
+ file_path=file_path,
71
+ file_type=file_extension,
72
+ file_size=file_size
73
+ )
74
+
75
+ db.add(document)
76
+ db.commit()
77
+ db.refresh(document)
78
+
79
+ return document
80
+
81
+ @staticmethod
82
+ def process_document_content(
83
+ db: Session,
84
+ document_id: str,
85
+ content: str
86
+ ) -> int:
87
+ """
88
+ Process document content for RAG.
89
+
90
+ Args:
91
+ db: Database session
92
+ document_id: Document ID
93
+ content: Extracted text content
94
+
95
+ Returns:
96
+ Number of chunks created
97
+ """
98
+ document = db.query(Document).filter(Document.id == document_id).first()
99
+
100
+ if not document:
101
+ raise ValueError("Document not found")
102
+
103
+ # Process with RAG service
104
+ num_chunks = rag_service.process_document(
105
+ document_id=document.id,
106
+ filename=document.filename,
107
+ content=content,
108
+ user_id=document.user_id
109
+ )
110
+
111
+ return num_chunks
112
+
113
+ @staticmethod
114
+ def get_user_documents(db: Session, user_id: str) -> List[Document]:
115
+ """
116
+ Get all documents for a user.
117
+
118
+ Args:
119
+ db: Database session
120
+ user_id: User ID
121
+
122
+ Returns:
123
+ List of Document objects
124
+ """
125
+ return db.query(Document).filter(
126
+ Document.user_id == user_id
127
+ ).order_by(Document.created_at.desc()).all()
128
+
129
+ @staticmethod
130
+ def delete_document(db: Session, document_id: str) -> bool:
131
+ """
132
+ Delete a document and its chunks.
133
+
134
+ Args:
135
+ db: Database session
136
+ document_id: Document ID
137
+
138
+ Returns:
139
+ True if deleted, False if not found
140
+ """
141
+ document = db.query(Document).filter(Document.id == document_id).first()
142
+
143
+ if not document:
144
+ return False
145
+
146
+ # Delete file from filesystem
147
+ if os.path.exists(document.file_path):
148
+ os.remove(document.file_path)
149
+
150
+ # Delete chunks from vector database
151
+ rag_service.delete_document_chunks(document_id)
152
+
153
+ # Delete database record
154
+ db.delete(document)
155
+ db.commit()
156
+
157
+ return True
158
+
159
+
160
+ # Global document service instance
161
+ document_service = DocumentService()
app/services/news_service.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ from urllib.parse import quote, urlparse, parse_qs
4
+ from datetime import datetime
5
+ import dateparser
6
+
7
+ from app.services.sentiment_service import analyze_sentiment
8
+
9
+ HEADERS = {
10
+ "User-Agent": (
11
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
12
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
13
+ "Chrome/115.0.0.0 Safari/537.36"
14
+ )
15
+ }
16
+
17
+
18
+ def clean_google_url(google_url: str) -> str:
19
+ """
20
+ Extract actual URL from Google redirect-style /url?q=... links.
21
+
22
+ Args:
23
+ google_url: Google redirect URL
24
+
25
+ Returns:
26
+ Cleaned actual URL
27
+ """
28
+ parsed = urlparse(google_url)
29
+ if parsed.path == "/url":
30
+ qs = parse_qs(parsed.query)
31
+ return qs.get("q", [google_url])[0]
32
+ return google_url
33
+
34
+
35
+ def fetch_google_news(query: str, state: str) -> list:
36
+ """
37
+ Fetch news articles from Google News by scraping search results.
38
+
39
+ Args:
40
+ query: Search query (e.g., 'construction law')
41
+ state: Location/state filter (e.g., 'gujarat')
42
+
43
+ Returns:
44
+ List of news article dictionaries with title, link, snippet, sentiment, date
45
+ """
46
+ search_query = quote(f"{query} {state}")
47
+ url = f"https://www.google.com/search?q={search_query}&tbm=nws"
48
+
49
+ try:
50
+ res = requests.get(url, headers=HEADERS, timeout=10)
51
+ res.raise_for_status()
52
+ except requests.RequestException as e:
53
+ print(f"❌ Request failed: {e}")
54
+ return []
55
+
56
+ soup = BeautifulSoup(res.text, "html.parser")
57
+
58
+ results = []
59
+ articles = soup.find_all("div", class_="SoaBEf")
60
+
61
+ for article in articles:
62
+ # Extract title
63
+ title_tag = article.find("div", class_="n0jPhd ynAwRc MBeuO nDgy9d")
64
+ title = title_tag.text.strip() if title_tag else ""
65
+
66
+ # Extract URL and clean it
67
+ a_tag = article.find("a")
68
+ link = clean_google_url(a_tag["href"]) if a_tag and a_tag.get("href") else ""
69
+
70
+ # Extract snippet
71
+ snippet_tag = article.find("div", class_="GI74Re nDgy9d")
72
+ snippet = snippet_tag.text.strip() if snippet_tag else ""
73
+
74
+ # Extract date from the correct div
75
+ date_div = article.find("div", class_="OSrXXb")
76
+ date_text = ""
77
+ if date_div:
78
+ span = date_div.find("span")
79
+ if span:
80
+ date_text = span.text.strip()
81
+
82
+ # Parse it to datetime
83
+ parsed_date = dateparser.parse(date_text) if date_text else None
84
+
85
+ # Analyze sentiment using BOTH title and snippet for better accuracy
86
+ # Combine title and snippet as news snippets alone are often neutral
87
+ combined_text = f"{title}. {snippet}"
88
+ sentiment = analyze_sentiment(combined_text)
89
+
90
+ print(f"[SENTIMENT] {sentiment}: {title[:50]}...")
91
+
92
+ if title and link:
93
+ results.append({
94
+ "title": title,
95
+ "link": link,
96
+ "snippet": snippet,
97
+ "sentiment": sentiment,
98
+ "published_date": parsed_date.isoformat() if parsed_date else None,
99
+ "source": "Google News"
100
+ })
101
+
102
+ # Sort by date (newest first)
103
+ results.sort(key=lambda x: x["published_date"] or "", reverse=True)
104
+
105
+ print(f"✅ Extracted {len(results)} news articles")
106
+ return results
107
+
108
+
109
+ def fetch_google_news_with_rss_fallback(query: str, state: str) -> list:
110
+ """
111
+ Compatibility wrapper: prefer RSS/structured sources in future; currently calls fetch_google_news.
112
+
113
+ Args:
114
+ query: Search query
115
+ state: Location filter
116
+
117
+ Returns:
118
+ List of news articles
119
+ """
120
+ try:
121
+ return fetch_google_news(query, state)
122
+ except Exception as e:
123
+ print(f"Error fetching news: {e}")
124
+ return []
125
+
126
+
127
+ def fetch_news_plus_extras(query: str, state: str, include_extras: bool = False) -> dict:
128
+ """
129
+ Return combined news and optional extras (web results).
130
+
131
+ Args:
132
+ query: Search query
133
+ state: Location filter
134
+ include_extras: Whether to include extra web results
135
+
136
+ Returns:
137
+ Dictionary with count, news articles, and optional extras
138
+
139
+ Structure:
140
+ { 'count': int, 'news': [...], 'extras': [...] }
141
+ """
142
+ news = []
143
+ try:
144
+ news = fetch_google_news(query, state)
145
+ except Exception as e:
146
+ print(f"Error in fetch_news_plus_extras: {e}")
147
+ news = []
148
+
149
+ extras = []
150
+ # Placeholder: if include_extras is True we could call a web search or DuckDuckGo API.
151
+ # Keep extras empty to avoid optional dependencies causing import errors.
152
+
153
+ return {"count": len(news), "news": news, "extras": extras}
app/services/policy_service.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service for managing official policies (admin only).
3
+ """
4
+ from sqlalchemy.orm import Session
5
+ from typing import List, Optional
6
+ import os
7
+ import shutil
8
+
9
+ from app.database.models import OfficialPolicy
10
+ from app.utils.helpers import generate_id
11
+ from app.utils.validators import validate_file_type, validate_file_size
12
+ from app.services.rag_service import rag_service
13
+
14
+
15
+ class PolicyService:
16
+ """Service for official policy management."""
17
+
18
+ @staticmethod
19
+ def upload_policy(
20
+ db: Session,
21
+ file,
22
+ title: str,
23
+ filename: str,
24
+ admin_user_id: str,
25
+ description: Optional[str] = None,
26
+ category: Optional[str] = None
27
+ ) -> OfficialPolicy:
28
+ """
29
+ Upload an official policy document.
30
+
31
+ Args:
32
+ db: Database session
33
+ file: File object
34
+ title: Policy title
35
+ filename: Original filename
36
+ admin_user_id: Admin user ID
37
+ description: Optional description
38
+ category: Optional category
39
+
40
+ Returns:
41
+ Created OfficialPolicy object
42
+
43
+ Raises:
44
+ ValueError: If file validation fails
45
+ """
46
+ # Validate file type
47
+ if not validate_file_type(filename, ['pdf', 'txt', 'docx']):
48
+ raise ValueError("Invalid file type. Only PDF, TXT, and DOCX files are allowed.")
49
+
50
+ # Get file size
51
+ file.seek(0, 2)
52
+ file_size = file.tell()
53
+ file.seek(0)
54
+
55
+ # Validate file size (20MB limit for policies)
56
+ if not validate_file_size(file_size, max_size_mb=20):
57
+ raise ValueError("File size exceeds 20MB limit.")
58
+
59
+ # Generate policy ID
60
+ policy_id = generate_id()
61
+
62
+ # Determine file type
63
+ file_extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'unknown'
64
+
65
+ # Create upload directory
66
+ upload_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "policies")
67
+ os.makedirs(upload_dir, exist_ok=True)
68
+
69
+ # Save file
70
+ file_path = os.path.join(upload_dir, f"{policy_id}_{filename}")
71
+ with open(file_path, "wb") as buffer:
72
+ shutil.copyfileobj(file, buffer)
73
+
74
+ # Create policy record
75
+ policy = OfficialPolicy(
76
+ id=policy_id,
77
+ title=title,
78
+ description=description,
79
+ filename=filename,
80
+ file_path=file_path,
81
+ file_type=file_extension,
82
+ file_size=file_size,
83
+ category=category,
84
+ uploaded_by=admin_user_id,
85
+ is_active=1
86
+ )
87
+
88
+ db.add(policy)
89
+ db.commit()
90
+ db.refresh(policy)
91
+
92
+ return policy
93
+
94
+ @staticmethod
95
+ def process_policy_content(
96
+ db: Session,
97
+ policy_id: str,
98
+ content: str
99
+ ) -> int:
100
+ """
101
+ Process policy content for RAG (store in vector DB with special collection).
102
+
103
+ Args:
104
+ db: Database session
105
+ policy_id: Policy ID
106
+ content: Extracted text content
107
+
108
+ Returns:
109
+ Number of chunks created
110
+ """
111
+ policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first()
112
+
113
+ if not policy:
114
+ raise ValueError("Policy not found")
115
+
116
+ # Process with RAG service (using a special "official_policies" user_id)
117
+ num_chunks = rag_service.process_document(
118
+ document_id=policy.id,
119
+ filename=f"[POLICY] {policy.title}",
120
+ content=content,
121
+ user_id="official_policies" # Special ID for policies
122
+ )
123
+
124
+ return num_chunks
125
+
126
+ @staticmethod
127
+ def get_all_policies(db: Session, active_only: bool = True) -> List[OfficialPolicy]:
128
+ """
129
+ Get all official policies.
130
+
131
+ Args:
132
+ db: Database session
133
+ active_only: Only return active policies
134
+
135
+ Returns:
136
+ List of OfficialPolicy objects
137
+ """
138
+ query = db.query(OfficialPolicy)
139
+
140
+ if active_only:
141
+ query = query.filter(OfficialPolicy.is_active == 1)
142
+
143
+ return query.order_by(OfficialPolicy.created_at.desc()).all()
144
+
145
+ @staticmethod
146
+ def get_policy_by_id(db: Session, policy_id: str) -> Optional[OfficialPolicy]:
147
+ """
148
+ Get policy by ID.
149
+
150
+ Args:
151
+ db: Database session
152
+ policy_id: Policy ID
153
+
154
+ Returns:
155
+ OfficialPolicy object or None
156
+ """
157
+ return db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first()
158
+
159
+ @staticmethod
160
+ def update_policy(
161
+ db: Session,
162
+ policy_id: str,
163
+ title: Optional[str] = None,
164
+ description: Optional[str] = None,
165
+ category: Optional[str] = None,
166
+ is_active: Optional[bool] = None
167
+ ) -> Optional[OfficialPolicy]:
168
+ """
169
+ Update policy metadata.
170
+
171
+ Args:
172
+ db: Database session
173
+ policy_id: Policy ID
174
+ title: New title
175
+ description: New description
176
+ category: New category
177
+ is_active: New active status
178
+
179
+ Returns:
180
+ Updated OfficialPolicy object or None
181
+ """
182
+ policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first()
183
+
184
+ if not policy:
185
+ return None
186
+
187
+ if title is not None:
188
+ policy.title = title
189
+ if description is not None:
190
+ policy.description = description
191
+ if category is not None:
192
+ policy.category = category
193
+ if is_active is not None:
194
+ policy.is_active = 1 if is_active else 0
195
+
196
+ db.commit()
197
+ db.refresh(policy)
198
+
199
+ return policy
200
+
201
+ @staticmethod
202
+ def delete_policy(db: Session, policy_id: str) -> bool:
203
+ """
204
+ Delete a policy and its chunks.
205
+
206
+ Args:
207
+ db: Database session
208
+ policy_id: Policy ID
209
+
210
+ Returns:
211
+ True if deleted, False if not found
212
+ """
213
+ policy = db.query(OfficialPolicy).filter(OfficialPolicy.id == policy_id).first()
214
+
215
+ if not policy:
216
+ return False
217
+
218
+ # Delete file from filesystem
219
+ if os.path.exists(policy.file_path):
220
+ os.remove(policy.file_path)
221
+
222
+ # Delete chunks from vector database
223
+ rag_service.delete_document_chunks(policy_id)
224
+
225
+ # Delete database record
226
+ db.delete(policy)
227
+ db.commit()
228
+
229
+ return True
230
+
231
+
232
+ # Global policy service instance
233
+ policy_service = PolicyService()