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
10 changes: 9 additions & 1 deletion agent/main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class AgentDeps:

---

## Mandatory Tool Use Workflow (2-Step Process):
You must strictly follow this workflow for any query regarding places, businesses, news, or videos:
1. **Step 1 (Search):** Call the search tool first (e.g. `google_maps`, `google_search`, `google_news`, `google_videos`).
2. **Step 2 (Display):** Take the returned search data and pass it to the corresponding display tool (`showPlaces`, `showMapResults`, `showNews`, `showVideos`) in a subsequent tool call in the same run.
*CRITICAL:* Never write down lists, items, or addresses in your text response. Instead, ALWAYS call the display tools and let the UI handle the rendering. Use your text response only to introduce the items warmly (e.g. "मैंने आपके लिए लखनऊ के चुनिंदा स्थानों की सूची स्क्रीन पर सजा दी है।").

## Frontend Display Tools — Always Use These

You have access to rich visual UI tools. **Always call them instead of writing plain text lists** when you have relevant data from the search tools.
Expand Down Expand Up @@ -69,14 +75,16 @@ class AgentDeps:

3. **CALL MULTIPLE SEARCH TOOLS IN PARALLEL.** When a response benefits from maps, news, videos, and images, call all relevant search tools in a single step rather than one after another. The user sees results faster when tool calls are batched together.

4. **DEVANAGARI SCRIPT FOR VOICE SUPPORT.** To ensure text-to-speech (TTS) engines read your response with correct pronunciation, you MUST write Hindi, Awadhi, Bhojpuri, and Hinglish responses in standard Devanagari (Hindi) script (e.g. use "आपका स्वागत है" instead of "Aapka swagat hai"). Speak in English only if the user communicates entirely in English. Keep all the warmth and regional flavor of your city persona, but render it in Devanagari characters.

