Skip to content
2 changes: 2 additions & 0 deletions tools/2ndRoundDeliberation/initialize_followup_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ def initialize_event_collection(
'follow_up_questions': follow_up_toggle,
'extra_questions': extra_questions, # Add extra questions block
'mode': 'followup', # or "listener" / "survey"
'interaction_limit': 450, # Default; can be customized per event later


'second_round_prompts': {
'system_prompt': (
Expand Down
4 changes: 4 additions & 0 deletions tools/2ndRoundDeliberation/initialize_listener_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def initialize_event_collection(event_id, event_name, event_location, event_back
'language_guidance': language_guidance,
'extra_questions': extra_questions,
'mode': 'listener', # or "followup" / "survey"
'interaction_limit': 450, # Default; can be customized per event later
'default_model': 'gpt-4o-mini',



'second_round_prompts': {
'system_prompt': (
Expand Down
56 changes: 56 additions & 0 deletions tools/blocked_numbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import firebase_admin
from firebase_admin import credentials, firestore
import logging
import os, json

FIREBASE_CREDENTIALS_JSON = os.environ.get("FIREBASE_CREDENTIALS_JSON")

if not FIREBASE_CREDENTIALS_JSON:
raise RuntimeError("Missing FIREBASE_CREDENTIALS_JSON environment variable")

cred = credentials.Certificate(json.loads(FIREBASE_CREDENTIALS_JSON))
firebase_admin.initialize_app(cred)
db = firestore.client()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def initialize_blacklist_config(default_ttl_seconds=3600, initial_blocked_numbers=None):

Choose a reason for hiding this comment

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

blacklist/blocklist

"""
Creates or updates everything under the same 'blocked_numbers' collection.
- blocked_numbers/_config : stores cache TTL and other metadata
- blocked_numbers/<phone_number> : empty doc or metadata for each blocked user
"""
blk_ref = db.collection('blocked_numbers')

# 1. Save the cache TTL under a special _config document
config_ref = blk_ref.document('_config')
config_ref.set({
"cache_ttl_seconds": default_ttl_seconds
}, merge=True)

logger.info(f"[initialize_blacklist_config] Set cache_ttl_seconds={default_ttl_seconds}")

# 2. Optionally preload blocked numbers
if initial_blocked_numbers:
for num in initial_blocked_numbers:
blk_ref.document(num).set({}) # Empty doc = blocked
logger.info(f"[initialize_blacklist_config] Added blocked number: {num}")

if __name__ == "__main__":
initialize_blacklist_config(
default_ttl_seconds=3600, # 1-hour cache TTL
initial_blocked_numbers=[
"+whatsapp:131xxx",
"+whatsapp:131xxx",
"whatsapp:" # add more as needed
]
)

# Optional verification
doc = db.collection("blocked_numbers").document("_config").get()
print("Config exists:", doc.exists)
if doc.exists:
print("Data:", doc.to_dict())
else:
print("⚠️ Still missing – check credentials or Firestore project ID.")
1 change: 1 addition & 0 deletions tools/initialize_listener_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def initialize_event_collection(event_id, event_name, event_location, event_back
'language_guidance': language_guidance,
'extra_questions': extra_questions,
'mode': 'listener' # or "followup" / "survey"

})

logger.info(f"[initialize_event_collection] Event '{event_name}' initialized/overwritten with extra questions.")
Expand Down
4 changes: 3 additions & 1 deletion tools/initialize_survey_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ def initialize_event_collection(
'questions': formatted_questions,
'completion_message': completion_message,
'extra_questions': extra_questions,
'mode': 'survey' # or "followup" / "survey"
'mode': 'survey', # or "followup" / "survey"
'interaction_limit': 450 # Default; can be customized per event later

})

logger.info(f"Event '{event_name}' initialized with {len(formatted_questions)} survey questions and {len(extra_questions)} extra questions.")
Expand Down
64 changes: 64 additions & 0 deletions whatsapp_bot/app/deliberation/tests/test_blocklist_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@

import time
import pytest

from app.utils import blocklist_helpers


def test_default_ttl_is_int():

Choose a reason for hiding this comment

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

I'm not sure how much value this test is adding

ttl = blocklist_helpers._get_cache_ttl()
assert isinstance(ttl, int)
assert ttl > 0


def test_cache_update_logic(monkeypatch):
blocklist_helpers._last_ttl_fetch = 0

class FakeDoc:
def __init__(self, exists=True):
self.exists = exists
def to_dict(self):
return {"cache_ttl_seconds": 42}

class FakeDB:
def collection(self, name):
assert name == "system_settings" or name == "blocked_numbers"
return self
def document(self, name):
return self
def get(self):
return FakeDoc()

monkeypatch.setattr(blocklist_helpers, "db", FakeDB())

ttl = blocklist_helpers._get_cache_ttl()
assert ttl == 42, "TTL should update from Firestore mock"


def test_cache_reuse(monkeypatch):
"""Ensures cached TTL is reused within refresh interval."""
blocklist_helpers._ttl_value = 99
blocklist_helpers._last_ttl_fetch = time.time()

class FakeDB:
def collection(self, *_):
raise AssertionError("Firestore should not be called")
monkeypatch.setattr(blocklist_helpers, "db", FakeDB())

ttl = blocklist_helpers._get_cache_ttl()
assert ttl == 99


def test_is_blocked_number_caching(monkeypatch):
"""Tests that cached phone results are reused correctly."""
fake_phone = "12345"
now = time.time()
blocklist_helpers._cache[fake_phone] = {"value": True, "time": now}

class FakeDB:
def collection(self, *_):
raise AssertionError("Firestore should not be called")
monkeypatch.setattr(blocklist_helpers, "db", FakeDB())

result = blocklist_helpers.is_blocked_number(fake_phone)
assert result is True
24 changes: 21 additions & 3 deletions whatsapp_bot/app/handlers/FollowupMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
from app.utils.validators import _norm
from app.utils.validators import normalize_event_path

from app.utils.blocklist_helpers import is_blocked_number, get_interaction_limit



def is_second_round_enabled(event_id: str) -> bool:
Expand Down Expand Up @@ -69,9 +71,13 @@ async def reply_followup(Body: str, From: str, MediaUrl0: str = None):

logger.info(f"Received message from {From} with body '{Body}' and media URL {MediaUrl0}")

# Normalize phone number
normalized_phone = From.replace("+", "").replace("-", "").replace(" ", "")

if is_blocked_number(normalized_phone):
logger.warning(f"[Blacklist] Ignoring message from blocked number: {normalized_phone}")

Choose a reason for hiding this comment

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

blacklist/blocklist (and in several other places)

return Response(status_code=200)


# Step 1: Retrieve or initialize user tracking document
user_tracking_ref = db.collection('user_event_tracking').document(normalized_phone)
user_tracking_doc = user_tracking_ref.get()
Expand Down Expand Up @@ -746,8 +752,20 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None):

data = event_doc.to_dict()
interactions = data.get('interactions', [])
if len(interactions) >= 450:
send_message(From, "You have reached your interaction limit with AOI. Please contact AOI for further assistance.")
interaction_limit = get_interaction_limit(current_event_id)
if len(interactions) >= interaction_limit:
logger.info(f"[FollowUpMode] {normalized_phone} reached interaction limit "
f"({len(interactions)} / {interaction_limit}) for {current_event_id}")
# Log event for moderation
db.collection("users_exceeding_limit").document(normalized_phone).set({
"phone": normalized_phone,
"event_id": current_event_id,
"timestamp": datetime.utcnow().isoformat(),
"total_interactions": len(interactions),
"limit_used": interaction_limit
}, merge=True)

send_message(From, f"You have reached your interaction limit ({interaction_limit}) for this event. Please contact AOI for assistance.")
return Response(status_code=200)

# Send user prompt to LLM
Expand Down
124 changes: 109 additions & 15 deletions whatsapp_bot/app/handlers/ListenerMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from pydub import AudioSegment
from fastapi import Response
from app.deliberation.second_round_agent import run_second_round_for_user

from app.utils.blocklist_helpers import get_interaction_limit, is_blocked_number
import random

from config.config import (
db, logger, client, twilio_client,
Expand All @@ -35,7 +36,8 @@
from app.utils.validators import _norm
from app.utils.validators import normalize_event_path


DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "gpt-4o-mini")
FALLBACK_MODEL = os.getenv("FALLBACK_MODEL", "gpt-4.1-mini")

def is_second_round_enabled(event_id: str) -> bool:
"""Return True iff info.second_round_claims_source.enabled is truthy."""
Expand Down Expand Up @@ -65,13 +67,14 @@ def is_second_round_enabled(event_id: str) -> bool:


async def reply_listener(Body: str, From: str, MediaUrl0: str = None):


logger.info(f"Received message from {From} with body '{Body}' and media URL {MediaUrl0}")

# Normalize phone number
normalized_phone = From.replace("+", "").replace("-", "").replace(" ", "")

Choose a reason for hiding this comment

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

Feels like this snippet should probably be a utility function since it's repeated a few times.


if is_blocked_number(normalized_phone):
logger.warning(f"[Blacklist] Ignoring message from blocked number: {normalized_phone}")
return Response(status_code=200)

# Step 1: Retrieve or initialize user tracking document
user_tracking_ref = db.collection('user_event_tracking').document(normalized_phone)
user_tracking_doc = user_tracking_ref.get()
Expand Down Expand Up @@ -742,8 +745,22 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None):

data = event_doc.to_dict()
interactions = data.get('interactions', [])
if len(interactions) >= 450:
send_message(From, "You have reached your interaction limit with AOI. Please contact AOI for further assistance.")


interaction_limit = get_interaction_limit(current_event_id)
if len(interactions) >= interaction_limit:
logger.info(f"[Listener Mode] {normalized_phone} reached interaction limit "
f"({len(interactions)} / {interaction_limit}) for {current_event_id}")
# Log event for moderation
db.collection("users_exceeding_limit").document(normalized_phone).set({
"phone": normalized_phone,
"event_id": current_event_id,
"timestamp": datetime.utcnow().isoformat(),
"total_interactions": len(interactions),
"limit_used": interaction_limit
}, merge=True)

send_message(From, f"You have reached your interaction limit ({interaction_limit}) for this event. Please contact AOI for assistance.")
return Response(status_code=200)

# Send user prompt to LLM
Expand All @@ -758,20 +775,97 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None):
'interactions': firestore.ArrayUnion([{'message': Body}])
})

