Spaces:
Runtime error
Runtime error
Commit ·
882dc25
1
Parent(s): 482c410
Uploaded
Browse files- Dockerfile +7 -8
- app.py +179 -0
- requirements.txt +5 -2
- webhook_server.py +0 -86
Dockerfile
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
-
# Use a
|
| 2 |
FROM python:3.10-slim
|
| 3 |
|
| 4 |
-
# Set the working directory
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
-
# Copy
|
| 8 |
-
# This leverages Docker caching to speed up future builds
|
| 9 |
COPY requirements.txt .
|
| 10 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
| 12 |
-
# Copy the rest of
|
| 13 |
COPY . .
|
| 14 |
|
| 15 |
-
# Expose the port
|
| 16 |
EXPOSE 7860
|
| 17 |
|
| 18 |
-
# Command to run the application
|
| 19 |
-
CMD ["
|
|
|
|
| 1 |
+
# Use a stable, official Python image
|
| 2 |
FROM python:3.10-slim
|
| 3 |
|
| 4 |
+
# Set the working directory
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
+
# Copy requirements and install them to leverage Docker caching
|
|
|
|
| 8 |
COPY requirements.txt .
|
| 9 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
|
| 11 |
+
# Copy the rest of the application files
|
| 12 |
COPY . .
|
| 13 |
|
| 14 |
+
# Expose the port Hugging Face uses for Streamlit apps
|
| 15 |
EXPOSE 7860
|
| 16 |
|
| 17 |
+
# Command to run the unified application
|
| 18 |
+
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
app.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import threading
|
| 4 |
+
import streamlit as st
|
| 5 |
+
import gspread
|
| 6 |
+
import google.generativeai as genai
|
| 7 |
+
from flask import Flask, request, jsonify
|
| 8 |
+
from oauth2client.service_account import ServiceAccountCredentials
|
| 9 |
+
from vapi import Vapi
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
# --- Configuration Loading ---
|
| 13 |
+
def load_and_set_config():
|
| 14 |
+
"""Loads configuration from a single Hugging Face secret (JSON) and sets them as environment variables."""
|
| 15 |
+
config_json_str = os.getenv("APP_CONFIG_JSON")
|
| 16 |
+
if not config_json_str:
|
| 17 |
+
st.error("FATAL: The 'APP_CONFIG_JSON' secret is not set in your Space settings!")
|
| 18 |
+
st.stop()
|
| 19 |
+
|
| 20 |
+
config = json.loads(config_json_str)
|
| 21 |
+
for key, value in config.items():
|
| 22 |
+
os.environ[key] = str(value)
|
| 23 |
+
return config
|
| 24 |
+
|
| 25 |
+
# --- VAPI Call Logic ---
|
| 26 |
+
def place_vapi_call(config, customer_number, raw_intent, target_name):
|
| 27 |
+
# Process intent to generate a good first message
|
| 28 |
+
genai.configure(api_key=config["GEMINI_API_KEY"])
|
| 29 |
+
model = genai.GenerativeModel('gemini-1.5-flash')
|
| 30 |
+
|
| 31 |
+
prompt_details = f"The user's raw intent is: '{raw_intent}'."
|
| 32 |
+
if target_name:
|
| 33 |
+
prompt_details += f" They are calling a person named '{target_name}'."
|
| 34 |
+
|
| 35 |
+
gemini_prompt = f"""
|
| 36 |
+
Based on the user's intent, generate a single, natural, and polite opening sentence for a phone call.
|
| 37 |
+
{prompt_details}
|
| 38 |
+
If a name is provided, start by asking for them. Example: "Hello, may I please speak with {target_name}?" followed by the reason.
|
| 39 |
+
If no name is provided, use a generic opening. Example: "Hello, I'm calling on behalf of a user to inquire about something."
|
| 40 |
+
Your output should be ONLY the single sentence string.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
response = model.generate_content(gemini_prompt)
|
| 45 |
+
first_message = response.text.strip()
|
| 46 |
+
except Exception as e:
|
| 47 |
+
st.error(f"Error with Gemini: {e}")
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
# Place the actual call
|
| 51 |
+
try:
|
| 52 |
+
vapi = Vapi(token=config["VAPI_API_KEY"])
|
| 53 |
+
system_prompt = "You are a helpful and polite AI assistant named Alex. Your first sentence has already been spoken. You are now in an active conversation. Listen carefully and respond naturally. Continue the conversation in the language the other person is speaking (English or Hindi)."
|
| 54 |
+
|
| 55 |
+
return vapi.calls.create(
|
| 56 |
+
assistant_id=config["VAPI_ASSISTANT_ID"],
|
| 57 |
+
phone_number_id=config["VAPI_PHONE_NUMBER_ID"],
|
| 58 |
+
customer={"number": customer_number},
|
| 59 |
+
assistant={
|
| 60 |
+
"serverUrl": config["WEBHOOK_SERVER_URL"],
|
| 61 |
+
"firstMessage": first_message,
|
| 62 |
+
"voice": {"provider": "11labs", "voiceId": "pola", "speed": 0.9},
|
| 63 |
+
"model": {"provider": "openai", "model": "gpt-4o", "messages": [{"role": "system", "content": system_prompt}]},
|
| 64 |
+
"metadata": {"raw_intent": f"{raw_intent} (for {target_name})"}
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
st.error(f"An error occurred placing the Vapi call: {e}")
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
# --- FLASK WEBHOOK (Runs in the Background) ---
|
| 72 |
+
flask_app = Flask(__name__)
|
| 73 |
+
|
| 74 |
+
def log_to_google_sheet(row_data, config):
|
| 75 |
+
try:
|
| 76 |
+
print("[Log] Attempting to log to Google Sheets...")
|
| 77 |
+
if not os.path.exists("credentials.json"):
|
| 78 |
+
print("[Log] FATAL: 'credentials.json' not found.")
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
creds = ServiceAccountCredentials.from_json_keyfile_name("credentials.json", ["https://spreadsheets.google.com/feeds", 'https://www.googleapis.com/auth/spreadsheets', "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive"])
|
| 82 |
+
client = gspread.authorize(creds)
|
| 83 |
+
|
| 84 |
+
sheet = client.open(config["GOOGLE_SHEET_NAME"]).sheet1
|
| 85 |
+
sheet.append_row(row_data)
|
| 86 |
+
print(f"[Log] Successfully logged call data.")
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"[Log] !!! An error occurred during Google Sheets logging: {e} !!!")
|
| 89 |
+
|
| 90 |
+
@flask_app.route("/webhook", methods=['POST'])
|
| 91 |
+
def webhook_handler():
|
| 92 |
+
print(f"\n--- Webhook received POST request from Vapi at {datetime.now()} ---")
|
| 93 |
+
try:
|
| 94 |
+
payload = request.json
|
| 95 |
+
if payload.get('message', {}).get('type') == 'hang':
|
| 96 |
+
call_data = payload.get('message', {}).get('call', {})
|
| 97 |
+
row_to_insert = [
|
| 98 |
+
call_data.get("id", "N/A"), datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 99 |
+
call_data.get("customer", {}).get("number", "N/A"), call_data.get("metadata", {}).get("raw_intent", "N/A"),
|
| 100 |
+
payload.get('message', {}).get('endedReason', 'N/A'), call_data.get('summary', 'No summary provided.'),
|
| 101 |
+
"Yes" if "follow-up" in call_data.get('summary', '').lower() else "No",
|
| 102 |
+
json.dumps(call_data.get('transcript', []), indent=2)
|
| 103 |
+
]
|
| 104 |
+
config = json.loads(os.getenv("APP_CONFIG_JSON"))
|
| 105 |
+
log_to_google_sheet(row_to_insert, config)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"Error in webhook handler: {e}")
|
| 108 |
+
return jsonify({'status': 'success'}), 200
|
| 109 |
+
|
| 110 |
+
def run_flask():
|
| 111 |
+
# Running on a different port than Streamlit
|
| 112 |
+
flask_app.run(host="0.0.0.0", port=8080)
|
| 113 |
+
|
| 114 |
+
# --- STREAMLIT UI (Main Application) ---
|
| 115 |
+
def run_streamlit():
|
| 116 |
+
st.set_page_config(page_title="Voice AI Coordinator", layout="centered")
|
| 117 |
+
|
| 118 |
+
# Custom CSS for a pleasing look
|
| 119 |
+
st.markdown("""
|
| 120 |
+
<style>
|
| 121 |
+
.stApp {
|
| 122 |
+
background-color: #f0f2f5;
|
| 123 |
+
}
|
| 124 |
+
.stForm {
|
| 125 |
+
background-color: white;
|
| 126 |
+
padding: 2em;
|
| 127 |
+
border-radius: 10px;
|
| 128 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 129 |
+
}
|
| 130 |
+
.stButton>button {
|
| 131 |
+
width: 100%;
|
| 132 |
+
border: none;
|
| 133 |
+
border-radius: 5px;
|
| 134 |
+
padding: 10px;
|
| 135 |
+
font-weight: bold;
|
| 136 |
+
color: white;
|
| 137 |
+
background-color: #ff4b4b;
|
| 138 |
+
}
|
| 139 |
+
</style>
|
| 140 |
+
""", unsafe_allow_html=True)
|
| 141 |
+
|
| 142 |
+
st.title("🚀 Voice AI Coordination Tool")
|
| 143 |
+
st.markdown("A unified interface to deploy intelligent voice agents on your behalf.")
|
| 144 |
+
|
| 145 |
+
# Load configuration
|
| 146 |
+
try:
|
| 147 |
+
config = load_and_set_config()
|
| 148 |
+
except Exception as e:
|
| 149 |
+
st.error(f"Failed to load configuration. Please ensure APP_CONFIG_JSON secret is set correctly. Error: {e}")
|
| 150 |
+
st.stop()
|
| 151 |
+
|
| 152 |
+
with st.form(key="call_form"):
|
| 153 |
+
st.subheader("📞 Place a New Call")
|
| 154 |
+
target_name = st.text_input("Whom are you calling? (Optional)", placeholder="e.g., Jane Doe")
|
| 155 |
+
phone_number = st.text_input("Phone Number (with country code)", placeholder="+911234567890")
|
| 156 |
+
raw_intent = st.text_area("Reason for Calling", placeholder="Ask if they would prefer tea or coffee", height=100)
|
| 157 |
+
|
| 158 |
+
submit_button = st.form_submit_button(label="Deploy Call")
|
| 159 |
+
|
| 160 |
+
if submit_button:
|
| 161 |
+
if not phone_number or not raw_intent:
|
| 162 |
+
st.warning("Please provide a phone number and a reason for the call.")
|
| 163 |
+
else:
|
| 164 |
+
with st.spinner("Processing intent and placing call..."):
|
| 165 |
+
call_response = place_vapi_call(config, phone_number, raw_intent, target_name)
|
| 166 |
+
if call_response and call_response.id:
|
| 167 |
+
st.success(f"✅ Call deployed successfully! Call ID: {call_response.id}")
|
| 168 |
+
st.info("The call is being connected. Once it ends, the data will be logged to your Google Sheet.")
|
| 169 |
+
st.balloons()
|
| 170 |
+
else:
|
| 171 |
+
st.error("❌ Failed to deploy the call. Please check the Space logs for errors.")
|
| 172 |
+
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
# Run Flask in a background daemon thread
|
| 175 |
+
flask_thread = threading.Thread(target=run_flask, daemon=True)
|
| 176 |
+
flask_thread.start()
|
| 177 |
+
|
| 178 |
+
# Run the main Streamlit app
|
| 179 |
+
run_streamlit()
|
requirements.txt
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
gspread
|
| 3 |
oauth2client
|
| 4 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
flask
|
| 3 |
gspread
|
| 4 |
oauth2client
|
| 5 |
+
vapi-python
|
| 6 |
+
google-generativeai
|
| 7 |
+
python-dotenv
|
webhook_server.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import json
|
| 3 |
-
import gspread
|
| 4 |
-
from oauth2client.service_account import ServiceAccountCredentials
|
| 5 |
-
from flask import Flask, request, jsonify
|
| 6 |
-
from datetime import datetime
|
| 7 |
-
|
| 8 |
-
app = Flask(__name__)
|
| 9 |
-
|
| 10 |
-
HTML_TEMPLATE = """
|
| 11 |
-
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Vapi Webhook Server Status</title><style>body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f0f2f5; }} .container {{ text-align: center; padding: 40px; background-color: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }} .status {{ display: inline-block; padding: 12px 24px; background-color: #28a745; color: white; font-size: 20px; font-weight: 600; border-radius: 8px; margin-bottom: 20px; }} h1 {{ color: #333; }} p {{ color: #555; font-size: 16px; }} code {{ background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", monospace; }}</style></head><body><div class="container"><div class="status">Live</div><h1>Your Vapi Webhook Server is Running!</h1><p>This server is successfully deployed and ready to receive requests from Vapi.</p><p>Use the following full URL in your application's configuration:</p><code>{full_webhook_url}</code></div></body></html>
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
def log_to_google_sheet(row_data):
|
| 15 |
-
"""A dedicated, robust function to handle Google Sheets logging with extreme verbosity."""
|
| 16 |
-
try:
|
| 17 |
-
# --- STAGE 1: CHECK FOR CREDENTIALS FILE ---
|
| 18 |
-
print("[Log] Stage 1: Checking for 'credentials.json' file...")
|
| 19 |
-
if not os.path.exists("credentials.json"):
|
| 20 |
-
print("[Log] FATAL ERROR: 'credentials.json' was not found. Have you uploaded it to the Space?")
|
| 21 |
-
return
|
| 22 |
-
print("[Log] Stage 1 SUCCESS: 'credentials.json' found.")
|
| 23 |
-
|
| 24 |
-
# --- STAGE 2: AUTHENTICATE WITH GOOGLE ---
|
| 25 |
-
print("[Log] Stage 2: Authenticating with Google using the credentials file...")
|
| 26 |
-
scope = ["https://spreadsheets.google.com/feeds", 'https://www.googleapis.com/auth/spreadsheets',
|
| 27 |
-
"https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive"]
|
| 28 |
-
creds = ServiceAccountCredentials.from_json_keyfile_name("credentials.json", scope)
|
| 29 |
-
client = gspread.authorize(creds)
|
| 30 |
-
print("[Log] Stage 2 SUCCESS: Google Authentication successful.")
|
| 31 |
-
|
| 32 |
-
# --- STAGE 3: CHECK FOR SHEET NAME ---
|
| 33 |
-
print("[Log] Stage 3: Checking for GOOGLE_SHEET_NAME secret...")
|
| 34 |
-
sheet_name = os.getenv("GOOGLE_SHEET_NAME")
|
| 35 |
-
if not sheet_name:
|
| 36 |
-
print("[Log] FATAL ERROR: The GOOGLE_SHEET_NAME secret is not set in your Space settings.")
|
| 37 |
-
return
|
| 38 |
-
print(f"[Log] Stage 3 SUCCESS: Found sheet name secret: '{sheet_name}'.")
|
| 39 |
-
|
| 40 |
-
# --- STAGE 4: OPEN THE SHEET ---
|
| 41 |
-
print(f"[Log] Stage 4: Attempting to open Google Sheet '{sheet_name}'...")
|
| 42 |
-
sheet = client.open(sheet_name).sheet1
|
| 43 |
-
print("[Log] Stage 4 SUCCESS: Successfully opened the sheet.")
|
| 44 |
-
|
| 45 |
-
# --- STAGE 5: APPEND THE ROW ---
|
| 46 |
-
print(f"[Log] Stage 5: Appending row data: {row_data}")
|
| 47 |
-
sheet.append_row(row_data)
|
| 48 |
-
print("[Log] Stage 5 SUCCESS: Row appended successfully!")
|
| 49 |
-
|
| 50 |
-
except Exception as e:
|
| 51 |
-
print(f"\n!!!!!!!!!!!!!! [Log] A CRITICAL ERROR OCCURRED DURING GOOGLE SHEETS LOGGING !!!!!!!!!!!!!!")
|
| 52 |
-
print(f"ERROR DETAILS: {e}")
|
| 53 |
-
print("This is likely due to one of three reasons:")
|
| 54 |
-
print("1. The `credentials.json` file is missing or malformed.")
|
| 55 |
-
print("2. The GOOGLE_SHEET_NAME secret in your Space does not EXACTLY match your sheet's name.")
|
| 56 |
-
print("3. You have not SHARED your Google Sheet with the `client_email` from the credentials file.\n")
|
| 57 |
-
|
| 58 |
-
@app.route('/', methods=['GET'])
|
| 59 |
-
def root():
|
| 60 |
-
host = request.host_url
|
| 61 |
-
full_url = f"{host}webhook"
|
| 62 |
-
return HTML_TEMPLATE.format(full_webhook_url=full_url)
|
| 63 |
-
|
| 64 |
-
@app.route('/webhook', methods=['POST'])
|
| 65 |
-
def webhook_handler():
|
| 66 |
-
print(f"\n--- Webhook received POST request from Vapi at {datetime.now()} ---")
|
| 67 |
-
try:
|
| 68 |
-
payload = request.json
|
| 69 |
-
if payload.get('message', {}).get('type') == 'hang':
|
| 70 |
-
print("Received 'hang' event. Preparing to log call details...")
|
| 71 |
-
call_data = payload.get('message', {}).get('call', {})
|
| 72 |
-
row_to_insert = [
|
| 73 |
-
call_data.get("id", "N/A"), datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 74 |
-
call_data.get("customer", {}).get("number", "N/A"), call_data.get("metadata", {}).get("raw_intent", "N/A"),
|
| 75 |
-
payload.get('message', {}).get('endedReason', 'N/A'), call_data.get('summary', 'No summary provided.'),
|
| 76 |
-
"Yes" if "follow-up" in call_data.get('summary', '').lower() else "No",
|
| 77 |
-
json.dumps(call_data.get('transcript', []), indent=2)
|
| 78 |
-
]
|
| 79 |
-
log_to_google_sheet(row_to_insert)
|
| 80 |
-
except Exception as e:
|
| 81 |
-
print(f"An error occurred in the main webhook handler: {e}")
|
| 82 |
-
return jsonify({'status': 'success'}), 200
|
| 83 |
-
|
| 84 |
-
if __name__ == "__main__":
|
| 85 |
-
port = int(os.environ.get("PORT", 7860))
|
| 86 |
-
app.run(host="0.0.0.0", port=port)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|