Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions backend/__main__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""
Entry point for running the backend as a module.
This allows running:
This allows running:
- From root: python -m backend
- From backend: python -m __main__

This will start the FastAPI application with uvicorn,
which includes the Telegram bot via the lifespan context manager.
"""

import os
import sys
import uvicorn
Expand All @@ -15,7 +16,7 @@
# Get the port from environment variable (Render provides PORT)
port = int(os.environ.get("PORT", 8000))
host = os.environ.get("HOST", "0.0.0.0")

# Determine the correct module path based on where we're running from
# If we're in the backend directory, use "main:app"
# If we're in the root directory, use "backend.main:app"
Expand All @@ -24,11 +25,6 @@
app_module = "main:app"
else:
app_module = "backend.main:app"

# Run uvicorn
uvicorn.run(
app_module,
host=host,
port=port,
log_level="info"
)
uvicorn.run(app_module, host=host, port=port, log_level="info")
36 changes: 21 additions & 15 deletions backend/adaptive_weights.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

logger = logging.getLogger(__name__)

DATA_FILE = os.path.join(os.path.dirname(__file__), 'data', 'modelWeights.json')
DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "modelWeights.json")


class AdaptiveWeights:
_instance = None
Expand All @@ -32,7 +33,7 @@ def _load_weights(self):

mtime = os.path.getmtime(DATA_FILE)
if self._weights is None or mtime > self._last_loaded:
with open(DATA_FILE, 'r') as f:
with open(DATA_FILE, "r") as f:
self._weights = json.load(f)
self._last_loaded = mtime
self._reload_count += 1
Expand All @@ -58,7 +59,7 @@ def reload_count(self) -> int:

def _save_weights(self):
try:
with open(DATA_FILE, 'w') as f:
with open(DATA_FILE, "w") as f:
json.dump(self._weights, f, indent=2)
# Update last loaded to avoid immediate reload
self._last_loaded = os.path.getmtime(DATA_FILE)
Expand All @@ -67,31 +68,31 @@ def _save_weights(self):

def get_severity_keywords(self) -> Dict[str, List[str]]:
self._check_reload()
return self._weights.get('severity_keywords', {})
return self._weights.get("severity_keywords", {})

def get_urgency_patterns(self) -> List[List[Any]]:
self._check_reload()
return self._weights.get('urgency_patterns', [])
return self._weights.get("urgency_patterns", [])

def get_category_keywords(self) -> Dict[str, List[str]]:
self._check_reload()
return self._weights.get('category_keywords', {})
return self._weights.get("category_keywords", {})

def get_category_multipliers(self) -> Dict[str, float]:
self._check_reload()
return self._weights.get('category_multipliers', {})
return self._weights.get("category_multipliers", {})

def get_duplicate_search_radius(self) -> float:
self._check_reload()
return self._weights.get('duplicate_search_radius', 50.0)
return self._weights.get("duplicate_search_radius", 50.0)

def update_category_weight(self, category: str, factor: float):
"""
Updates the multiplier for a category.
Factor should be slightly > 1.0 to increase severity, or < 1.0 to decrease.
"""
self._check_reload() # Ensure we have latest
multipliers = self._weights.get('category_multipliers', {})
self._check_reload() # Ensure we have latest
multipliers = self._weights.get("category_multipliers", {})
current = multipliers.get(category, 1.0)

# Apply factor
Expand All @@ -101,22 +102,27 @@ def update_category_weight(self, category: str, factor: float):
new_weight = max(0.5, min(3.0, new_weight))

multipliers[category] = new_weight
self._weights['category_multipliers'] = multipliers
self._weights["category_multipliers"] = multipliers

self._weights['last_updated'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
self._weights["last_updated"] = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime()
)
self._save_weights()
logger.info(f"Updated weight for {category} to {new_weight:.2f}")

def update_duplicate_radius(self, factor: float):
self._check_reload()
current = self._weights.get('duplicate_search_radius', 50.0)
current = self._weights.get("duplicate_search_radius", 50.0)
new_radius = current * factor
# Clamp (10m to 200m)
new_radius = max(10.0, min(200.0, new_radius))

self._weights['duplicate_search_radius'] = new_radius
self._weights['last_updated'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
self._weights["duplicate_search_radius"] = new_radius
self._weights["last_updated"] = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime()
)
self._save_weights()
logger.info(f"Updated duplicate search radius to {new_radius:.1f}m")


adaptive_weights = AdaptiveWeights()
1 change: 1 addition & 0 deletions backend/ai_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

Fallback chain: gemini → huggingface → mock
"""