run = client.beta.threads.runs.create_and_poll(
thread_id=thread.id,
assistant_id=assistant_id,
instructions=event_instructions
)
try:
# Attempt to fetch model configuration from Firestore
event_info_ref = db.collection(normalize_event_path(current_event_id)).document("info")
event_info_doc = event_info_ref.get()

# Pre-initialize with the environment or constant default
default_model = DEFAULT_MODEL
if event_info_doc.exists:
event_info_data = event_info_doc.to_dict()
default_model = event_info_data.get("default_model", default_model)

logger.info(f"[LLM Config] Using model from Firestore: {default_model}")

except Exception as e:
logger.error(f"[LLM Config] Failed to fetch model from Firestore, defaulting to {DEFAULT_MODEL}: {e}")
default_model = DEFAULT_MODEL


try:
# Primary model attempt
logger.info(f"[LLM Run] Starting primary run with model: {default_model}")

run = client.beta.threads.runs.create_and_poll(
thread_id=thread.id,
assistant_id=assistant_id,
instructions=event_instructions,
model=default_model
)

logger.info(f"[LLM Debug] Primary run status: {getattr(run, 'status', 'N/A')}")

# Fallback if the primary model failed or didn’t complete
if run.status != "completed":
logger.warning(f"[LLM Fallback] Model {default_model} failed, retrying with {FALLBACK_MODEL}")