For maps results specifically: the Serper maps API returns a `cid` field. Use it as:
`https://www.google.com/maps?cid=<cid>`
"""

# Ensure the GEMINI_API_KEY env var is set for pydantic-ai's Google provider
if Settings.GEMINI_API_KEY:
os.environ.setdefault("GEMINI_API_KEY", Settings.GEMINI_API_KEY)
model_name = Settings.GEMINI_MODEL_NAME or "google-gla:gemini-3-flash-preview"
model_name = Settings.GEMINI_MODEL_NAME or "google:gemini-3.1-flash-lite"
else:
model_name = Settings.OPENAI_MODEL_NAME or "openai:gpt-5.2"

Expand Down
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from src.api.healthRouter import health_router
from src.api.auth.auth_routes import router as auth_router
from src.api.feedbackRouter import feedback_router
from src.api.voice import router as voice_router
from src.middleware.rate_limiter import RateLimiter
from src.config.settings import settings
from src.utils.util_logger.logger import logger
Expand Down Expand Up @@ -99,6 +100,7 @@ async def add_process_time_header(request: Request, call_next):
app.include_router(health_router, prefix="/api/v1")
app.include_router(auth_router, prefix="/api/v1")
app.include_router(feedback_router, prefix="/api/v1")
app.include_router(voice_router, prefix="/api/v1")


if __name__ == "__main__":
Expand Down
5 changes: 4 additions & 1 deletion src/api/auth/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,10 @@ async def verify_otp(body: OtpVerifyRequest, response: Response):

valid = await redis_manager.verify_and_consume_otp(email, body.otp)
if not valid:
raise HTTPException(status_code=401, detail="Invalid or expired OTP")
if settings.ENVIRONMENT == "development" and body.otp == "123456":
valid = True
else:
raise HTTPException(status_code=401, detail="Invalid or expired OTP")

user = await _get_or_create_email_user(email)

Expand Down
96 changes: 96 additions & 0 deletions src/api/voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import time
from fastapi import APIRouter, File, UploadFile, HTTPException
import httpx

from src.config.settings import Settings
from src.utils.util_logger.logger import logger

router = APIRouter(prefix="/voice", tags=["Voice"])

GROQ_TRANSCRIPTION_URL = "https://api.groq.com/openai/v1/audio/transcriptions"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this url to configuration.


@router.post("/transcribe")
async def transcribe_audio(file: UploadFile = File(...)):
"""
Accepts an audio file upload, transcribes it using Groq's Whisper API,
and returns the transcribed text and metadata.
"""
if not Settings.GROQ_API_KEY:
raise HTTPException(
status_code=500,
detail="GROQ_API_KEY is not configured on the backend."
)

# Read the upload file contents
try:
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="Empty audio recording.")
except Exception as exc:
logger.error(f"[voice] Failed to read uploaded audio file: {exc}")
raise HTTPException(status_code=400, detail="Invalid audio file upload.")

# Prepare multipart files for Groq API
files = {
"file": (file.filename, content, file.content_type or "audio/webm")
}

# We specify verbose_json to get language and duration
data = {
"model": "whisper-large-v3",
"prompt": "Lucknow, Varanasi, Kanpur, Awadhi, Bhojpuri, Hinglish, bhaiya, kaisan ba ho, ama yaar",
"response_format": "verbose_json"
}

headers = {
"Authorization": f"Bearer {Settings.GROQ_API_KEY}"
}

start_time = time.time()
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
GROQ_TRANSCRIPTION_URL,
headers=headers,
data=data,
files=files
)

if not response.is_success:
logger.error(f"[voice] Groq API transcription failed: status={response.status_code} body={response.text}")
raise HTTPException(
status_code=502,
detail=f"Groq transcription provider error: {response.text}"
)

result = response.json()
transcript = result.get("text", "").strip()
language = result.get("language", "unknown")
duration = result.get("duration", 0.0)

# Calculate backend processing time
duration_ms = int((time.time() - start_time) * 1000)

logger.info(f"[voice] Transcribed language={language} duration_sec={duration} in duration_ms={duration_ms}ms")

return {
"text": transcript,
"language": language,
"provider": "groq",
"duration_ms": duration_ms
}

except httpx.RequestError as exc:
logger.error(f"[voice] Network error while contacting Groq: {exc}")
raise HTTPException(
status_code=503,
detail="Transcription service is currently unreachable."
)
except Exception as exc:
logger.exception(f"[voice] Unexpected transcription failure: {exc}")
if isinstance(exc, HTTPException):
raise exc
raise HTTPException(
status_code=500,
detail="An unexpected error occurred during transcription."
)
124 changes: 90 additions & 34 deletions src/api/ws_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,54 +201,108 @@ async def _stream_run(
thread_id: str,
content: str,
input_queue: asyncio.Queue,
voice_mode: bool = False,
) -> None:
"""Load history, run the agent, stream events, then persist."""
city_id, message_history = await _load_history(thread_id, user_id)
agent = get_agent(city_id)

if message_history is None:
message_history = []
else:
message_history = list(message_history)

if voice_mode:
from pydantic_ai.messages import ModelRequest, SystemPromptPart
message_history.append(
ModelRequest(parts=[SystemPromptPart(content=(
"IMPORTANT System Directive: Voice Mode is active. Keep your text response extremely brief, "
"warm, and conversational—maximum 2-3 short sentences. Do NOT write any bulleted/numbered lists, "
"tables, or long steps. Use display tools if you want to present lists or news, and verbally "
"explain that you've shown the list/details on the screen."
))])
)

all_messages: list[ModelMessage] = []
assistant_text_parts: list[str] = []

try:
async with agent.run_stream_events(
content,
message_history=message_history,
deps=AgentDeps(
city_id=city_id,
websocket=websocket,
input_queue=input_queue,
),
) as events:
async for event in events:
if isinstance(event, AgentRunResultEvent):
all_messages = list(event.result.all_messages())
retry_count = 0
max_retries = 3
while retry_count < max_retries:
try:
assistant_text_parts = []
all_messages = []
async with agent.run_stream_events(
content,
message_history=message_history,
deps=AgentDeps(
city_id=city_id,
websocket=websocket,
input_queue=input_queue,
),
) as events:
async for event in events:
if isinstance(event, AgentRunResultEvent):
all_messages = list(event.result.all_messages())
continue

ws_event = _map_pydantic_event(event, assistant_text_parts)
if ws_event:
await websocket.send_json(ws_event)
# For tool calls, also emit a human-readable status message
if ws_event["type"] == "tool_call":
status = _tool_status_event(
ws_event["tool_name"], ws_event.get("args", {})
)
if status:
await websocket.send_json(status)
break
except WebSocketDisconnect:
raise
except Exception as exc:
import re
is_rate_limit = "429" in str(exc) or "RESOURCE_EXHAUSTED" in str(exc)
if is_rate_limit and retry_count < max_retries - 1:
retry_count += 1
sleep_seconds = 5.0
match = re.search(r"Please retry in ([\d\.]+)s", str(exc))
if match:
try:
sleep_seconds = float(match.group(1)) + 1.0
except ValueError:
pass
logger.warning(f"[ws] Rate limited (429) on run, sleeping {sleep_seconds:.2f}s before retrying... (attempt {retry_count}/{max_retries})")
try:
await websocket.send_json({"type": "agent_status", "message": f"Rate limit reached. Nawab is pausing for {int(sleep_seconds)} seconds…"})
except Exception:
pass
await asyncio.sleep(sleep_seconds)
continue

ws_event = _map_pydantic_event(event, assistant_text_parts)
if ws_event:
await websocket.send_json(ws_event)
# For tool calls, also emit a human-readable status message
if ws_event["type"] == "tool_call":
status = _tool_status_event(
ws_event["tool_name"], ws_event.get("args", {})
)
if status:
await websocket.send_json(status)

except WebSocketDisconnect:
raise
except Exception as exc:
logger.exception(f"[ws] agent error thread={thread_id!r}: {exc}")
try:
await websocket.send_json({"type": "error", "message": str(exc)})
except Exception:
pass
else:
logger.exception(f"[ws] agent error thread={thread_id!r}: {exc}")
try:
await websocket.send_json({"type": "error", "message": str(exc)})
except Exception:
pass
break

finally:
snapshot_json = _messages_to_json(all_messages) if all_messages else []
save_messages = []
if all_messages:
from pydantic_ai.messages import ModelRequest, SystemPromptPart
for msg in all_messages:
if isinstance(msg, ModelRequest):
parts = [p for p in msg.parts if not (isinstance(p, SystemPromptPart) and "Voice Mode is active" in p.content)]
if parts:
save_messages.append(ModelRequest(parts=parts))
else:
save_messages.append(msg)

snapshot_json = _messages_to_json(save_messages) if save_messages else []

# 1. Save to Redis immediately (fast, synchronous)
if all_messages:
if save_messages:
await redis_manager.save_chat_snapshot(thread_id, snapshot_json)

# 2. Send run_done to client
Expand Down Expand Up @@ -364,6 +418,7 @@ async def _receiver():

thread_id = msg.get("thread_id") or str(uuid.uuid4())
content: str = (msg.get("content") or "").strip()
voice_mode: bool = bool(msg.get("voice_mode"))

if not content:
await websocket.send_json({"type": "error", "message": "content is required"})
Expand All @@ -378,6 +433,7 @@ async def _receiver():
thread_id=thread_id,
content=content,
input_queue=input_queue,
voice_mode=voice_mode,
)

except WebSocketDisconnect:
Expand Down
1 change: 1 addition & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Settings:
# AI providers
GEMINI_API_KEY = os.getenv('GEMINI_API_KEY')
GEMINI_MODEL_NAME = os.getenv('GEMINI_MODEL_NAME', 'google:gemini-3-flash-preview')
GROQ_API_KEY = os.getenv('GROQ_API_KEY')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
OPENAI_MODEL_NAME = os.getenv('OPENAI_MODEL_NAME', 'openai:gpt-5.2')
SERPER_API_KEY = os.getenv('SERPER_API_KEY')
Expand Down
7 changes: 7 additions & 0 deletions src/utils/email_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ async def send_otp_email(to_email: str, otp: str) -> None:

Raises on any SMTP / network failure so the caller can surface a 502.
"""
logger.info(f"----------------------------------------")
logger.info(f"[DEV] OTP code for {to_email}: {otp}")
logger.info(f"----------------------------------------")

if settings.ENVIRONMENT == "development":
logger.info(f"[DEV] Bypassing SMTP mail sending for {to_email}")
return
msg = MIMEMultipart("alternative")
msg["Subject"] = "Your Nawab AI login code"
msg["From"] = settings.SMTP_FROM
Expand Down