diff --git a/.image-version b/.image-version new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/.image-version @@ -0,0 +1 @@ +1.0.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..397dc1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN useradd -m -u 1001 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +CMD ["uvicorn", "app.agent:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agent.py b/app/agent.py new file mode 100644 index 0000000..fedde62 --- /dev/null +++ b/app/agent.py @@ -0,0 +1,148 @@ +""" +AutoParseEmailContentAndExtrac Agent — auto-generated by Agent Factory. +""" +import asyncio +import logging +import os + +import click +import httpx +import uvicorn +from contextlib import asynccontextmanager +from dotenv import load_dotenv +from langchain_core.rate_limiters import InMemoryRateLimiter +from langchain_openai import AzureChatOpenAI + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from app.config import ( + AGENT_SELF_URL, + AZURE_OPENAI_API_KEY, + AZURE_OPENAI_API_VERSION, + AZURE_OPENAI_DEPLOYMENT, + AZURE_OPENAI_ENDPOINT, + LOG_LEVEL, + REGISTRY_URL, +) +from app.skills import AGENT_CONFIG, AUTO_PARSE_EMAIL_CONTENT_AND_EXTRAC_SKILLS +from app.workflows.auto_parse_email_content_and_extrac_workflow import create_auto_parse_email_content_and_extrac_workflow + +load_dotenv() + +# ── Logging ────────────────────────────────────────────────────────────── +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s %(name)s %(levelname)s %(message)s", +) +logger = logging.getLogger(__name__) + +# ── LLM ────────────────────────────────────────────────────────────────── +rate_limiter = InMemoryRateLimiter( + requests_per_second=10 / 60, + check_every_n_seconds=0.1, + max_bucket_size=10, +) + +llm = AzureChatOpenAI( + temperature=0, + azure_deployment=AZURE_OPENAI_DEPLOYMENT, + api_version=AZURE_OPENAI_API_VERSION, + azure_endpoint=AZURE_OPENAI_ENDPOINT or "", + api_key=AZURE_OPENAI_API_KEY or "", + max_retries=5, + timeout=120, + rate_limiter=rate_limiter, +) + +workflow = create_auto_parse_email_content_and_extrac_workflow(llm) + + +# ── Endpoints ──────────────────────────────────────────────────────────── + +async def health_check(request: Request) -> JSONResponse: + return JSONResponse({"status": "healthy", "agent": 'AutoParseEmailContentAndExtrac'}) + + +async def agent_manifest(request: Request) -> JSONResponse: + """GET /.well-known/agent.json""" + return JSONResponse({ + "name": AGENT_CONFIG["name"], + "version": AGENT_CONFIG["version"], + "description": AGENT_CONFIG["description"], + "url": "/", + "skills": [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "tags": s.tags, + "inputSchema": AGENT_CONFIG.get("input_schema"), + "outputSchema": AGENT_CONFIG.get("output_schema"), + } for s in AUTO_PARSE_EMAIL_CONTENT_AND_EXTRAC_SKILLS + ], + "capabilities": AGENT_CONFIG["capabilities"], + }) + + +async def process_endpoint(request: Request) -> JSONResponse: + """POST /process — run the agent workflow.""" + try: + body = await request.json() + result = await workflow.ainvoke(body) + return JSONResponse(result) + except Exception as exc: + logger.error("Processing failed: %s", exc, exc_info=True) + return JSONResponse({"error": str(exc)}, status_code=500) + + +# ── Self-registration ──────────────────────────────────────────────────── + +async def _register_with_registry(): + if not REGISTRY_URL: + logger.info("REGISTRY_URL not set — skipping self-registration") + return + await asyncio.sleep(2) + url = f"{REGISTRY_URL.rstrip('/')}/agents/register-url" + for attempt in range(3): + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(url, json={"endpoint": AGENT_SELF_URL}) + if resp.status_code in (200, 201): + logger.info("Self-registered with agent-registry at %s", REGISTRY_URL) + return + logger.warning("Registration attempt %d: HTTP %d", attempt + 1, resp.status_code) + except Exception as exc: + logger.warning("Registration attempt %d failed: %s", attempt + 1, exc) + await asyncio.sleep(5) + logger.error("Failed to self-register after 3 attempts") + + +@asynccontextmanager +async def lifespan(app): + task = asyncio.create_task(_register_with_registry()) + yield + task.cancel() + + +app = Starlette( + routes=[ + Route("/health", methods=["GET"], endpoint=health_check), + Route("/.well-known/agent.json", methods=["GET"], endpoint=agent_manifest), + Route("/process", methods=["POST"], endpoint=process_endpoint), + ], + lifespan=lifespan, +) + + +@click.command() +@click.option("--host", default="0.0.0.0") +@click.option("--port", default=8080, type=int) +def main(host: str, port: int): + uvicorn.run(app, host=host, port=port, log_level=LOG_LEVEL.lower()) + + +if __name__ == "__main__": + main() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5a8212f --- /dev/null +++ b/app/config.py @@ -0,0 +1,21 @@ +""" +Configuration for the AutoParseEmailContentAndExtrac agent. +""" +import os + +# ── Azure OpenAI ───────────────────────────────────────────────────────── +AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT") +AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY") +AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") +AZURE_OPENAI_DEPLOYMENT = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o") + +# ── Agent Registry ─────────────────────────────────────────────────────── +REGISTRY_URL = os.getenv( + "REGISTRY_URL", "http://agent-gateway.agents.svc.cluster.local" +) +AGENT_SELF_URL = os.getenv( + "AGENT_SELF_URL", "http://auto-parse-email-content-and-extrac.agents.svc.cluster.local" +) + +# ── Logging ────────────────────────────────────────────────────────────── +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") diff --git a/app/nodes/__init__.py b/app/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/nodes/core_node.py b/app/nodes/core_node.py new file mode 100644 index 0000000..dd2fccd --- /dev/null +++ b/app/nodes/core_node.py @@ -0,0 +1,41 @@ +""" +This module defines a LangGraph node function that parses raw email content +and extracts the sender's email address, the email subject, and the email body. +The function uses an LLM to perform the extraction and returns the parsed +information in a structured format. +""" + +async def process(state: dict) -> dict: + from app.agent import llm + from langchain_core.messages import SystemMessage, HumanMessage + + try: + email_raw = state.get("email_raw", "") + if not email_raw: + return {"error": "Missing 'email_raw' input", "phase": "failed"} + + messages = [ + SystemMessage(content="You are an expert in parsing email content. Extract the sender's email address, the subject, and the body from the provided raw email text."), + HumanMessage(content=email_raw), + ] + + response = await llm.ainvoke(messages) + # Assuming the LLM returns a structured response in the format: + # "Sender: \nSubject: \nBody: " + try: + lines = response.content.split("\n") + sender_email = next((line.split(": ", 1)[1] for line in lines if line.startswith("Sender:")), "").strip() + email_subject = next((line.split(": ", 1)[1] for line in lines if line.startswith("Subject:")), "").strip() + email_body = next((line.split(": ", 1)[1] for line in lines if line.startswith("Body:")), "").strip() + + return { + "sender_email": sender_email, + "email_subject": email_subject, + "email_body": email_body, + "phase": "complete" + } + except Exception as parse_exc: + return {"error": f"Failed to parse LLM response: {str(parse_exc)}", "phase": "failed"} + + except Exception as exc: + return {"error": str(exc), "phase": "failed"} \ No newline at end of file diff --git a/app/skills.py b/app/skills.py new file mode 100644 index 0000000..d4aec63 --- /dev/null +++ b/app/skills.py @@ -0,0 +1,29 @@ +""" +A2A skill declarations for AutoParseEmailContentAndExtrac. +""" +from a2a.types import AgentSkill + + +AUTO_PARSE_EMAIL_CONTENT_AND_EXTRAC_SKILLS = [ + AgentSkill( + id="auto_parse_email_content_and_extrac_skill", + name="AutoParseEmailContentAndExtrac", + description="Parse email content and extract sender, subject, and body", + tags=["auto-generated"], + examples=[], + ), +] + + +AGENT_CONFIG = { + "name": "AutoParseEmailContentAndExtrac", + "description": "Extract relevant fields from incoming support email", + "version": "1.0.0", + "framework": "LangGraph + Starlette", + "capabilities": { + "streaming": False, + "async": True, + }, + "input_schema": {"email_raw": "string"}, + "output_schema": {"sender_email": "string", "email_subject": "string", "email_body": "string"}, +} diff --git a/app/states/__init__.py b/app/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/states/auto_parse_email_content_and_extrac_state.py b/app/states/auto_parse_email_content_and_extrac_state.py new file mode 100644 index 0000000..30c8f0b --- /dev/null +++ b/app/states/auto_parse_email_content_and_extrac_state.py @@ -0,0 +1,17 @@ +""" +State definitions for AutoParseEmailContentAndExtrac agent. +""" +from __future__ import annotations +from typing import Any, Dict, List, Optional +from langgraph.graph import MessagesState + + +class AutoParseEmailContentAndExtracState(MessagesState): + """Workflow state for AutoParseEmailContentAndExtrac.""" + # ── Input fields ───────────────────────────────────────────────────── + pass + # ── Output fields ──────────────────────────────────────────────────── + pass + # ── Internal ───────────────────────────────────────────────────────── + error: Optional[str] + phase: str diff --git a/app/workflows/__init__.py b/app/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/workflows/auto_parse_email_content_and_extrac_workflow.py b/app/workflows/auto_parse_email_content_and_extrac_workflow.py new file mode 100644 index 0000000..d62eb68 --- /dev/null +++ b/app/workflows/auto_parse_email_content_and_extrac_workflow.py @@ -0,0 +1,19 @@ +""" +LangGraph workflow for AutoParseEmailContentAndExtrac agent. +""" +from langgraph.graph import StateGraph, END + +from app.states.auto_parse_email_content_and_extrac_state import AutoParseEmailContentAndExtracState +from app.nodes.core_node import process + + +def create_auto_parse_email_content_and_extrac_workflow(llm): + """Build and compile the AutoParseEmailContentAndExtrac workflow graph.""" + graph = StateGraph(AutoParseEmailContentAndExtracState) + + graph.add_node("process", process) + + graph.set_entry_point("process") + graph.add_edge("process", END) + + return graph.compile() diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..6201f5c --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: auto-parse-email-content-and-extrac-config + namespace: agents +data: + REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local" + AGENT_SELF_URL: "http://auto-parse-email-content-and-extrac.agents.svc.cluster.local" + AZURE_OPENAI_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-08-01-preview" + LOG_LEVEL: "INFO" + OTEL_SERVICE_NAME: "auto-parse-email-content-and-extrac" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.monitoring.svc.cluster.local:4318" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..3505c70 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auto-parse-email-content-and-extrac + namespace: agents + labels: + app: auto-parse-email-content-and-extrac + component: agent + generated-by: agent-factory +spec: + replicas: 1 + selector: + matchLabels: + app: auto-parse-email-content-and-extrac + template: + metadata: + labels: + app: auto-parse-email-content-and-extrac + azure.workload.identity/use: "true" + annotations: + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + spec: + serviceAccountName: agents-sa + containers: + - name: auto-parse-email-content-and-extrac + image: bstagecjotdevacr.azurecr.io/auto-parse-email-content-and-extrac:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: auto-parse-email-content-and-extrac-config + env: + - name: AZURE_OPENAI_ENDPOINT + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-endpoint + - name: AZURE_OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-api-key + - name: OTEL_SERVICE_NAME + value: "auto-parse-email-content-and-extrac" + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,deployment.environment=production" + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + volumeMounts: + - name: secrets-store + mountPath: "/mnt/secrets-store" + readOnly: true + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: agents-kv-spc diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..cfbfd37 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: auto-parse-email-content-and-extrac + namespace: agents + labels: + app: auto-parse-email-content-and-extrac +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app: auto-parse-email-content-and-extrac diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..64ac29f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Auto-generated by Agent Factory +uvicorn[standard]==0.32.1 +starlette>=0.28.0 +pydantic>=2.11.3 +python-dotenv==1.0.1 +httpx>=0.28.1 +click>=8.1.8 + +# A2A Protocol +a2a-sdk[http-server]>=0.3.0 + +# LangChain & LangGraph +langchain>=1.2.10 +langchain-openai>=0.2.12 +langchain-core>=1.2.11 +langgraph>=0.2.59 +langgraph-checkpoint>=2.0.6 +openai>=1.109.1 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..187bb4b --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# deploy.sh — Build auto-parse-email-content-and-extrac image in ACR and deploy to AKS +set -e + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ACR_NAME="bstagecjotdevacr" +APP_NAME="auto-parse-email-content-and-extrac" +NAMESPACE="agents" +VERSION_FILE="${SCRIPT_DIR}/../.image-version" +TAG="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) TAG="$2"; shift ;; + esac + shift +done + +if [ -z "$TAG" ]; then + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + else + CURRENT_VERSION="1.0.0" + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" + +echo "======================================================================" +echo " auto-parse-email-content-and-extrac — Build & Deploy" +echo " Image: ${IMAGE}" +echo "======================================================================" + +az acr build --registry ${ACR_NAME} \ + --image ${APP_NAME}:${TAG} \ + --image ${APP_NAME}:latest \ + "${SCRIPT_DIR}/.." + +kubectl apply -f "${SCRIPT_DIR}/../k8s/configmap.yaml" 2>/dev/null || true +kubectl apply -f "${SCRIPT_DIR}/../k8s/deployment.yaml" +kubectl apply -f "${SCRIPT_DIR}/../k8s/service.yaml" 2>/dev/null || true + +kubectl set image deployment/${APP_NAME} \ + ${APP_NAME}=${IMAGE} \ + -n ${NAMESPACE} + +kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE} --timeout=120s + +echo "✓ Done. Image: ${IMAGE}" +echo " Port forward: kubectl port-forward -n ${NAMESPACE} deployment/${APP_NAME} 8080:8080" diff --git a/scripts/full-deploy.sh b/scripts/full-deploy.sh new file mode 100644 index 0000000..f9158d6 --- /dev/null +++ b/scripts/full-deploy.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# full-deploy.sh — Preflight + build + deploy auto-parse-email-content-and-extrac to AKS +set -e + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ACR_NAME="bstagecjotdevacr" +APP_NAME="auto-parse-email-content-and-extrac" +NAMESPACE="agents" +VERSION_FILE="${SCRIPT_DIR}/../.image-version" +TAG="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) TAG="$2"; shift ;; + esac + shift +done + +print_success() { echo -e "${GREEN}✓ $1${NC}"; } +print_error() { echo -e "${RED}✗ $1${NC}"; exit 1; } + +command -v kubectl &>/dev/null || print_error "kubectl not found." +command -v az &>/dev/null || print_error "Azure CLI not found." +az account show &>/dev/null || print_error "Not logged in to Azure." +kubectl cluster-info &>/dev/null || print_error "Cannot connect to cluster." +CLUSTER=$(kubectl config current-context) +print_success "Connected to cluster: ${CLUSTER}" + +if [ -z "$TAG" ]; then + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + else + CURRENT_VERSION="1.0.0" + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" +print_success "Image tag: ${TAG}" + +az acr build --registry ${ACR_NAME} \ + --image ${APP_NAME}:${TAG} \ + --image ${APP_NAME}:latest \ + "${SCRIPT_DIR}/.." || print_error "ACR build failed." +print_success "Image built: ${IMAGE}" + +kubectl apply -f "${SCRIPT_DIR}/../k8s/configmap.yaml" 2>/dev/null || true +kubectl apply -f "${SCRIPT_DIR}/../k8s/deployment.yaml" || print_error "Failed to apply deployment." +kubectl apply -f "${SCRIPT_DIR}/../k8s/service.yaml" || print_error "Failed to apply service." +print_success "Manifests applied" + +kubectl set image deployment/${APP_NAME} ${APP_NAME}=${IMAGE} -n ${NAMESPACE} +kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE} --timeout=180s || \ + print_error "Rollout failed." +print_success "Rollout complete" + +echo "" +echo -e "${GREEN} auto-parse-email-content-and-extrac deployed: ${IMAGE}${NC}" +echo " Port forward: kubectl port-forward -n ${NAMESPACE} deployment/${APP_NAME} 8080:8080"