if hasattr(run, 'last_error'):
logger.error(f"[LLM Debug] last_error (primary): {run.last_error}")
if hasattr(run, 'incomplete_details'):
logger.error(f"[LLM Debug] incomplete_details (primary): {run.incomplete_details}")

run = client.beta.threads.runs.create_and_poll(
thread_id=thread.id,
assistant_id=assistant_id,
instructions=event_instructions,
model=FALLBACK_MODEL
)

logger.info(f"[LLM Debug] Fallback run status: {getattr(run, 'status', 'N/A')}")

except Exception as e:
logger.exception(f"[LLM Exception] Error while creating run: {e}")
run = None


# --- RESPONSE HANDLING ---
if run and run.status == "completed":
final_model = getattr(run, 'model', default_model)
logger.info(f"[LLM Success] Final model used: {final_model}")

if run.status == 'completed':
messages = client.beta.threads.messages.list(thread_id=thread.id)
assistant_response = extract_text_from_messages(messages)

send_message(From, assistant_response)
event_doc_ref.update({
'interactions': firestore.ArrayUnion([{'response': assistant_response}])
'interactions': firestore.ArrayUnion([
{'response': assistant_response, 'model': final_model, 'fallback': False}
])
})

else:
send_message(From, "There was an issue processing your request.")
logger.warning("[LLM Fallback] Both models failed or returned incomplete response.")

if run and hasattr(run, 'last_error'):
logger.error(f"[LLM Debug] last_error (final): {run.last_error}")
if run and hasattr(run, 'incomplete_details'):
logger.error(f"[LLM Debug] incomplete_details (final): {run.incomplete_details}")

fallback_responses = [
"Agreed.",
"Please continue.",
"That’s an interesting point, tell me more.",
"I understand.",
"Go on, I’m listening."
]
fallback_message = random.choice(fallback_responses)

send_message(From, fallback_message)
event_doc_ref.update({
'interactions': firestore.ArrayUnion([
{'response': fallback_message, 'model': None, 'fallback': True}
])
})

return Response(status_code=200)
Loading