import os
from typing import Literal

Expand Down
17 changes: 10 additions & 7 deletions backend/ai_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This module defines abstract interfaces for AI services to reduce tight coupling
and enable easier testing, mocking, and service provider switching.
"""

from abc import ABC, abstractmethod
from typing import Dict, Optional, Protocol
import asyncio
Expand All @@ -16,8 +17,8 @@ async def generate_action_plan(
self,
issue_description: str,
category: str,
language: str = 'en',
image_path: Optional[str] = None
language: str = "en",
image_path: Optional[str] = None,
) -> Dict[str, str]:
"""
Generate action plan with WhatsApp message and email draft.
Expand Down Expand Up @@ -57,7 +58,7 @@ async def generate_mla_summary(
district: str,
assembly_constituency: str,
mla_name: str,
issue_category: Optional[str] = None
issue_category: Optional[str] = None,
) -> str:
"""
Generate a human-readable summary about an MLA.
Expand All @@ -81,7 +82,7 @@ def __init__(
self,
action_plan_service: ActionPlanService,
chat_service: ChatService,
mla_summary_service: MLASummaryService
mla_summary_service: MLASummaryService,
):
self.action_plan_service = action_plan_service
self.chat_service = chat_service
Expand All @@ -95,19 +96,21 @@ def __init__(
def get_ai_services() -> AIServiceContainer:
"""Get the global AI services container."""
if _ai_services is None:
raise RuntimeError("AI services not initialized. Call initialize_ai_services() first.")
raise RuntimeError(
"AI services not initialized. Call initialize_ai_services() first."
)
return _ai_services


def initialize_ai_services(
action_plan_service: ActionPlanService,
chat_service: ChatService,
mla_summary_service: MLASummaryService
mla_summary_service: MLASummaryService,
) -> None:
"""Initialize the global AI services container."""
global _ai_services
_ai_services = AIServiceContainer(
action_plan_service=action_plan_service,
chat_service=chat_service,
mla_summary_service=mla_summary_service
mla_summary_service=mla_summary_service,
)
52 changes: 34 additions & 18 deletions backend/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@
# Allow dummy key for testing/building if not strictly required at startup
api_key = "dummy"
if os.environ.get("ENVIRONMENT") == "production":
logger.warning("GEMINI_API_KEY not set in production environment!")
logger.warning("GEMINI_API_KEY not set in production environment!")

genai.configure(api_key=api_key)

RESPONSIBILITY_MAP_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json")
RESPONSIBILITY_MAP_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json"
)


async def retry_with_exponential_backoff(
func: Callable,
Expand All @@ -37,7 +40,7 @@ async def retry_with_exponential_backoff(
max_delay: float = 60.0,
backoff_factor: float = 2.0,
*args,
**kwargs
**kwargs,
) -> Any:
"""
Retry an async function with exponential backoff.
Expand Down Expand Up @@ -66,17 +69,21 @@ async def retry_with_exponential_backoff(

if attempt == max_retries:
# Last attempt failed, re-raise the exception
logger.error(f"Function {func.__name__} failed after {max_retries + 1} attempts: {e}")
logger.error(
f"Function {func.__name__} failed after {max_retries + 1} attempts: {e}"
)
raise AIServiceException(
f"AI service operation failed after {max_retries + 1} attempts",
service="Gemini",
details={"function": func.__name__, "error": str(e)}
details={"function": func.__name__, "error": str(e)},
) from e

# Calculate delay with exponential backoff
delay = min(base_delay * (backoff_factor ** attempt), max_delay)
delay = min(base_delay * (backoff_factor**attempt), max_delay)

logger.warning(f"Function {func.__name__} failed (attempt {attempt + 1}/{max_retries + 1}): {e}. Retrying in {delay:.1f}s")
logger.warning(
f"Function {func.__name__} failed (attempt {attempt + 1}/{max_retries + 1}): {e}. Retrying in {delay:.1f}s"
)
await asyncio.sleep(delay)

# This should never be reached, but just in case
Expand Down Expand Up @@ -108,7 +115,12 @@ def build_x_post(issue_description: str, category: str) -> str:
return f"{base_message} #CivicIssue #VishwaGuru"


async def generate_action_plan(issue_description: str, category: str, language: str = 'en', image_path: Optional[str] = None) -> dict:
async def generate_action_plan(
issue_description: str,
category: str,
language: str = "en",
image_path: Optional[str] = None,
) -> dict:
"""
Generates an action plan (WhatsApp message, Email draft) using Gemini with retry logic.
"""
Expand All @@ -120,12 +132,12 @@ async def generate_action_plan(issue_description: str, category: str, language:
"whatsapp": f"Hello, I would like to report a {category} issue: {issue_description}",
"email_subject": f"Complaint regarding {category}",
"email_body": f"Respected Authority,\n\nI am writing to bring to your attention a {category} issue: {issue_description}.\n\nPlease take necessary action.\n\nSincerely,\nCitizen",
"x_post": x_post_text
"x_post": x_post_text,
}

async def _generate_with_gemini() -> dict:
"""Inner function to generate action plan with Gemini"""
model = genai.GenerativeModel('gemini-1.5-flash')
model = genai.GenerativeModel("gemini-1.5-flash")

prompt = f"""
You are a civic action assistant. A user has reported a civic issue.
Expand All @@ -147,9 +159,9 @@ async def _generate_with_gemini() -> dict:

# Cleanup if markdown code blocks are returned
if "```json" in text_response:
text_response = text_response.split("```json")[1].split("```")[0]
text_response = text_response.split("```json")[1].split("```")[0]
elif "```" in text_response:
text_response = text_response.split("```")[1].split("```")[0]
text_response = text_response.split("```")[1].split("```")[0]

text_response = text_response.strip()

Expand All @@ -165,7 +177,9 @@ async def _generate_with_gemini() -> dict:
return plan

try:
return await retry_with_exponential_backoff(_generate_with_gemini, max_retries=3)
return await retry_with_exponential_backoff(
_generate_with_gemini, max_retries=3
)
except AIServiceException:
# Already properly wrapped, re-raise
raise
Expand All @@ -174,14 +188,16 @@ async def _generate_with_gemini() -> dict:
raise AIServiceException(
"Failed to generate action plan",
service="Gemini",
details={"error": str(e)}
details={"error": str(e)},
) from e


# Manual cache for chat
_chat_cache = {}
CHAT_CACHE_TTL = 3600 # 1 hour
CHAT_CACHE_TTL = 3600 # 1 hour
MAX_CHAT_CACHE_SIZE = 100


async def chat_with_civic_assistant(query: str) -> str:
"""
Chat with the civic assistant using Gemini with retry logic.
Expand All @@ -199,7 +215,7 @@ async def chat_with_civic_assistant(query: str) -> str:

async def _chat_with_gemini() -> str:
"""Inner function to chat with Gemini"""
model = genai.GenerativeModel('gemini-1.5-flash')
model = genai.GenerativeModel("gemini-1.5-flash")

prompt = f"""
You are VishwaGuru, a helpful civic assistant for Indian citizens.
Expand All @@ -219,7 +235,7 @@ async def _chat_with_gemini() -> str:
# Update cache
if len(_chat_cache) > MAX_CHAT_CACHE_SIZE:
# Prune oldest 20%
keys_to_remove = list(_chat_cache.keys())[:int(MAX_CHAT_CACHE_SIZE * 0.2)]
keys_to_remove = list(_chat_cache.keys())[: int(MAX_CHAT_CACHE_SIZE * 0.2)]
for k in keys_to_remove:
del _chat_cache[k]

Expand All @@ -234,5 +250,5 @@ async def _chat_with_gemini() -> str:
raise AIServiceException(
"Failed to process chat request",
service="Gemini",
details={"error": str(e)}
details={"error": str(e)},
) from e
Loading
Loading