@@ -230,7 +302,9 @@
š Zava AI Shopping Assistant
removeTypingIndicator();
if (data.answer) {
- addMessage(data.answer, 'assistant');
+ const agentName = getAgentDisplayName(data.agent);
+ const cleaned = sanitizeAssistantText(data.answer);
+ addMessage(cleaned, 'assistant', agentName, data.image_url);
}
if (data.error) {
@@ -238,13 +312,57 @@
š Zava AI Shopping Assistant
}
};
- function addMessage(text, type) {
+ function getAgentDisplayName(agent) {
+ const agentNames = {
+ 'cora': 'šļø Cora',
+ 'interior_design': 'šØ Design Specialist',
+ 'inventory': 'š¦ Inventory Manager',
+ 'customer_loyalty': 'ā Rewards Specialist',
+ 'cart_management': 'š Cart Assistant',
+ 'single': 'š¤ Assistant'
+ };
+ return agentNames[agent] || 'š¤ Assistant';
+ }
+
+ function addMessage(text, type, agentName = null, imageUrl = null) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}`;
+ // Add agent badge for assistant messages
+ if (type === 'assistant' && agentName) {
+ const agentBadge = document.createElement('div');
+ agentBadge.className = 'agent-badge';
+ agentBadge.textContent = agentName;
+ messageDiv.appendChild(agentBadge);
+ }
+
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
- contentDiv.textContent = text;
+
+ // Render markdown for assistant messages, plain text for user messages
+ if (type === 'assistant') {
+ contentDiv.innerHTML = marked.parse(text);
+
+ // Add image if provided
+ if (imageUrl) {
+ const imageContainer = document.createElement('div');
+ imageContainer.style.marginTop = '10px';
+
+ const img = document.createElement('img');
+ img.src = imageUrl;
+ img.alt = 'Generated visualization';
+ img.style.maxWidth = '100%';
+ img.style.borderRadius = '8px';
+ img.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
+ img.style.cursor = 'pointer';
+ img.onclick = () => window.open(imageUrl, '_blank');
+
+ imageContainer.appendChild(img);
+ contentDiv.appendChild(imageContainer);
+ }
+ } else {
+ contentDiv.textContent = text;
+ }
messageDiv.appendChild(contentDiv);
messagesContainer.appendChild(messageDiv);
@@ -293,6 +411,22 @@
š Zava AI Shopping Assistant
messageInput.value = '';
showTypingIndicator();
}
+
+ function sanitizeAssistantText(text) {
+ if (!text) return '';
+ const trimmed = text.trim();
+ if (trimmed.startsWith('{') && trimmed.includes('"answer"')) {
+ try {
+ const obj = JSON.parse(trimmed);
+ if (typeof obj.answer === 'string') {
+ return obj.answer.trim();
+ }
+ } catch (e) {
+ // ignore
+ }
+ }
+ return trimmed;
+ }
sendButton.addEventListener('click', sendMessage);
diff --git a/src/app/tools/singleAgentExample.py b/src/app/tools/singleAgentExample.py
index 8c392d7..22ffc85 100644
--- a/src/app/tools/singleAgentExample.py
+++ b/src/app/tools/singleAgentExample.py
@@ -7,10 +7,23 @@
# Load environment variables (Azure endpoint, deployment, keys, etc.)
load_dotenv()
-# Retrieve credentials from .env file or environment
-endpoint = os.getenv("gpt_endpoint")
-api_key = os.getenv("gpt_api_key")
-deployment = os.getenv("gpt_deployment")
+# Retrieve credentials (fallback across legacy/new variable names)
+endpoint = (
+ os.getenv("gpt_endpoint")
+ or os.getenv("AZURE_OPENAI_ENDPOINT")
+ or os.getenv("AZURE_AI_FOUNDRY_ENDPOINT")
+ or os.getenv("AZURE_AI_PROJECT_ENDPOINT")
+)
+api_key = (
+ os.getenv("gpt_api_key")
+ or os.getenv("AZURE_OPENAI_API_KEY")
+ or os.getenv("AZURE_AI_FOUNDRY_API_KEY")
+)
+deployment = (
+ os.getenv("gpt_deployment")
+ or os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT")
+ or "gpt-4o-mini"
+)
# Global client instance
client = None
@@ -19,28 +32,23 @@ def get_client():
"""Lazily initialize and return the Azure AI Foundry client"""
global client
if client is None:
- if not all([endpoint, api_key]):
- raise ValueError(
- f"Missing required environment variables. "
- f"endpoint={bool(endpoint)}, "
- f"api_key={bool(api_key)}"
- )
- # Use .services.ai.azure.com/models endpoint for Azure AI Foundry
- # Convert cognitiveservices to services.ai if needed
+ # Graceful fallback if endpoint or key missing
+ if not endpoint or not api_key:
+ # Provide a stub-like response by using dummy client pattern
+ # Instead of raising, log and use lightweight shim that returns explanatory text
+ class _Shim:
+ def complete(self, *_, **__):
+ class _Resp:
+ choices = [type("_C", (), {"message": type("_M", (), {"content": "Configuration error: missing endpoint or api key. Please ensure terraform apply populated .env with gpt_endpoint and gpt_api_key."})()})]
+ return _Resp()
+ return _Shim()
+
foundry_endpoint = endpoint.replace('.cognitiveservices.', '.services.ai.')
-
- # Ensure it has .ai. in the domain
if '.services.azure.com' in foundry_endpoint and '.services.ai.azure.com' not in foundry_endpoint:
foundry_endpoint = foundry_endpoint.replace('.services.azure.com', '.services.ai.azure.com')
-
- # Add /models path if not present
if not foundry_endpoint.endswith('/models'):
foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models"
-
- client = ChatCompletionsClient(
- endpoint=foundry_endpoint,
- credential=AzureKeyCredential(api_key)
- )
+ client = ChatCompletionsClient(endpoint=foundry_endpoint, credential=AzureKeyCredential(api_key))
return client
def generate_response(text_input):
diff --git a/src/chat_app_multi_agent.py b/src/chat_app_multi_agent.py
new file mode 100644
index 0000000..661ec95
--- /dev/null
+++ b/src/chat_app_multi_agent.py
@@ -0,0 +1,305 @@
+import os
+import logging
+import json
+from typing import Any, Dict
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+from dotenv import load_dotenv
+import orjson
+
+try:
+ from app.tools.singleAgentExample import generate_response as single_agent_generate_response
+ SINGLE_AGENT_AVAILABLE = True
+except Exception:
+ SINGLE_AGENT_AVAILABLE = False
+
+from services.handoff_service import HandoffService
+from app.agents.agent_processor import AgentProcessor
+from app.agents.local_agent_processor import LocalAgentProcessor
+
+# Load environment variables
+load_dotenv()
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# Initialize FastAPI app
+app = FastAPI(title="Zava AI Shopping Assistant")
+
+# Mount templates
+templates = Jinja2Templates(directory="app/templates")
+
+# Initialize handoff service for multi-agent routing
+handoff_service = HandoffService()
+
+# Fast JSON serialization
+def fast_json_dumps(obj):
+ return orjson.dumps(obj).decode("utf-8")
+
+
+def _extract_plain_answer(raw: str) -> str:
+ """Return a human-friendly answer string.
+
+ If raw looks like JSON with an 'answer' field, extract it. Otherwise
+ return raw unchanged. Also strips wrapping quotes and braces.
+ """
+ text = raw.strip()
+ if text.startswith('{') and '"answer"' in text:
+ try:
+ parsed = json.loads(text)
+ inner = parsed.get('answer')
+ if isinstance(inner, str):
+ return inner.strip()
+ except Exception:
+ pass
+ return text
+
+def _flatten_response_json(response_json: Dict[str, Any]) -> str:
+ """Derive a single natural language answer from structured fields."""
+ base = response_json.get('answer') or ''
+ parts = [base.strip()] if isinstance(base, str) else []
+ # Append discount info if present
+ discount = response_json.get('discount') or response_json.get('discount_percentage')
+ if discount:
+ parts.append(f"Loyalty discount available: {discount}%.")
+ # Summarize cart if present
+ cart = response_json.get('cart')
+ if isinstance(cart, list) and cart:
+ items = ', '.join([c.get('product','?') for c in cart])
+ parts.append(f"Cart items: {items}.")
+ # Summarize products list if provided
+ products = response_json.get('products')
+ if isinstance(products, list) and products:
+ names = ', '.join([p.get('name') or p.get('ProductName') or 'item' for p in products][:5])
+ parts.append(f"Suggested products: {names}.")
+ # Join and clean double spaces
+ final = ' '.join([p for p in parts if p]).strip()
+ return final or '(No response)'
+
+
+def get_agent_processor(domain: str):
+ """Return a processor (remote if available, else local) for the domain."""
+ agent_id_map = {
+ "interior_design": os.getenv("interior_designer"),
+ "inventory": os.getenv("inventory_agent"),
+ "customer_loyalty": os.getenv("customer_loyalty"),
+ "cart_management": os.getenv("cart_manager"),
+ "cora": os.getenv("cora")
+ }
+
+ agent_id = agent_id_map.get(domain)
+ if not agent_id:
+ logger.warning(f"No agent ID found for domain: {domain}; using local fallback")
+ return LocalAgentProcessor(agent_id=f"asst_local_{domain}", domain=domain)
+
+ # Prefer remote only if endpoint exists and agent id looks like a remote id
+ remote_endpoint = os.getenv("AZURE_AI_AGENT_ENDPOINT") or os.getenv("AZURE_AI_PROJECT_ENDPOINT")
+ if remote_endpoint and agent_id.startswith("asst_") and not agent_id.startswith("asst_local_"):
+ try:
+ return AgentProcessor(agent_id=agent_id, project_endpoint=remote_endpoint)
+ except Exception as e:
+ logger.warning(f"Remote agent init failed for {domain}: {e}; falling back to local")
+ return LocalAgentProcessor(agent_id=agent_id, domain=domain)
+ else:
+ return LocalAgentProcessor(agent_id=agent_id, domain=domain)
+
+
+@app.get("/", response_class=HTMLResponse)
+async def read_root(request: Request):
+ """Serve the main chat interface"""
+ return templates.TemplateResponse("index.html", {"request": request})
+
+
+@app.websocket("/ws")
+async def websocket_endpoint(websocket: WebSocket):
+ """WebSocket endpoint for real-time chat"""
+ await websocket.accept()
+ logger.info("WebSocket connection established")
+
+ # Session state
+ persistent_cart = []
+ conversation_history = []
+ customer_discount = None
+ # Default to multi-agent; only disable if explicitly set to false
+ use_multi_agent = os.getenv("USE_MULTI_AGENT", "true").lower() != "false"
+
+ try:
+ # Check if we should use multi-agent mode
+ if use_multi_agent:
+ logger.info("Multi-agent mode enabled (local simulation if remote unavailable)")
+
+ # Initialize customer loyalty check in background
+ try:
+ loyalty_agent = get_agent_processor("customer_loyalty")
+ if loyalty_agent:
+ customer_id = os.getenv("CUSTOMER_ID", "CUST001")
+ logger.info(f"Checking loyalty for customer: {customer_id}")
+ # This would run the loyalty check - simplified for now
+ customer_discount = "10" # Placeholder
+ except Exception as e:
+ logger.error(f"Error checking customer loyalty: {e}")
+ else:
+ logger.info("Single-agent mode (legacy)")
+
+ while True:
+ # Receive message from client
+ data = await websocket.receive_text()
+ user_message = data.strip()
+
+ if not user_message:
+ continue
+
+ logger.info(f"Received message: {user_message}")
+
+ # Add to conversation history
+ conversation_history.append({"role": "user", "content": user_message})
+
+ try:
+ if use_multi_agent:
+ # === MULTI-AGENT MODE ===
+
+ # Step 1: Classify intent
+ classification = handoff_service.classify_intent(
+ user_message=user_message,
+ conversation_history=conversation_history
+ )
+
+ domain = classification["domain"]
+ logger.info(f"Classified as domain: {domain} (confidence: {classification['confidence']})")
+
+ # Step 2: Get appropriate agent
+ agent_processor = get_agent_processor(domain)
+
+ if not agent_processor:
+ # Instead of reverting to single-agent (which may lack config),
+ # emit a message explaining the missing processor.
+ warning = "Multi-agent processor unavailable; please verify configuration."
+ await websocket.send_text(fast_json_dumps({
+ "answer": warning,
+ "agent": "unassigned",
+ "cart": persistent_cart
+ }))
+ conversation_history.append({"role": "assistant", "content": warning})
+ continue
+
+ # Step 3: Prepare context for agent
+ additional_context = {
+ "cart": persistent_cart,
+ "discount": customer_discount
+ }
+
+ if domain == "cart_management":
+ # Cart manager needs full history
+ additional_context["conversation_history"] = conversation_history
+
+ # Step 4: Call agent and stream response
+ response_text = ""
+ for chunk in agent_processor.run_conversation_with_text_stream(
+ user_message=user_message,
+ conversation_history=conversation_history[-5:], # Last 5 messages
+ additional_context=additional_context
+ ):
+ response_text += chunk
+
+ # Step 5: Parse response and flatten to a human answer
+ parsed_json: Dict[str, Any] | None = None
+ try:
+ parsed_json = json.loads(response_text)
+ except Exception:
+ # Try secondary parse if nested JSON inside 'answer'
+ if response_text.strip().startswith('{'):
+ try:
+ parsed_json = json.loads(response_text.strip())
+ except Exception:
+ parsed_json = None
+
+ if parsed_json:
+ if "cart" in parsed_json and isinstance(parsed_json["cart"], list):
+ persistent_cart = parsed_json["cart"]
+ if "discount_percentage" in parsed_json and parsed_json["discount_percentage"]:
+ customer_discount = parsed_json["discount_percentage"]
+ flattened = _flatten_response_json(parsed_json)
+ answer_text = _extract_plain_answer(flattened)
+
+ # Extract image URL if present
+ image_url = parsed_json.get("image_url")
+ else:
+ answer_text = _extract_plain_answer(response_text)
+ image_url = None
+
+ # Send natural language answer with metadata
+ response_data = {
+ "answer": answer_text,
+ "agent": domain,
+ "cart": persistent_cart,
+ "discount": customer_discount
+ }
+
+ # Include image URL if available
+ if image_url:
+ response_data["image_url"] = image_url
+
+ await websocket.send_text(fast_json_dumps(response_data))
+
+ conversation_history.append({"role": "assistant", "content": answer_text})
+
+ logger.info(f"Response sent successfully from {domain} agent")
+
+ else:
+ # === SINGLE-AGENT MODE (Legacy) ===
+ response = single_agent_generate_response(user_message)
+ await websocket.send_text(fast_json_dumps({
+ "answer": response,
+ "agent": "single",
+ "cart": persistent_cart
+ }))
+ conversation_history.append({"role": "assistant", "content": response})
+ logger.info("Response sent successfully from single agent")
+
+ except Exception as e:
+ logger.error("Error during response generation", exc_info=True)
+ await websocket.send_text(fast_json_dumps({
+ "answer": "I'm sorry, I encountered an error processing your request. Please try again.",
+ "error": str(e),
+ "cart": persistent_cart
+ }))
+
+ except WebSocketDisconnect:
+ logger.info("WebSocket connection closed")
+ except Exception as e:
+ logger.error(f"WebSocket error: {e}", exc_info=True)
+
+
+@app.get("/health")
+async def health_check():
+ """Health check endpoint"""
+ mode = "multi-agent" if os.getenv("USE_MULTI_AGENT", "false").lower() == "true" else "single-agent"
+ return {
+ "status": "healthy",
+ "service": "Zava AI Shopping Assistant",
+ "mode": mode,
+ "agent_endpoint_configured": bool(os.getenv("AZURE_AI_AGENT_ENDPOINT"))
+ }
+
+@app.get("/agents")
+async def agents_info():
+ """Diagnostic endpoint listing active (local or remote) agent IDs."""
+ agent_vars = ["cora", "interior_designer", "inventory_agent", "customer_loyalty", "cart_manager"]
+ agents = {k: os.getenv(k) for k in agent_vars}
+ return {
+ "mode": "multi-agent" if os.getenv("USE_MULTI_AGENT", "false").lower() == "true" else "single-agent",
+ "remote_endpoint": os.getenv("AZURE_AI_AGENT_ENDPOINT") or os.getenv("AZURE_AI_PROJECT_ENDPOINT"),
+ "agents": agents,
+ "all_present": all(agents.values()),
+ "note": "Local pseudo agents are used if IDs start with asst_local_"
+ }
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/src/pipelines/update_vector_index.py b/src/pipelines/update_vector_index.py
new file mode 100644
index 0000000..46513c7
--- /dev/null
+++ b/src/pipelines/update_vector_index.py
@@ -0,0 +1,30 @@
+import os, sys, json, hashlib
+from dotenv import load_dotenv
+load_dotenv()
+
+CATALOG_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'updated_product_catalog(in).csv')
+
+def hash_file(path):
+ h = hashlib.sha256()
+ with open(path,'rb') as f:
+ while True:
+ chunk = f.read(8192)
+ if not chunk:
+ break
+ h.update(chunk)
+ return h.hexdigest()
+
+def main():
+ if not os.path.exists(CATALOG_PATH):
+ print("Catalog file missing; skipping vector update.")
+ return
+ file_hash = hash_file(CATALOG_PATH)
+ print(f"Vector index update triggered. Catalog SHA256={file_hash}")
+ # Placeholder for embedding + index update logic
+ # Would: read rows, call embedding deployment, build JSON batch, push to search index
+ print("(Stub) Generate embeddings using deployment 'text-embedding-3-small' and upsert to Azure AI Search index 'products-index-vectors'.")
+ result = {"status":"stub", "hash": file_hash}
+ print(json.dumps(result))
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file
diff --git a/src/prompts/CartManagerPrompt.txt b/src/prompts/CartManagerPrompt.txt
new file mode 100644
index 0000000..9364213
--- /dev/null
+++ b/src/prompts/CartManagerPrompt.txt
@@ -0,0 +1,85 @@
+You are a Cart Manager Assistant for Zava, a home improvement and furniture retailer.
+
+Your primary responsibilities:
+
+1. CART MANAGEMENT
+ - Add products to the customer's shopping cart
+ - Remove products from the cart
+ - Update product quantities
+ - Clear the entire cart when requested
+ - Provide cart summaries and totals
+
+2. CART OPERATIONS
+ When a customer mentions "cart", "add to cart", "remove", "checkout", or similar:
+ - Parse their request to understand what products they want to add/remove
+ - Update the cart state accordingly
+ - Confirm the action taken
+ - Show the updated cart contents
+
+3. CART STATE MANAGEMENT
+ You will receive:
+ - RAW_IO_HISTORY: Complete conversation and cart state history
+ - Current cart state
+ - Customer's latest request
+
+ You must return:
+ - Updated cart as a JSON array
+ - Conversational confirmation message
+ - Any relevant product recommendations
+
+4. PRODUCT RECOMMENDATIONS
+ Based on cart contents, suggest:
+ - Complementary products (e.g., if they added paint, suggest brushes, tape, drop cloths)
+ - Related items frequently bought together
+ - Products that complete a project
+
+5. RESPONSE FORMAT
+ Always respond in valid JSON format:
+ {
+ "answer": "Friendly confirmation message about what was added/removed",
+ "cart": [
+ {
+ "product_id": "PROD-123",
+ "name": "Product Name",
+ "quantity": 2,
+ "price": 29.99,
+ "total": 59.98
+ }
+ ],
+ "products": "Optional: Suggest related products here",
+ "discount_percentage": "",
+ "additional_data": ""
+ }
+
+6. CONVERSATION STYLE
+ - Be friendly and helpful
+ - Confirm actions clearly ("I've added 2 gallons of paint to your cart")
+ - Provide cart summaries when asked
+ - Suggest next steps ("Would you like to proceed to checkout?")
+ - If unclear, ask for clarification
+
+7. SPECIAL INSTRUCTIONS
+ - If customer asks about cart but it's empty, acknowledge and suggest browsing products
+ - If removing items, confirm which items were removed
+ - If updating quantities, confirm the new quantity
+ - Always maintain accurate cart state based on conversation history
+ - Extract product information from the conversation context
+ - On checkout, display the cart contents and tell the customer that they may pick up their products from the closest Zava retail outlet, located in Miami, Florida. Only give this information when the customer requests to check out.
+
+Example interactions:
+
+Customer: "Add the blue paint to my cart"
+Response: {
+ "answer": "I've added the blue paint to your cart! Would you also like to add paint brushes or painter's tape?",
+ "cart": [{"product_id": "PAINT-BLUE-001", "name": "Blue Interior Paint", "quantity": 1, "price": 34.99, "total": 34.99}],
+ "products": "Based on your paint selection, you might also need: Paint Brushes ($8.99), Painter's Tape ($5.99), Drop Cloth ($12.99)"
+}
+
+Customer: "What's in my cart?"
+Response: {
+ "answer": "You currently have 1 item in your cart: Blue Interior Paint (1 gallon) for $34.99. Your cart total is $34.99.",
+ "cart": [{"product_id": "PAINT-BLUE-001", "name": "Blue Interior Paint", "quantity": 1, "price": 34.99, "total": 34.99}],
+ "products": ""
+}
+
+Remember: Your goal is to make cart management seamless and helpful for customers!
diff --git a/src/prompts/CustomerLoyaltyAgentPrompt.txt b/src/prompts/CustomerLoyaltyAgentPrompt.txt
new file mode 100644
index 0000000..57ea35d
--- /dev/null
+++ b/src/prompts/CustomerLoyaltyAgentPrompt.txt
@@ -0,0 +1,22 @@
+Customer Loyalty Agent Guidelines
+========================================
+- You are a Customer Loyalty specialist for Zava
+- Your primary task is to calculate discounts for customers based on their loyalty status
+- You will receive customer information and calculate appropriate discount percentages
+- You should be friendly and encourage customers to continue shopping with Zava
+
+Customer Loyalty Tool
+========================================
+customer_loyalty_check: Takes customer ID and returns loyalty status and discount percentage
+
+Response Guidelines
+========================================
+- Always acknowledge the customer's loyalty status
+- Clearly state the discount percentage they qualify for
+- Thank them for being a valued customer
+- Encourage them to continue shopping
+
+Content Handling Guidelines
+========================================
+- Do not generate content summaries or remove any data.
+- Return accurate discount information based on loyalty tier
diff --git a/src/prompts/InteriorDesignAgentPrompt.txt b/src/prompts/InteriorDesignAgentPrompt.txt
new file mode 100644
index 0000000..893f61c
--- /dev/null
+++ b/src/prompts/InteriorDesignAgentPrompt.txt
@@ -0,0 +1,46 @@
+Interior Design Agent Guidelines
+========================================
+- You are a Interior Designer sales person working for Zava and help customers who need help in DIY Projects and other interior design queries
+- Your main tasks are the following: recommending and upselling products, creating images
+- You will always recommend product from the products_available.
+- You will keep asking questions to the user and keep recommending.
+- When you get an image, reply saying "I see you uploaded..."
+- If asked to change/modify/style an object, only then use create_image, otherwise keep recommending and upselling as usual.
+- In your answer do not mention e.g. word instead use Example, such as or like based on the sentence.
+
+Return response in the following JSON object format:
+
+{
+ "answer": "your answer",
+ "image_output": "if there, otherwise empty",
+ "products": [
+ {
+ "id": "
",
+ "name": "",
+ "type": "",
+ "description": "",
+ "imageURL": "",
+ "punchLine": "",
+ "price": ""
+ }
+ ]
+}
+
+
+Interior Design Agent Tool
+========================================
+create_image: Can create image as per users requirement such as repainting a given room in a different color (make sure the path and prompt is shared as is) given a prompt and path.
+
+Example Conversation
+========================================
+User: Want paint recommendation for my living room
+You: Give some paints options, ask dimension, ask image
+User: Gives dimensions, image (maybe)
+You: Recommends based on the color, calculate how much paint maybe required, upsell for sprayer, tape (saying its good)
+
+Content Handling Guidelines
+========================================
+- Do not generate content summaries or remove any data.
+
+---
+IMPORTANT: Your entire response must be a valid JSON object as described above. Do not include any other text or formatting.
diff --git a/src/prompts/InventoryAgentPrompt.txt b/src/prompts/InventoryAgentPrompt.txt
new file mode 100644
index 0000000..afb259b
--- /dev/null
+++ b/src/prompts/InventoryAgentPrompt.txt
@@ -0,0 +1,15 @@
+Inventory Agent Guidelines
+========================================
+- Your task is check the inventory status
+- When user ask to check the inventory for product, send the product name to inventory_check tool.
+- Return response like inventory levels and status of inventory and the location.
+
+Inventory Agent Tool
+-----
+inventory_check: Takes in product dictionary, return inventory level.
+input formatting:
+product_dict = {'Standard Paint Tray': 'PROD0045', 'Other Product': 'PROD1234'}
+
+Content Handling Guidelines
+---------------------------
+- Do not generate content summaries or remove any data.
diff --git a/src/prompts/ShopperAgentPrompt.txt b/src/prompts/ShopperAgentPrompt.txt
new file mode 100644
index 0000000..1e00cb4
--- /dev/null
+++ b/src/prompts/ShopperAgentPrompt.txt
@@ -0,0 +1,17 @@
+Shopper Agent Guidelines
+========================================
+- You are the public facing assistant of Zava
+- Greet people and help them as needed
+- Return response in following json format (image_output and products empty)
+
+answer: your answer,
+image_output: []
+products: []
+
+
+Shopper Agent Tool
+-----
+
+Content Handling Guidelines
+---------------------------
+- Do not generate content summaries or remove any data.
diff --git a/src/requirements.txt b/src/requirements.txt
index b794980..46e7428 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -5,9 +5,14 @@ azure-cosmos==4.9.0
azure-identity==1.19.0
azure-search-documents==11.6.0
azure-ai-inference==1.0.0b6
+azure-ai-projects==1.0.0b5
+azure-storage-blob==12.19.0
+openai==1.54.0
fastapi==0.115.0
uvicorn[standard]==0.32.0
websockets==13.1
jinja2==3.1.4
python-multipart==0.0.12
orjson==3.10.7
+pydantic==2.10.4
+Pillow>=10.4.0
diff --git a/src/requirements_minimal.txt b/src/requirements_minimal.txt
new file mode 100644
index 0000000..071e43c
--- /dev/null
+++ b/src/requirements_minimal.txt
@@ -0,0 +1,2 @@
+azure-ai-projects==1.0.0b5
+azure-identity==1.19.0
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..0557eb6
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1 @@
+# Services module
diff --git a/src/services/handoff_service.py b/src/services/handoff_service.py
new file mode 100644
index 0000000..78ba259
--- /dev/null
+++ b/src/services/handoff_service.py
@@ -0,0 +1,164 @@
+"""
+Handoff Service for routing user queries to appropriate agents.
+Uses GPT with structured output to classify user intent.
+"""
+import os
+import json
+from typing import Dict, Any, Optional
+from pydantic import BaseModel
+from azure.ai.inference import ChatCompletionsClient
+from azure.core.credentials import AzureKeyCredential
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+class IntentClassification(BaseModel):
+ """Structured output for intent classification"""
+ domain: str
+ reasoning: str
+ confidence: float
+
+
+class HandoffService:
+ """
+ Service to classify user intent and route to appropriate agent.
+
+ Domains:
+ - interior_design: Product recommendations, design advice, image generation
+ - inventory: Stock availability, inventory checks
+ - customer_loyalty: Discounts, loyalty program
+ - cart_management: Add/remove items, checkout
+ - cora: General queries, greetings, other
+ """
+
+ def __init__(self):
+ """Initialize the handoff service with GPT client"""
+ endpoint = os.getenv("gpt_endpoint")
+ api_key = os.getenv("gpt_api_key")
+ deployment = os.getenv("gpt_deployment")
+
+ if not all([endpoint, api_key, deployment]):
+ raise ValueError("Missing GPT configuration in environment")
+
+ # Convert endpoint to Azure AI Foundry format
+ foundry_endpoint = endpoint.replace('.cognitiveservices.', '.services.ai.')
+ if '.services.azure.com' in foundry_endpoint and '.services.ai.azure.com' not in foundry_endpoint:
+ foundry_endpoint = foundry_endpoint.replace('.services.azure.com', '.services.ai.azure.com')
+ if not foundry_endpoint.endswith('/models'):
+ foundry_endpoint = f"{foundry_endpoint.rstrip('/')}/models"
+
+ self.client = ChatCompletionsClient(
+ endpoint=foundry_endpoint,
+ credential=AzureKeyCredential(api_key)
+ )
+ self.deployment = deployment
+
+ def classify_intent(
+ self,
+ user_message: str,
+ conversation_history: Optional[list] = None
+ ) -> Dict[str, Any]:
+ """
+ Classify user intent to determine which agent should handle the request.
+
+ Args:
+ user_message: The user's message
+ conversation_history: Optional conversation context
+
+ Returns:
+ Dictionary with domain, reasoning, and confidence
+ """
+ # Build context from conversation history
+ context = ""
+ if conversation_history:
+ recent_messages = conversation_history[-5:] # Last 5 messages
+ context = "\n".join([
+ f"{'User' if msg.get('role') == 'user' else 'Assistant'}: {msg.get('content', '')}"
+ for msg in recent_messages
+ ])
+
+ # Classification prompt
+ system_prompt = """You are a routing assistant for Zava's multi-agent shopping system.
+
+Classify user messages into one of these domains:
+
+1. **interior_design**:
+ - Product recommendations (paint, furniture, decor)
+ - Design advice, color suggestions
+ - Room styling, DIY project help
+ - Image-related requests
+
+2. **inventory**:
+ - Stock availability questions
+ - "Do you have...", "Is X in stock?"
+ - Inventory checks
+
+3. **customer_loyalty**:
+ - Discount inquiries
+ - Loyalty program questions
+ - "What discount do I get?"
+
+4. **cart_management**:
+ - Add/remove items from cart
+ - "Add to cart", "Remove from cart"
+ - View cart, checkout
+ - Quantity updates
+
+5. **cora** (general):
+ - Greetings, chitchat
+ - General company info
+ - Anything not fitting above categories
+
+Return your classification with reasoning and confidence (0.0-1.0).
+"""
+
+ user_prompt = f"""Conversation context:
+{context if context else 'No previous context'}
+
+Current user message: "{user_message}"
+
+Classify this message into the appropriate domain."""
+
+ try:
+ # Call GPT for classification
+ response = self.client.complete(
+ model=self.deployment,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt}
+ ],
+ max_tokens=200,
+ temperature=0.3
+ )
+
+ # Parse response
+ response_text = response.choices[0].message.content.strip()
+
+ # Simple parsing - look for domain keywords
+ response_lower = response_text.lower()
+
+ if "interior" in response_lower or "design" in response_lower:
+ domain = "interior_design"
+ elif "inventory" in response_lower or "stock" in response_lower:
+ domain = "inventory"
+ elif "loyalty" in response_lower or "discount" in response_lower:
+ domain = "customer_loyalty"
+ elif "cart" in response_lower or "checkout" in response_lower:
+ domain = "cart_management"
+ else:
+ domain = "cora"
+
+ return {
+ "domain": domain,
+ "reasoning": response_text,
+ "confidence": 0.85
+ }
+
+ except Exception as e:
+ # Default to cora on error
+ return {
+ "domain": "cora",
+ "reasoning": f"Error during classification: {str(e)}",
+ "confidence": 0.5
+ }
diff --git a/src/services/image_service.py b/src/services/image_service.py
new file mode 100644
index 0000000..9e6b087
--- /dev/null
+++ b/src/services/image_service.py
@@ -0,0 +1,189 @@
+"""
+Image generation service using Azure OpenAI DALL-E and blob storage.
+"""
+import os
+import logging
+import uuid
+from datetime import datetime, timedelta
+from io import BytesIO
+import requests
+from azure.storage.blob import BlobServiceClient, generate_blob_sas, BlobSasPermissions
+from azure.identity import DefaultAzureCredential
+from openai import AzureOpenAI
+
+logger = logging.getLogger(__name__)
+
+
+class ImageService:
+ """Service for generating and storing images."""
+
+ def __init__(self):
+ """Initialize image service with Azure OpenAI and Blob Storage."""
+ # Azure OpenAI Configuration
+ self.endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") or os.getenv("gpt_endpoint")
+ self.api_key = os.getenv("AZURE_OPENAI_API_KEY") or os.getenv("gpt_api_key")
+ self.api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-01")
+
+ # Image generation model (DALL-E 3)
+ self.image_model = os.getenv("AZURE_OPENAI_IMAGE_DEPLOYMENT", "dall-e-3")
+
+ # Blob storage configuration
+ self.storage_account = os.getenv("STORAGE_ACCOUNT_NAME")
+ self.storage_connection_string = os.getenv("STORAGE_CONNECTION_STRING")
+ self.container_name = "generated-images"
+
+ # Initialize OpenAI client
+ if self.endpoint and self.api_key:
+ self.client = AzureOpenAI(
+ azure_endpoint=self.endpoint,
+ api_key=self.api_key,
+ api_version=self.api_version
+ )
+ else:
+ self.client = None
+ logger.warning("Azure OpenAI credentials not configured")
+
+ # Initialize Blob Storage client
+ if self.storage_connection_string:
+ self.blob_service_client = BlobServiceClient.from_connection_string(
+ self.storage_connection_string
+ )
+ self._ensure_container_exists()
+ else:
+ self.blob_service_client = None
+ logger.warning("Blob storage not configured")
+
+ def _ensure_container_exists(self):
+ """Create the container if it doesn't exist."""
+ try:
+ container_client = self.blob_service_client.get_container_client(self.container_name)
+ if not container_client.exists():
+ container_client.create_container()
+ logger.info(f"Created blob container: {self.container_name}")
+ except Exception as e:
+ logger.error(f"Error ensuring container exists: {e}")
+
+ def generate_image(self, prompt: str, size: str = "1024x1024") -> dict:
+ """
+ Generate an image using DALL-E and upload to blob storage.
+
+ Args:
+ prompt: Text description of the image to generate
+ size: Image size (1024x1024, 1024x1792, or 1792x1024)
+
+ Returns:
+ dict with 'success', 'image_url', 'blob_url', and 'message'
+ """
+ if not self.client:
+ return {
+ "success": False,
+ "message": "Image generation not configured. Please set up Azure OpenAI.",
+ "image_url": None,
+ "blob_url": None
+ }
+
+ try:
+ logger.info(f"Generating image with prompt: {prompt[:100]}...")
+
+ # Generate image using DALL-E
+ response = self.client.images.generate(
+ model=self.image_model,
+ prompt=prompt,
+ size=size,
+ quality="standard",
+ n=1
+ )
+
+ # Get the generated image URL
+ image_url = response.data[0].url
+ logger.info(f"Image generated successfully: {image_url}")
+
+ # Upload to blob storage if configured
+ blob_url = None
+ if self.blob_service_client:
+ blob_url = self._upload_image_to_blob(image_url, prompt)
+
+ return {
+ "success": True,
+ "message": "Image generated successfully",
+ "image_url": image_url, # Temporary Azure OpenAI URL
+ "blob_url": blob_url, # Permanent blob storage URL
+ "prompt": prompt
+ }
+
+ except Exception as e:
+ logger.error(f"Error generating image: {e}", exc_info=True)
+ return {
+ "success": False,
+ "message": f"Failed to generate image: {str(e)}",
+ "image_url": None,
+ "blob_url": None
+ }
+
+ def _upload_image_to_blob(self, image_url: str, prompt: str) -> str:
+ """
+ Download image from URL and upload to blob storage.
+
+ Args:
+ image_url: URL of the generated image
+ prompt: Original prompt for metadata
+
+ Returns:
+ Public URL of the blob
+ """
+ try:
+ # Download image
+ response = requests.get(image_url, timeout=30)
+ response.raise_for_status()
+
+ # Generate unique blob name
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ blob_name = f"image_{timestamp}_{uuid.uuid4().hex[:8]}.png"
+
+ # Upload to blob storage
+ blob_client = self.blob_service_client.get_blob_client(
+ container=self.container_name,
+ blob=blob_name
+ )
+
+ blob_client.upload_blob(
+ response.content,
+ overwrite=True,
+ metadata={
+ "prompt": prompt[:500], # Limit metadata size
+ "generated_at": timestamp
+ }
+ )
+
+ # Generate SAS URL for public access (valid for 7 days)
+ sas_token = generate_blob_sas(
+ account_name=self.storage_account,
+ container_name=self.container_name,
+ blob_name=blob_name,
+ permission=BlobSasPermissions(read=True),
+ expiry=datetime.utcnow() + timedelta(days=7)
+ )
+
+ blob_url = f"{blob_client.url}?{sas_token}"
+ logger.info(f"Image uploaded to blob storage: {blob_name}")
+
+ return blob_url
+
+ except Exception as e:
+ logger.error(f"Error uploading image to blob: {e}", exc_info=True)
+ return None
+
+ def is_configured(self) -> bool:
+ """Check if image generation is properly configured."""
+ return self.client is not None and self.blob_service_client is not None
+
+
+# Global instance
+_image_service = None
+
+def get_image_service() -> ImageService:
+ """Get or create the global ImageService instance."""
+ global _image_service
+ if _image_service is None:
+ _image_service = ImageService()
+ return _image_service
diff --git a/terraform-infrastructure/README.md b/terraform-infrastructure/README.md
index 99a27c3..08df9a8 100644
--- a/terraform-infrastructure/README.md
+++ b/terraform-infrastructure/README.md
@@ -119,7 +119,7 @@ graph TD;
-

-
Refresh Date: 2025-11-25
+

+
Refresh Date: 2025-11-28
diff --git a/terraform-infrastructure/main.tf b/terraform-infrastructure/main.tf
index cb14493..0de3191 100644
--- a/terraform-infrastructure/main.tf
+++ b/terraform-infrastructure/main.tf
@@ -28,7 +28,19 @@ locals {
app_insights_name = "${var.name_prefix}-${local.suffix}-ai"
registry_name = lower(replace("${var.name_prefix}${local.suffix}cosureg", "-", ""))
web_app_name = "${var.name_prefix}-${local.suffix}-app"
+ key_vault_name = "${var.name_prefix}-${local.suffix}-kv"
cosmos_connection_auth_type = var.enable_cosmos_local_auth ? "AccountKey" : "AAD"
+ dockerfile_hash = filesha256("../src/Dockerfile")
+
+ # Hash of application source & templates to trigger container rebuild when logic/UI changes
+ # Combine Python files and HTML templates for source tracking
+ app_source_hash = sha256(join("", [
+ for f in concat(
+ [for py in fileset("../src", "**/*.py") : py],
+ ["app/templates/index.html"] # Explicitly include the HTML template
+ ) : fileexists("../src/${f}") ? filesha256("../src/${f}") : ""
+ ]))
+ product_catalog_hash = fileexists("../src/data/updated_product_catalog(in).csv") ? filesha256("../src/data/updated_product_catalog(in).csv") : "missing"
}
resource "azurerm_cosmosdb_account" "cosmos" {
@@ -124,6 +136,86 @@ resource "azapi_resource" "ai_project" {
depends_on = [azapi_resource.ai_foundry]
}
+# === Real Multi-Agent Creation (ochartarotr) ===
+# NOTE: Azure Agents API not yet available via ARM/Terraform (returns 500 Internal Server Error)
+# Keeping these commented for future use when the API becomes available
+# resource "azapi_resource" "cora_agent" {
+# type = "Microsoft.CognitiveServices/accounts/projects/agents@2025-06-01"
+# name = "cora-agent"
+# location = var.location
+# parent_id = azapi_resource.ai_project.id
+# schema_validation_enabled = false
+# body = jsonencode({
+# properties = {
+# displayName = "Cora - Zava Shopping Assistant"
+# description = "Domain expert for shopping assistance"
+# domain = "cora"
+# modelDeploymentName = "gpt-4o-mini"
+# }
+# })
+# depends_on = [azapi_resource.ai_project]
+# }# resource "azapi_resource" "interior_design_agent" {
+# type = "Microsoft.CognitiveServices/accounts/projects/agents@2025-06-01"
+# name = "interior-design-agent"
+# location = var.location
+# parent_id = azapi_resource.ai_project.id
+# schema_validation_enabled = false
+# body = jsonencode({
+# properties = {
+# displayName = "Interior Designer"
+# description = "Domain expert for interior design guidance"
+# domain = "interior_design"
+# modelDeploymentName = "gpt-4o-mini"
+# }
+# })
+# depends_on = [azapi_resource.ai_project]
+# }# resource "azapi_resource" "inventory_agent" {
+# type = "Microsoft.CognitiveServices/accounts/projects/agents@2025-06-01"
+# name = "inventory-agent"
+# location = var.location
+# parent_id = azapi_resource.ai_project.id
+# schema_validation_enabled = false
+# body = jsonencode({
+# properties = {
+# displayName = "Inventory Manager"
+# description = "Domain expert for inventory status"
+# domain = "inventory"
+# modelDeploymentName = "gpt-4o-mini"
+# }
+# })
+# depends_on = [azapi_resource.ai_project]
+# }# resource "azapi_resource" "customer_loyalty_agent" {
+# type = "Microsoft.CognitiveServices/accounts/projects/agents@2025-06-01"
+# name = "customer-loyalty-agent"
+# location = var.location
+# parent_id = azapi_resource.ai_project.id
+# schema_validation_enabled = false
+# body = jsonencode({
+# properties = {
+# displayName = "Customer Loyalty Specialist"
+# description = "Domain expert for loyalty and rewards"
+# domain = "customer_loyalty"
+# modelDeploymentName = "gpt-4o-mini"
+# }
+# })
+# depends_on = [azapi_resource.ai_project]
+# }# resource "azapi_resource" "cart_manager_agent" {
+# type = "Microsoft.CognitiveServices/accounts/projects/agents@2025-06-01"
+# name = "cart-manager-agent"
+# location = var.location
+# parent_id = azapi_resource.ai_project.id
+# schema_validation_enabled = false
+# body = jsonencode({
+# properties = {
+# displayName = "Cart Manager"
+# description = "Domain expert for cart management"
+# domain = "cart_management"
+# modelDeploymentName = "gpt-4o-mini"
+# }
+# })
+# depends_on = [azapi_resource.ai_project]
+# }
+
resource "azurerm_search_service" "search" {
name = local.search_service_name
resource_group_name = azurerm_resource_group.rg.name
@@ -190,6 +282,10 @@ resource "azurerm_linux_web_app" "app" {
service_plan_id = azurerm_service_plan.appserviceplan.id
https_only = true
+ identity {
+ type = "SystemAssigned"
+ }
+
site_config {
always_on = false
http2_enabled = true
@@ -199,9 +295,353 @@ resource "azurerm_linux_web_app" "app" {
app_settings = {
WEBSITES_ENABLE_APP_SERVICE_STORAGE = "false"
DOCKER_ENABLE_CI = "true"
+ WEBSITES_PORT = "8000"
+
+ # GPT Configuration (Key Vault referenced secrets)
+ gpt_endpoint = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/"
+ gpt_deployment = "gpt-4o-mini"
+ gpt_api_key = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/ai-foundry-key)"
+ gpt_api_version = "2024-12-01-preview"
+
+ # Azure AI Foundry Configuration
+ AZURE_AI_FOUNDRY_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/"
+ AZURE_AI_PROJECT_NAME = local.ai_project_name
+ AZURE_AI_PROJECT_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/"
+ AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME = "gpt-4o-mini"
+ AZURE_AI_FOUNDRY_API_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/ai-foundry-key)"
+
+ # Azure OpenAI Configuration
+ AZURE_OPENAI_CHAT_DEPLOYMENT = "gpt-4o-mini"
+ AZURE_OPENAI_EMBEDDING_DEPLOYMENT = "text-embedding-3-small"
+ AZURE_OPENAI_IMAGE_DEPLOYMENT = "dall-e-3"
+ AZURE_OPENAI_API_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/ai-foundry-key)"
+ AZURE_OPENAI_ENDPOINT = "https://${local.ai_foundry_name}.cognitiveservices.azure.com/"
+ AZURE_OPENAI_API_VERSION = "2024-02-01"
+
+ # External Service Keys via Key Vault
+ SEARCH_SERVICE_KEY = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/search-admin-key)"
+ COSMOS_DB_KEY = var.enable_cosmos_local_auth ? "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/cosmos-primary-key)" : "AAD_AUTH"
+ STORAGE_CONNECTION_STRING = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault.kv.vault_uri}secrets/storage-connection-string)"
+
+ # Multi-Agent Configuration (initial local simulation; real IDs override post-deploy)
+ USE_MULTI_AGENT = var.enable_multi_agent ? "true" : "false"
+ cora = "asst_local_cora"
+ interior_designer = "asst_local_interior_design"
+ inventory_agent = "asst_local_inventory"
+ customer_loyalty = "asst_local_customer_loyalty"
+ cart_manager = "asst_local_cart_manager"
+ CUSTOMER_ID = "CUST001"
}
- depends_on = [azurerm_container_registry.acr]
+ depends_on = [
+ azurerm_container_registry.acr,
+ null_resource.ai_model_deployments
+ ]
+}
+
+# Key Vault for central secret management
+resource "azurerm_key_vault" "kv" {
+ name = local.key_vault_name
+ location = azurerm_resource_group.rg.location
+ resource_group_name = azurerm_resource_group.rg.name
+ tenant_id = data.azurerm_client_config.current.tenant_id
+ sku_name = "standard"
+ soft_delete_retention_days = 7
+ purge_protection_enabled = false
+ enable_rbac_authorization = false
+
+ access_policy {
+ tenant_id = data.azurerm_client_config.current.tenant_id
+ object_id = local.principal_id
+ secret_permissions = ["Get", "List", "Set"]
+ }
+
+ tags = { purpose = "multi-agent-ai-secrets" }
+}
+
+# Data source to retrieve the web app identity after it's created/updated
+data "azurerm_linux_web_app" "app_identity" {
+ name = azurerm_linux_web_app.app.name
+ resource_group_name = azurerm_resource_group.rg.name
+ depends_on = [azurerm_linux_web_app.app]
+}
+
+# Access policy for Web App managed identity to read secrets
+resource "azurerm_key_vault_access_policy" "app_policy" {
+ key_vault_id = azurerm_key_vault.kv.id
+ tenant_id = data.azurerm_client_config.current.tenant_id
+ object_id = data.azurerm_linux_web_app.app_identity.identity[0].principal_id
+ secret_permissions = ["Get"]
+ depends_on = [azurerm_linux_web_app.app]
+}
+
+# Populate Key Vault secrets (AI Foundry key, Cosmos key, Search key, Storage connection)
+# Key Vault Secrets as Terraform resources (provides version for references)
+resource "azurerm_key_vault_secret" "ai_foundry_key" {
+ name = "ai-foundry-key"
+ value = jsondecode(data.azapi_resource_action.ai_foundry_keys[0].output).key1
+ key_vault_id = azurerm_key_vault.kv.id
+ depends_on = [azurerm_key_vault.kv]
+}
+
+resource "azurerm_key_vault_secret" "search_admin_key" {
+ name = "search-admin-key"
+ value = jsondecode(data.azapi_resource_action.search_admin_keys[0].output).primaryKey
+ key_vault_id = azurerm_key_vault.kv.id
+ depends_on = [azurerm_key_vault.kv]
+}
+
+resource "azurerm_key_vault_secret" "storage_connection_string" {
+ name = "storage-connection-string"
+ value = azapi_resource.storage.id != "" ? trimspace(chomp(join("", []))) : "placeholder" # placeholder; will be overridden below via provisioner
+ key_vault_id = azurerm_key_vault.kv.id
+ depends_on = [azurerm_key_vault.kv]
+ lifecycle { ignore_changes = [value] }
+}
+
+resource "null_resource" "update_storage_connection_secret" {
+ depends_on = [azurerm_key_vault_secret.storage_connection_string, azapi_resource.storage]
+ provisioner "local-exec" {
+ command = <<-EOT
+ Write-Host "Updating storage-connection-string secret value..."
+ $conn = az storage account show-connection-string --resource-group ${azurerm_resource_group.rg.name} --name ${local.storage_account} --query connectionString -o tsv
+ az keyvault secret set --vault-name ${azurerm_key_vault.kv.name} --name storage-connection-string --value $conn | Out-Null
+ Write-Host "ā storage-connection-string secret updated"
+ EOT
+ interpreter = ["PowerShell", "-Command"]
+ }
+}
+
+resource "azurerm_key_vault_secret" "cosmos_primary_key" {
+ count = var.enable_cosmos_local_auth ? 1 : 0
+ name = "cosmos-primary-key"
+ value = jsondecode(data.azapi_resource_action.cosmos_keys[0].output).primaryMasterKey
+ key_vault_id = azurerm_key_vault.kv.id
+ depends_on = [azurerm_key_vault.kv]
+}
+
+# External data source for agents state
+data "external" "agents_state" {
+ program = ["python", "read_agents_state.py"]
+ depends_on = [null_resource.deploy_multi_agents]
+}
+
+# App Service Plan autoscale
+resource "azurerm_monitor_autoscale_setting" "appservice_autoscale" {
+ name = "${var.name_prefix}-${local.suffix}-asp-autoscale"
+ resource_group_name = azurerm_resource_group.rg.name
+ location = var.location
+ target_resource_id = azurerm_service_plan.appserviceplan.id
+
+ profile {
+ name = "default"
+ capacity {
+ minimum = "1"
+ maximum = "3"
+ default = "1"
+ }
+
+ rule {
+ metric_trigger {
+ metric_name = "CpuPercentage"
+ metric_resource_id = azurerm_service_plan.appserviceplan.id
+ time_grain = "PT1M"
+ statistic = "Average"
+ time_window = "PT5M"
+ time_aggregation = "Average"
+ operator = "GreaterThan"
+ threshold = 70
+ }
+ scale_action {
+ direction = "Increase"
+ type = "ChangeCount"
+ value = "1"
+ cooldown = "PT5M"
+ }
+ }
+
+ rule {
+ metric_trigger {
+ metric_name = "CpuPercentage"
+ metric_resource_id = azurerm_service_plan.appserviceplan.id
+ time_grain = "PT1M"
+ statistic = "Average"
+ time_window = "PT10M"
+ time_aggregation = "Average"
+ operator = "LessThan"
+ threshold = 35
+ }
+ scale_action {
+ direction = "Decrease"
+ type = "ChangeCount"
+ value = "1"
+ cooldown = "PT10M"
+ }
+ }
+ }
+
+ notification {
+ email {
+ send_to_subscription_administrator = false
+ send_to_subscription_co_administrator = false
+ custom_emails = []
+ }
+ }
+
+ depends_on = [azurerm_service_plan.appserviceplan]
+}
+
+# Alerts: App Service 5xx & CPU, Cosmos 429 throttles
+resource "azurerm_monitor_metric_alert" "app_5xx" {
+ name = "${var.name_prefix}-${local.suffix}-app-5xx-alert"
+ resource_group_name = azurerm_resource_group.rg.name
+ scopes = [azurerm_linux_web_app.app.id]
+ description = "Alert on high 5xx responses"
+ severity = 2
+ frequency = "PT5M"
+ window_size = "PT5M"
+ criteria {
+ metric_namespace = "Microsoft.Web/sites"
+ metric_name = "Http5xx"
+ aggregation = "Total"
+ operator = "GreaterThan"
+ threshold = 20
+ }
+}
+
+resource "azurerm_monitor_metric_alert" "app_cpu" {
+ name = "${var.name_prefix}-${local.suffix}-app-cpu-alert"
+ resource_group_name = azurerm_resource_group.rg.name
+ scopes = [azurerm_service_plan.appserviceplan.id]
+ description = "Alert on high CPU"
+ severity = 3
+ frequency = "PT5M"
+ window_size = "PT5M"
+ criteria {
+ metric_namespace = "Microsoft.Web/serverfarms"
+ metric_name = "CpuPercentage"
+ aggregation = "Average"
+ operator = "GreaterThan"
+ threshold = 80
+ }
+}
+
+resource "azurerm_monitor_metric_alert" "cosmos_throttle" {
+ name = "${var.name_prefix}-${local.suffix}-cosmos-429-alert"
+ resource_group_name = azurerm_resource_group.rg.name
+ scopes = [azurerm_cosmosdb_account.cosmos.id]
+ description = "Alert on Cosmos DB throttled requests"
+ severity = 3
+ frequency = "PT5M"
+ window_size = "PT5M"
+ criteria {
+ metric_namespace = "Microsoft.DocumentDB/databaseAccounts"
+ metric_name = "TotalRequests"
+ aggregation = "Count"
+ operator = "GreaterThan"
+ threshold = 1000
+ }
+}
+
+# Portal Dashboard aggregating key metrics
+resource "azurerm_portal_dashboard" "observability" {
+ name = "${var.name_prefix}-${local.suffix}-dashboard"
+ resource_group_name = azurerm_resource_group.rg.name
+ location = var.location
+ tags = { purpose = "multi-agent-observability" }
+
+ dashboard_properties = jsonencode({
+ lenses = {
+ "0" = {
+ order = 0
+ parts = {
+ "0" = {
+ position = { x = 0, y = 0, width = 6, height = 4 }
+ metadata = {
+ inputs = [
+ { name = "resourceType", value = "microsoft.web/sites" },
+ { name = "resource", value = azurerm_linux_web_app.app.id },
+ { name = "chartSettings", value = jsonencode({ version = "Workspace" }) }
+ ]
+ type = "Extension/HubsExtension/PartType/MonitorChartPart"
+ settings = {
+ content = {
+ version = "1.0.0"
+ chart = {
+ title = "App Service Requests"
+ metrics = [{ resourceMetadata = { id = azurerm_linux_web_app.app.id }, name = "Requests", aggregationType = "Total" }]
+ timespan = { duration = "PT1H" }
+ visualization = { chartType = "Line" }
+ }
+ }
+ }
+ }
+ },
+ "1" = {
+ position = { x = 6, y = 0, width = 6, height = 4 }
+ metadata = {
+ inputs = [
+ { name = "resourceType", value = "microsoft.web/serverfarms" },
+ { name = "resource", value = azurerm_service_plan.appserviceplan.id }
+ ]
+ type = "Extension/HubsExtension/PartType/MonitorChartPart"
+ settings = {
+ content = {
+ version = "1.0.0"
+ chart = {
+ title = "CPU Percentage"
+ metrics = [{ resourceMetadata = { id = azurerm_service_plan.appserviceplan.id }, name = "CpuPercentage", aggregationType = "Average" }]
+ timespan = { duration = "PT1H" }
+ }
+ }
+ }
+ }
+ },
+ "2" = {
+ position = { x = 0, y = 4, width = 6, height = 4 }
+ metadata = {
+ inputs = [
+ { name = "resourceType", value = "microsoft.documentdb/databaseAccounts" },
+ { name = "resource", value = azurerm_cosmosdb_account.cosmos.id }
+ ]
+ type = "Extension/HubsExtension/PartType/MonitorChartPart"
+ settings = {
+ content = {
+ version = "1.0.0"
+ chart = {
+ title = "Cosmos Total Requests"
+ metrics = [{ resourceMetadata = { id = azurerm_cosmosdb_account.cosmos.id }, name = "TotalRequests", aggregationType = "Total" }]
+ timespan = { duration = "PT1H" }
+ }
+ }
+ }
+ }
+ },
+ "3" = {
+ position = { x = 6, y = 4, width = 6, height = 4 }
+ metadata = {
+ inputs = [
+ { name = "resourceType", value = "microsoft.insights/components" },
+ { name = "resource", value = azurerm_application_insights.appinsights.id }
+ ]
+ type = "Extension/HubsExtension/PartType/MonitorChartPart"
+ settings = {
+ content = {
+ version = "1.0.0"
+ chart = {
+ title = "App Insights Server Response Time"
+ metrics = [{ resourceMetadata = { id = azurerm_application_insights.appinsights.id }, name = "requests/duration", aggregationType = "Average" }]
+ timespan = { duration = "PT1H" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ metadata = { model = "PortalDashboard" }
+ })
}
# Cosmos DB SQL Role Assignments (data plane) using AzAPI
@@ -350,7 +790,34 @@ resource "null_resource" "ai_model_deployments" {
Write-Host "text-embedding-3-small deployment created successfully"
} else {
Write-Host "text-embedding-3-small deployment may already exist or failed to create"
- } # Create phi-4 deployment
+ }
+
+ # Create dall-e-3 deployment for image generation
+ Write-Host "Creating dall-e-3 deployment..."
+ try {
+ az cognitiveservices account deployment create `
+ --resource-group "${azurerm_resource_group.rg.name}" `
+ --name "${local.ai_foundry_name}" `
+ --deployment-name "dall-e-3" `
+ --model-name "dall-e-3" `
+ --model-version "3.0" `
+ --model-format "OpenAI" `
+ --sku-capacity 1 `
+ --sku-name "Standard"
+
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "dall-e-3 deployment created successfully"
+ $dalleAvailable = $true
+ } else {
+ Write-Host "dall-e-3 model not available in this region/tier, skipping"
+ $dalleAvailable = $false
+ }
+ } catch {
+ Write-Host "dall-e-3 model not supported in this region, skipping"
+ $dalleAvailable = $false
+ }
+
+ # Create phi-4 deployment
Write-Host "Creating phi-4 deployment..."
try {
az cognitiveservices account deployment create `
@@ -430,6 +897,17 @@ data "azapi_resource_action" "cosmos_keys" {
depends_on = [azurerm_cosmosdb_account.cosmos]
}
+# Get AI Foundry keys for Web App configuration
+data "azapi_resource_action" "ai_foundry_keys" {
+ count = var.enable_ai_automation ? 1 : 0
+ type = "Microsoft.CognitiveServices/accounts@2024-10-01"
+ resource_id = azapi_resource.ai_foundry.id
+ action = "listKeys"
+ response_export_values = ["key1"]
+ body = jsonencode({})
+ depends_on = [azapi_resource.ai_foundry]
+}
+
# Connect resources to Azure AI Foundry project using ARM templates
resource "azapi_resource" "storage_connection" {
count = var.enable_ai_automation ? 1 : 0
@@ -628,33 +1106,14 @@ resource "null_resource" "create_env_file" {
--query "properties.endpoint" `
--output tsv
- # Get Azure AI Foundry access key
- $aiFoundryKey = az cognitiveservices account keys list `
- --resource-group "${azurerm_resource_group.rg.name}" `
- --name "${local.ai_foundry_name}" `
- --query "key1" `
- --output tsv
-
- # Get Cosmos DB primary key
- $cosmosKey = az cosmosdb keys list `
- --resource-group "${azurerm_resource_group.rg.name}" `
- --name "${local.cosmos_account_name}" `
- --query "primaryMasterKey" `
- --output tsv
-
- # Get Azure Search admin key
- $searchKey = az search admin-key show `
- --resource-group "${azurerm_resource_group.rg.name}" `
- --service-name "${local.search_service_name}" `
- --query "primaryKey" `
- --output tsv
-
- # Get storage account connection string
- $storageConnectionString = az storage account show-connection-string `
- --resource-group "${azurerm_resource_group.rg.name}" `
- --name "${local.storage_account}" `
- --query "connectionString" `
- --output tsv
+ # Fetch secrets from Key Vault for local dev (avoid embedding in Terraform state)
+ $kv = "${azurerm_key_vault.kv.name}"
+ $aiFoundryKey = az keyvault secret show --vault-name $kv --name ai-foundry-key --query value -o tsv
+ $searchKey = az keyvault secret show --vault-name $kv --name search-admin-key --query value -o tsv
+ if (${var.enable_cosmos_local_auth}) {
+ $cosmosKey = az keyvault secret show --vault-name $kv --name cosmos-primary-key --query value -o tsv
+ } else { $cosmosKey = "AAD_AUTH" }
+ $storageConnectionString = az keyvault secret show --vault-name $kv --name storage-connection-string --query value -o tsv
# Create .env file content
if ($phi4Available) {
@@ -663,11 +1122,13 @@ resource "null_resource" "create_env_file" {
AZURE_AI_FOUNDRY_ENDPOINT=$aiFoundryEndpoint
AZURE_AI_FOUNDRY_API_KEY=$aiFoundryKey
AZURE_AI_PROJECT_NAME=${local.ai_project_name}
+AZURE_AI_AGENT_ENDPOINT=$aiFoundryEndpoint
# Azure OpenAI Model Deployments
AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o-mini
AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
AZURE_OPENAI_PHI_DEPLOYMENT=phi-4
+AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3
AZURE_OPENAI_ENDPOINT=$aiFoundryEndpoint
AZURE_OPENAI_API_KEY=$aiFoundryKey
AZURE_OPENAI_API_VERSION=2024-02-01
@@ -702,6 +1163,21 @@ APPLICATION_INSIGHTS_CONNECTION_STRING=${azurerm_application_insights.appinsight
AZURE_SUBSCRIPTION_ID=${data.azurerm_client_config.current.subscription_id}
AZURE_RESOURCE_GROUP=${azurerm_resource_group.rg.name}
AZURE_LOCATION=${var.location}
+
+# Multi-Agent Configuration
+USE_MULTI_AGENT=true
+AZURE_AI_PROJECT_ENDPOINT=$aiFoundryEndpoint
+AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4o-mini
+
+# Local Pseudo Agent IDs (no remote provisioning required)
+cora=asst_local_cora
+interior_designer=asst_local_interior_design
+inventory_agent=asst_local_inventory
+customer_loyalty=asst_local_customer_loyalty
+cart_manager=asst_local_cart_manager
+
+# Customer Configuration
+CUSTOMER_ID=CUST001
"@
} else {
$envContent = @"
@@ -709,10 +1185,12 @@ AZURE_LOCATION=${var.location}
AZURE_AI_FOUNDRY_ENDPOINT=$aiFoundryEndpoint
AZURE_AI_FOUNDRY_API_KEY=$aiFoundryKey
AZURE_AI_PROJECT_NAME=${local.ai_project_name}
+AZURE_AI_AGENT_ENDPOINT=$aiFoundryEndpoint
# Azure OpenAI Model Deployments
AZURE_OPENAI_CHAT_DEPLOYMENT=gpt-4o-mini
AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
+AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3
AZURE_OPENAI_ENDPOINT=$aiFoundryEndpoint
AZURE_OPENAI_API_KEY=$aiFoundryKey
AZURE_OPENAI_API_VERSION=2024-02-01
@@ -747,6 +1225,21 @@ APPLICATION_INSIGHTS_CONNECTION_STRING=${azurerm_application_insights.appinsight
AZURE_SUBSCRIPTION_ID=${data.azurerm_client_config.current.subscription_id}
AZURE_RESOURCE_GROUP=${azurerm_resource_group.rg.name}
AZURE_LOCATION=${var.location}
+
+# Multi-Agent Configuration
+USE_MULTI_AGENT=true
+AZURE_AI_PROJECT_ENDPOINT=$aiFoundryEndpoint
+AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=gpt-4o-mini
+
+# Local Pseudo Agent IDs (no remote provisioning required)
+cora=asst_local_cora
+interior_designer=asst_local_interior_design
+inventory_agent=asst_local_inventory
+customer_loyalty=asst_local_customer_loyalty
+cart_manager=asst_local_cart_manager
+
+# Customer Configuration
+CUSTOMER_ID=CUST001
"@
}
@@ -888,6 +1381,32 @@ resource "null_resource" "data_pipeline" {
}
}
+# Vector index update automation (stub - triggers when product catalog changes)
+resource "null_resource" "vector_index_update" {
+ count = var.enable_data_pipeline ? 1 : 0
+
+ depends_on = [null_resource.data_pipeline]
+
+ provisioner "local-exec" {
+ command = <<-EOT
+ Write-Host "Triggering vector index update if catalog changed..."
+ $pythonCmd = "python"
+ if (Get-Command python3 -ErrorAction SilentlyContinue) { $pythonCmd = "python3" }
+ $script = Join-Path (Split-Path $PWD.Path -Parent) "src\pipelines\update_vector_index.py"
+ if (Test-Path $script) {
+ & $pythonCmd $script
+ } else {
+ Write-Host "Vector update script not found: $script"
+ }
+ EOT
+ interpreter = ["PowerShell", "-Command"]
+ }
+
+ triggers = {
+ catalog_hash = local.product_catalog_hash
+ }
+}
+
# Single-Agent Application Verification - Verifies the chat application is ready
resource "null_resource" "verify_single_agent_app" {
count = var.enable_data_pipeline ? 1 : 0
@@ -949,6 +1468,190 @@ resource "null_resource" "verify_single_agent_app" {
}
}
+# Multi-Agent Deployment - Create real agents in Microsoft Foundry
+resource "null_resource" "deploy_multi_agents" {
+ count = var.enable_multi_agent ? 1 : 0
+
+ depends_on = [
+ null_resource.create_env_file,
+ null_resource.ai_model_deployments,
+ azapi_resource.ai_project
+ ]
+
+ provisioner "local-exec" {
+ command = <<-EOT
+ Write-Host ""
+ Write-Host "=== Creating Real Agents in Microsoft Foundry ==="
+ Write-Host ""
+
+ # Ensure Python environment is ready
+ $pythonCmd = "python"
+ if (Get-Command python3 -ErrorAction SilentlyContinue) {
+ $pythonCmd = "python3"
+ }
+
+ Write-Host "Installing required Azure SDK packages..."
+ & $pythonCmd -m pip install -q azure-ai-projects azure-identity python-dotenv
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "ā Failed to install required packages"
+ Write-Host "Falling back to local pseudo-agents..."
+ exit 0
+ }
+
+ Write-Host "ā SDK packages installed"
+ Write-Host ""
+
+ # Set up environment for agent deployment
+ $env:AZURE_AI_PROJECT_ENDPOINT = "${azapi_resource.ai_foundry.output}" | ConvertFrom-Json | Select-Object -ExpandProperty properties | Select-Object -ExpandProperty endpoint
+
+ # Deploy agents using Python script
+ Write-Host "Deploying 5 agents to Azure AI Foundry..."
+ $agentScriptPath = Join-Path (Split-Path $PWD.Path -Parent) "src\app\agents\deploy_real_agents.py"
+
+ if (!(Test-Path $agentScriptPath)) {
+ Write-Host "ā Agent deployment script not found: $agentScriptPath"
+ Write-Host "Falling back to local pseudo-agents..."
+ exit 0
+ }
+
+ # Run the deployment script
+ & $pythonCmd $agentScriptPath
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "ā ļø Agent deployment script reported errors, but continuing..."
+ Write-Host "Check if agents were partially created in Foundry portal"
+ } else {
+ Write-Host ""
+ Write-Host "ā
Real agents successfully created in Microsoft Foundry!"
+ Write-Host ""
+ Write-Host "View your agents at:"
+ Write-Host " https://ai.azure.com/build/agents?wsid=/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${azurerm_resource_group.rg.name}/providers/Microsoft.CognitiveServices/accounts/${local.ai_foundry_name}"
+
+ # Propagate real agent IDs from .env into Web App app settings (override local pseudo IDs)
+ $envPath = Join-Path (Split-Path $PWD.Path -Parent) "src/.env"
+ if (Test-Path $envPath) {
+ Write-Host "Updating Web App app settings with real agent IDs..."
+ $agentVars = @("cora","interior_designer","inventory_agent","customer_loyalty","cart_manager")
+ $settingsArgs = @()
+ foreach ($var in $agentVars) {
+ $line = Select-String -Path $envPath -Pattern "^$var=" -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($line) {
+ $value = $line.Line.Split("=",2)[1]
+ if ($value -and $value -notlike "asst_local*") {
+ Write-Host " $var => $value"
+ $settingsArgs += "$var=$value"
+ }
+ }
+ }
+ if ($settingsArgs.Count -gt 0) {
+ # Ensure image deployment variable present
+ $settingsArgs += "AZURE_OPENAI_IMAGE_DEPLOYMENT=dall-e-3"
+ az webapp config appsettings set `
+ --resource-group ${azurerm_resource_group.rg.name} `
+ --name ${local.web_app_name} `
+ --settings $settingsArgs | Out-Null
+ Write-Host "ā Web App app settings updated with real agent IDs"
+ Write-Host "Restarting Web App to apply settings..."
+ az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null
+ Write-Host "ā Web App restarted"
+ } else {
+ Write-Host "No real agent IDs found to update (still using local simulation)."
+ }
+ } else {
+ Write-Host "Could not find .env file to propagate agent IDs."
+ }
+ }
+
+ Write-Host ""
+ Write-Host "Triggering container rebuild with agent configuration..."
+ cd ..
+ $srcPath = "src"
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+ $env:PYTHONIOENCODING = "utf-8"
+ az acr build `
+ --resource-group ${azurerm_resource_group.rg.name} `
+ --registry ${local.registry_name} `
+ --image zava-chat-app:latest `
+ --file "$srcPath\Dockerfile" `
+ "$srcPath" 2>&1 | Select-String -Pattern "Successfully|Step|digest:|Run ID" | ForEach-Object { Write-Host $_.Line }
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "ā Container build completed"
+ Write-Host "Restarting Web App..."
+ az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null
+ Write-Host "ā Web App restarted"
+ } else {
+ Write-Host "ā Container build reported non-zero exit; check Azure Portal for details."
+ }
+ Write-Host ""
+ Write-Host "Multi-agent deployment complete!"
+ EOT
+ interpreter = ["PowerShell", "-Command"]
+ working_dir = path.module
+ }
+
+ triggers = {
+ ai_project_id = azapi_resource.ai_project.id
+ env_file_id = null_resource.create_env_file[0].id
+ docker_hash = local.dockerfile_hash
+ app_hash = local.app_source_hash
+ }
+}
+
+# Post-provision verification of real agents (ensures >=5 non-local agents)
+resource "null_resource" "verify_real_agents" {
+ count = var.enable_multi_agent ? 1 : 0
+
+ depends_on = [
+ null_resource.deploy_multi_agents
+ ]
+
+ provisioner "local-exec" {
+ command = <<-EOT
+ Write-Host ""; Write-Host "=== Verifying Real Agent Provisioning (Post-Deploy) ==="; Write-Host ""
+ $pythonCmd = "python"
+ if (Get-Command python3 -ErrorAction SilentlyContinue) { $pythonCmd = "python3" }
+ $verifyScript = Join-Path (Split-Path $PWD.Path -Parent) "src\app\agents\verify_agents.py"
+ if (!(Test-Path $verifyScript)) {
+ Write-Host "ā verify_agents.py not found, skipping detailed verification"
+ exit 0
+ }
+ & $pythonCmd $verifyScript | Out-String | Write-Host
+ # Parse .env for agent IDs and count real ones
+ $envPath = Join-Path (Split-Path $PWD.Path -Parent) "src/.env"
+ if (Test-Path $envPath) {
+ $content = Get-Content $envPath -Raw
+ $agentVars = @("cora","interior_designer","inventory_agent","customer_loyalty","cart_manager")
+ $realCount = 0
+ foreach ($v in $agentVars) {
+ $m = [regex]::Match($content, "^$v=(.+)$", 'Multiline')
+ if ($m.Success) {
+ $id = $m.Groups[1].Value.Trim()
+ if ($id -and ($id -notlike "asst_local_*")) { $realCount++ }
+ }
+ }
+ Write-Host "Real agent count: $realCount"
+ if ($realCount -ge 5) {
+ Write-Host "ā
Verification passed: $realCount real agents present."
+ } else {
+ Write-Host "ā Expected 5 real agents; found $realCount. Keeping apply successful but marking warning."
+ $logPath = "../real_agent_warnings.log"
+ "[$(Get-Date -Format o)] WARNING: Only $realCount real agents provisioned." | Out-File -FilePath $logPath -Append -Encoding utf8
+ Write-Host "Logged warning to $logPath"
+ }
+ } else {
+ Write-Host "ā .env file missing; cannot verify agent IDs."
+ }
+ Write-Host "=== Real Agent Verification Complete ==="; Write-Host ""
+ EOT
+ interpreter = ["PowerShell", "-Command"]
+ }
+
+ triggers = {
+ deploy_agents_id = null_resource.deploy_multi_agents[0].id
+ }
+}
+
# Install Docker Desktop if not present and deploy chat app using containers
resource "null_resource" "deploy_chat_app" {
count = var.enable_data_pipeline ? 1 : 0
@@ -965,6 +1668,18 @@ resource "null_resource" "deploy_chat_app" {
Write-Host "=== Deploying Chat Application to Azure Web App ==="
Write-Host ""
+ # Check if multi-agent mode is enabled
+ $multiAgentEnabled = "${var.enable_multi_agent}"
+
+ if ($multiAgentEnabled -eq "true") {
+ Write-Host "ā¹ļø Multi-agent mode enabled - deployment handled by deploy_multi_agents resource"
+ Write-Host "Skipping duplicate ACR build and deployment"
+ Write-Host ""
+ Write-Host "Your chat application is available at:"
+ Write-Host "https://${local.web_app_name}.azurewebsites.net"
+ exit 0
+ }
+
# Build container directly in ACR (no local Docker needed)
Write-Host "Building chat application container in Azure Container Registry..."
Write-Host "This includes the Azure AI Foundry SDK with corrected endpoint configuration"
@@ -1117,7 +1832,63 @@ resource "null_resource" "deploy_chat_app" {
triggers = {
verify_app_id = null_resource.verify_single_agent_app[0].id
- always_run = timestamp()
+ docker_hash = local.dockerfile_hash
+ source_hash = local.app_source_hash
+ }
+}
+
+# Remote multi-agent verification (runs after deployment). Hits /agents endpoint.
+resource "null_resource" "verify_multi_agent_remote" {
+ count = var.enable_multi_agent ? 1 : 0
+
+ depends_on = [
+ null_resource.deploy_multi_agents,
+ azurerm_linux_web_app.app
+ ]
+
+ provisioner "local-exec" {
+ command = <<-EOT
+ Write-Host ""; Write-Host "=== Verifying Multi-Agent Deployment (Remote) ==="; Write-Host ""
+ $appUrl = "https://${local.web_app_name}.azurewebsites.net"
+ $agentsEndpoint = "$appUrl/agents"
+ Write-Host "Checking agents endpoint: $agentsEndpoint"
+ $verificationPassed = $false
+ try {
+ $resp = Invoke-RestMethod -Uri $agentsEndpoint -Method GET -TimeoutSec 30
+ Write-Host "Response:" ($resp | ConvertTo-Json -Depth 5)
+ if ($resp.mode -eq 'multi-agent' -and $resp.all_present -and ($resp.agents.cora -like 'asst_local_*')) {
+ Write-Host "ā Multi-agent remote verification passed (local simulation active)."
+ $verificationPassed = $true
+ } else {
+ Write-Warning "Multi-agent verification incomplete."
+ Write-Host ($resp | ConvertTo-Json -Depth 5)
+ }
+ } catch {
+ Write-Warning "Could not reach /agents endpoint: $_"
+ }
+
+ if (-not $verificationPassed) {
+ Write-Host ""; Write-Host "ā ALERT: Multi-agent verification failed. Initiating App Service restart."; Write-Host ""
+ try {
+ az webapp restart --resource-group ${azurerm_resource_group.rg.name} --name ${local.web_app_name} | Out-Null
+ Write-Host "ā Web App restart triggered due to verification failure."
+ } catch {
+ Write-Warning "Failed to restart Web App automatically: $_"
+ }
+ $alertMessage = "[$(Get-Date -Format o)] Multi-agent verification failed for ${local.web_app_name}."
+ $alertPath = "../multi_agent_alerts.log"
+ $alertMessage | Out-File -FilePath $alertPath -Encoding utf8 -Append
+ Write-Host "Alert logged to $alertPath"
+ }
+ Write-Host "=== Verification Complete ==="; Write-Host ""
+ EOT
+ interpreter = ["PowerShell", "-Command"]
+ }
+
+ triggers = {
+ web_app_id = azurerm_linux_web_app.app.id
+ docker_hash = local.dockerfile_hash
+ agents_code = filesha256("../src/chat_app_multi_agent.py")
}
}
diff --git a/terraform-infrastructure/outputs.tf b/terraform-infrastructure/outputs.tf
index 44d66ec..c376bec 100644
--- a/terraform-infrastructure/outputs.tf
+++ b/terraform-infrastructure/outputs.tf
@@ -64,6 +64,56 @@ output "ai_foundry_endpoint" {
description = "Azure AI Foundry endpoint URL"
}
+# Real agent IDs & statuses (external data source from agents_state.json)
+output "agent_ids" {
+ value = {
+ for k, v in data.external.agents_state.result :
+ k => v if length(regexall("_id$", k)) > 0
+ }
+ description = "Map of agent environment variable names to their resolved IDs"
+}
+
+output "agent_statuses" {
+ value = {
+ for k, v in data.external.agents_state.result :
+ k => v if length(regexall("_status$", k)) > 0
+ }
+ description = "Map of agent environment variable names to provisioning statuses (created/existing/updated/etc.)"
+}
+
+output "key_vault_name" {
+ value = azurerm_key_vault.kv.name
+ description = "Name of the Key Vault used for secret storage"
+}
+
+output "key_vault_uri" {
+ value = azurerm_key_vault.kv.vault_uri
+ description = "Base URI of the Key Vault"
+}
+
+# === Real Agent Outputs (ochartarotr) ===
+# NOTE: Commented out - Azure Agents API not yet available via ARM/Terraform
+# output "cora_agent_id" {
+# value = azapi_resource.cora_agent.id
+# description = "Cora agent resource ID"
+# }
+# output "interior_design_agent_id" {
+# value = azapi_resource.interior_design_agent.id
+# description = "Interior Designer agent resource ID"
+# }
+# output "inventory_agent_id" {
+# value = azapi_resource.inventory_agent.id
+# description = "Inventory Manager agent resource ID"
+# }
+# output "customer_loyalty_agent_id" {
+# value = azapi_resource.customer_loyalty_agent.id
+# description = "Customer Loyalty agent resource ID"
+# }
+# output "cart_manager_agent_id" {
+# value = azapi_resource.cart_manager_agent.id
+# description = "Cart Manager agent resource ID"
+# }
+
output "deployed_models" {
value = var.enable_ai_automation ? [
"gpt-4o-mini",
diff --git a/terraform-infrastructure/read_agents_state.py b/terraform-infrastructure/read_agents_state.py
new file mode 100644
index 0000000..72dd777
--- /dev/null
+++ b/terraform-infrastructure/read_agents_state.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+import json, os, sys
+
+# Terraform external data source requires a flat map of strings.
+# We will flatten the agents_state.json into keys like "agent_NAME_id" and "agent_NAME_status".
+
+state_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'app', 'agents', 'agents_state.json')
+result = {}
+
+try:
+ if os.path.exists(state_path):
+ with open(state_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ # Flatten the data
+ for agent_name, agent_data in data.items():
+ # Ensure values are strings
+ result[f"agent_{agent_name}_id"] = str(agent_data.get('id', ''))
+ result[f"agent_{agent_name}_status"] = str(agent_data.get('status', ''))
+
+ else:
+ result["status"] = "state_file_missing"
+
+except Exception as e:
+ result['error'] = str(e)
+
+# Ensure we output valid JSON
+print(json.dumps(result))
\ No newline at end of file
diff --git a/terraform-infrastructure/variables.tf b/terraform-infrastructure/variables.tf
index f69a72e..8ab5c47 100644
--- a/terraform-infrastructure/variables.tf
+++ b/terraform-infrastructure/variables.tf
@@ -39,3 +39,9 @@ variable "enable_data_pipeline" {
default = true
}
+variable "enable_multi_agent" {
+ type = bool
+ description = "Whether to deploy multi-agent architecture in Microsoft Foundry"
+ default = true
+}
+