53 min readAutonomous agents

Implementation Guide: Monitor regulatory dockets and alert attorneys when relevant rules change

Step-by-step implementation guide for deploying AI to monitor regulatory dockets and alert attorneys when relevant rules change for Legal Services clients.

Hardware Procurement

Application Server

Dell TechnologiesPowerEdge R360 (Xeon E-2488, 64GB ECC DDR5, 2x1TB NVMe SSD RAID 1)Qty: 1

$4,500 MSP cost / $5,850 suggested resale (30% markup)

Optional on-premises application server for firms with strict data-sovereignty requirements. Hosts Docker containers for n8n, LangGraph agent runtime, and Pinecone proxy cache. Not required for the recommended cloud-first deployment.

NAS for Document Storage

SynologyRS1221+ (8-bay rackmount, 32GB RAM) with 4x Seagate IronWolf 4TB (ST4000VN006)Qty: 1

$2,800 MSP cost / $3,640 suggested resale (30% markup)

Optional network-attached storage for local caching of regulatory documents, audit logs, and backup of vector database snapshots. Only required for on-prem or hybrid deployments.

Firewall/Security Appliance

FortinetFortiGate 40F (FG-40F)Qty: 1

$450 MSP cost / $585 suggested resale (30% markup)

Dedicated firewall for the regulatory monitoring infrastructure segment. Provides outbound TLS inspection, DNS filtering, and API traffic logging for compliance audit trails. Recommended even for cloud deployments to secure the office network egress.

UPS

Uninterruptible Power Supply

APC by Schneider ElectricSmart-UPS 1500VA (SMT1500RM2U)Qty: 1

$600 MSP cost / $780 suggested resale (30% markup)

Uninterruptible power supply for on-prem server and NAS. Provides 15-20 minutes of runtime for graceful shutdown. Only required for on-prem deployments.

Software Procurement

$75–$150/month MSP cost / $200–$400/month resale

Cloud hosting for the LangGraph agent runtime (Azure App Service B2 plan: 2 vCPU, 3.5GB RAM), scheduled polling via Azure Functions (Consumption plan), and Azure Blob Storage for regulatory document cache and audit logs.

OpenAI API (GPT-4.1 and GPT-5.4 mini)

OpenAIGPT-4.1 / GPT-5.4 miniQty: ~500 regulatory documents/month (10-attorney firm)

$100–$200/month MSP cost / $250–$500/month resale (estimated)

Primary LLM for regulatory document analysis, relevance classification, and alert summarization. GPT-4.1 ($2/M input, $8/M output tokens) for deep analysis; GPT-5.4 mini ($0.15/M input, $0.60/M output) for initial triage and classification.

Same token pricing as OpenAI direct, plus Azure subscription

Enterprise-grade alternative to direct OpenAI API. Data stays within Azure tenant, not shared with OpenAI for training. Provides SOC 2, ISO 27001, HIPAA BAA compliance. Recommended for firms requiring maximum data governance controls.

$50–$200/month MSP cost / $150–$400/month resale

License type: SaaS usage-based (minimum $50/month Standard). Stores vector embeddings of regulatory documents, firm practice area descriptions, attorney expertise profiles, and client matter summaries for retrieval-augmented generation (RAG). Enables the Relevance Classifier Agent to match new regulations against the firm's knowledge base.

LegiScan GAITS Pro

LegiScanSaaS per-state subscriptionQty: 10-30 state coverage

$5/state/month (~$50–$150/month for 10-30 state coverage) MSP cost / $150–$400/month resale

Structured JSON API providing legislation tracking across all 50 states and Congress. Provides bill text, status, sponsors, committee assignments, and relevance scoring. Essential data source for state-level legislative monitoring.

n8n (Self-Hosted Community Edition)

n8n GmbHSustainable Use License (free self-hosted for internal MSP use)Qty: 1

$0 (self-hosted) or $50/month (n8n Cloud Starter) / $150–$300/month resale

Workflow orchestration platform that schedules API polling, manages the multi-agent pipeline execution, handles error recovery and retries, sends email alerts via Microsoft Graph API, and provides a visual workflow editor for MSP technicians to modify agent pipelines without code changes.

Clio Manage API Access

Clio (Themis Solutions)Clio Manage REST API v4Qty: 1

$0 incremental (client's existing Clio subscription)

Integration target for posting regulatory alerts as matter notes, creating tasks for attorney review, and reading practice area/matter metadata to inform relevance scoring. Uses OAuth 2.0 with 400+ REST endpoints. License type: Included with Clio subscription (API access on all plans).

LangGraph (Agent Orchestration Framework)

LangChain Inc.MIT open-source licenseQty: N/A

$0 (open-source)

Core agent orchestration framework for building the multi-agent regulatory monitoring pipeline. Provides state checkpointing, interrupt functionality for human-in-the-loop approval, and LangSmith integration for audit trails. Specifically chosen for regulated sectors due to its native compliance-friendly architecture.

LangSmith (Observability & Audit)

LangChain Inc.SaaS (Free tier: 5K traces/month; Plus: $39/seat/month)

$39–$78/month MSP cost / $100–$200/month resale

LLM observability platform providing full trace logging of every agent decision, token usage tracking, prompt versioning, and evaluation datasets. Critical for compliance audit trails required by ABA ethics rules—demonstrates how the AI arrived at each classification decision.

Microsoft 365 Business Standard

Microsoftper-seat subscription

$0 incremental (client's existing M365 subscription)

Email delivery infrastructure for attorney alerts via Microsoft Graph API. Also provides Teams integration for optional channel-based alert delivery and SharePoint for shared regulatory document libraries.

Prerequisites

  • Active Microsoft 365 Business Standard or higher subscription with Global Admin access for app registration (Microsoft Graph API permissions: Mail.Send, User.Read.All)
  • Clio Manage subscription (any tier) with API access enabled and an admin user who can authorize OAuth 2.0 application access
  • Python 3.11+ development environment available to the MSP build team (for LangGraph agent development)
  • Docker and Docker Compose installed on the deployment target (Azure VM, on-prem server, or local development machine)
  • OpenAI API account with billing configured and at least $50 prepaid credit (or Azure OpenAI Service provisioned with GPT-4 and GPT-5.4 mini model deployments approved)
  • Pinecone account created with a serverless index provisioned in the us-east-1 (AWS) or eastus (Azure) region
  • LegiScan API key obtained from https://legiscan.com/user/register (free tier for initial development; GAITS Pro subscription for production)
  • Federal Register API access verified (no key required, but test endpoint: https://www.federalregister.gov/api/v1/documents.json?per_page=1)
  • Regulations.gov API key obtained from https://api.data.gov/signup/ (free, required for docket search)
  • A completed Practice Area Taxonomy document from the managing partner listing: (a) all active practice areas, (b) relevant regulatory bodies per practice area, (c) priority keywords and topics per practice area, (d) list of active client matters with associated regulatory exposure
  • Network outbound HTTPS (port 443) access confirmed to: api.openai.com, *.pinecone.io, legiscan.com, www.federalregister.gov, api.regulations.gov, graph.microsoft.com, app.clio.com, api.smith.langchain.com
  • Git repository provisioned for version control of all agent code, prompts, and configuration (GitHub, Azure DevOps, or equivalent)
  • SSL/TLS certificate for the monitoring dashboard domain (can use Azure-managed certificate or Let's Encrypt)
  • Documented approval from the firm's managing partner acknowledging AI use in regulatory monitoring, consistent with ABA Formal Opinion 512 disclosure requirements

Installation Steps

Step 1: Provision Azure Infrastructure

Create the Azure resource group and provision all required cloud services: App Service for the agent runtime, Function App for scheduled triggers, Blob Storage for document cache, and Key Vault for secrets management. This forms the cloud foundation for the entire system.

Provision Azure resource group, App Service, Function App, Blob Storage containers, and Key Vault secrets
bash
az login
az group create --name rg-regmonitor-prod --location eastus
az appservice plan create --name asp-regmonitor --resource-group rg-regmonitor-prod --sku B2 --is-linux
az webapp create --name app-regmonitor-agent --resource-group rg-regmonitor-prod --plan asp-regmonitor --runtime 'PYTHON:3.11'
az functionapp create --name func-regmonitor-scheduler --resource-group rg-regmonitor-prod --storage-account stregmonitor --consumption-plan-location eastus --runtime python --runtime-version 3.11 --functions-version 4
az storage account create --name stregmonitor --resource-group rg-regmonitor-prod --sku Standard_LRS --kind StorageV2
az storage container create --name regulatory-docs --account-name stregmonitor
az storage container create --name audit-logs --account-name stregmonitor
az keyvault create --name kv-regmonitor --resource-group rg-regmonitor-prod --location eastus
az keyvault secret set --vault-name kv-regmonitor --name openai-api-key --value '<YOUR_OPENAI_API_KEY>'
az keyvault secret set --vault-name kv-regmonitor --name pinecone-api-key --value '<YOUR_PINECONE_API_KEY>'
az keyvault secret set --vault-name kv-regmonitor --name legiscan-api-key --value '<YOUR_LEGISCAN_API_KEY>'
az keyvault secret set --vault-name kv-regmonitor --name regulations-gov-api-key --value '<YOUR_REGULATIONS_GOV_KEY>'
az keyvault secret set --vault-name kv-regmonitor --name clio-client-secret --value '<YOUR_CLIO_CLIENT_SECRET>'
az keyvault secret set --vault-name kv-regmonitor --name langsmith-api-key --value '<YOUR_LANGSMITH_API_KEY>'
Note

Use Azure Key Vault for ALL secrets—never hardcode API keys. If the firm requires Azure OpenAI Service instead of direct OpenAI, also provision: az cognitiveservices account create --name oai-regmonitor --resource-group rg-regmonitor-prod --kind OpenAI --sku S0 --location eastus. Ensure the Azure subscription has the OpenAI resource provider registered.

Step 2: Register Microsoft Graph API Application

Register an Azure AD application to enable sending email alerts through Microsoft Graph API using the firm's M365 tenant. This allows the system to send alerts as a shared mailbox or service account.

Create Azure AD app registration and service principal
bash
az ad app create --display-name 'RegMonitor Alert Service' --sign-in-audience AzureADMyOrg
# Note the Application (client) ID from output
az ad app credential reset --id <APP_ID> --display-name 'RegMonitor Secret'
# Note the client secret from output
az ad sp create --id <APP_ID>
1
In Azure Portal: Azure Active Directory > App registrations > RegMonitor Alert Service > API permissions
2
Add: Microsoft Graph > Application permissions > Mail.Send, User.Read.All
3
Click 'Grant admin consent for <tenant>'
Store Microsoft Graph credentials in Azure Key Vault
bash
az keyvault secret set --vault-name kv-regmonitor --name msgraph-client-id --value '<APP_ID>'
az keyvault secret set --vault-name kv-regmonitor --name msgraph-client-secret --value '<CLIENT_SECRET>'
az keyvault secret set --vault-name kv-regmonitor --name msgraph-tenant-id --value '<TENANT_ID>'
Note

The Mail.Send application permission allows the service to send as any user. For tighter security, create a dedicated shared mailbox (e.g., regulatory-alerts@firmname.com) and use an Exchange application access policy to restrict sending to only that mailbox. Run: New-ApplicationAccessPolicy -AppId <APP_ID> -PolicyScopeGroupId regulatory-alerts@firmname.com -AccessRight RestrictAccess -Description 'Restrict RegMonitor to alerts mailbox'

Step 3: Configure Clio API OAuth Application

Register the regulatory monitoring application with Clio's developer platform to obtain OAuth 2.0 credentials for reading matter data and posting alert notes.

1
Navigate to https://app.clio.com/nc/#/settings/developer_applications
2
Click 'Add Application'
3
Fill in: Name: Regulatory Monitor | URL: https://app-regmonitor-agent.azurewebsites.net | Redirect URI: https://app-regmonitor-agent.azurewebsites.net/auth/clio/callback | Scopes: matters:read, notes:create, tasks:create, contacts:read, practice_areas:read
4
Note the Client ID and Client Secret
Store Clio OAuth credentials in Azure Key Vault
bash
az keyvault secret set --vault-name kv-regmonitor --name clio-client-id --value '<CLIO_CLIENT_ID>'
az keyvault secret set --vault-name kv-regmonitor --name clio-client-secret --value '<CLIO_CLIENT_SECRET>'
Note

Clio OAuth tokens expire after a set period. The application must implement token refresh logic. Store the refresh token in Key Vault and update it on each refresh cycle. The initial OAuth flow requires a one-time browser-based authorization by a Clio admin user.

Step 4: Initialize Pinecone Vector Database

Create the Pinecone serverless index that will store regulatory document embeddings and practice area profile embeddings for RAG-based relevance matching.

bash
pip install pinecone-client==3.2.2
python3 << 'EOF'
import os
from pinecone import Pinecone, ServerlessSpec

pc = Pinecone(api_key=os.environ['PINECONE_API_KEY'])

# Create index for regulatory document embeddings
pc.create_index(
    name='regulatory-docs',
    dimension=3072,  # text-embedding-3-large dimension
    metric='cosine',
    spec=ServerlessSpec(
        cloud='azure',
        region='eastus'
    )
)

# Create index for practice area profiles
pc.create_index(
    name='practice-profiles',
    dimension=3072,
    metric='cosine',
    spec=ServerlessSpec(
        cloud='azure',
        region='eastus'
    )
)
print('Indexes created successfully')
EOF
Note

Use dimension=3072 for OpenAI text-embedding-3-large model. If using text-embedding-3-small (cheaper, less accurate), set dimension=1536. The 'azure' cloud with 'eastus' region minimizes latency when the App Service is also in eastus. Index creation takes 1-2 minutes.

Step 5: Build Practice Area Embedding Profiles

Using the Practice Area Taxonomy document from the managing partner, create vector embeddings for each practice area, attorney expertise profile, and active client matter. These embeddings form the 'knowledge' the relevance classifier agent uses to determine which regulations matter to which attorneys.

python
python3 << 'PYEOF'
import json
import os
from openai import OpenAI
from pinecone import Pinecone

client = OpenAI(api_key=os.environ['OPENAI_API_KEY'])
pc = Pinecone(api_key=os.environ['PINECONE_API_KEY'])
index = pc.Index('practice-profiles')

# Load the practice area taxonomy (prepared by managing partner)
with open('practice_area_taxonomy.json', 'r') as f:
    taxonomy = json.load(f)

vectors_to_upsert = []

for area in taxonomy['practice_areas']:
    # Create rich text description for embedding
    profile_text = f"""Practice Area: {area['name']}
Description: {area['description']}
Regulatory Bodies: {', '.join(area['regulatory_bodies'])}
Key Topics: {', '.join(area['keywords'])}
Relevant Statutes: {', '.join(area.get('statutes', []))}
Attorneys: {', '.join([a['name'] for a in area['attorneys']])}"""
    
    response = client.embeddings.create(
        model='text-embedding-3-large',
        input=profile_text
    )
    
    vectors_to_upsert.append({
        'id': f"pa-{area['id']}",
        'values': response.data[0].embedding,
        'metadata': {
            'type': 'practice_area',
            'name': area['name'],
            'attorney_emails': [a['email'] for a in area['attorneys']],
            'keywords': area['keywords'],
            'regulatory_bodies': area['regulatory_bodies']
        }
    })

# Upsert in batches of 100
for i in range(0, len(vectors_to_upsert), 100):
    index.upsert(vectors=vectors_to_upsert[i:i+100])

print(f'Upserted {len(vectors_to_upsert)} practice area profiles')
PYEOF
Note

The practice_area_taxonomy.json file must be prepared during the Discovery phase with the managing partner. See the Custom AI Components section for the exact JSON schema. This step should be re-run whenever the firm adds practice areas, attorneys, or significant client matters.

Step 6: Deploy n8n Workflow Orchestration

Deploy n8n as a Docker container on the Azure App Service (or a separate Azure Container Instance) to serve as the scheduling and workflow orchestration layer. n8n handles the polling schedule, error handling, retries, and provides a visual editor for the MSP to modify workflows.

Create docker-compose.n8n.yml for n8n
bash
# Create docker-compose.yml for n8n
cat << 'COMPOSE' > docker-compose.n8n.yml
version: '3.8'
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:1.50.2
    restart: always
    ports:
      - '5678:5678'
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=${N8N_USER}
      - N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD}
      - N8N_HOST=n8n-regmonitor.azurewebsites.net
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n-regmonitor.azurewebsites.net/
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=${POSTGRES_HOST}
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=${POSTGRES_USER}
      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - n8n_data:/home/node/.n8n
volumes:
  n8n_data:
COMPOSE
Deploy n8n to Azure Container Instance
bash
az container create --resource-group rg-regmonitor-prod --name n8n-regmonitor --image docker.n8n.io/n8nio/n8n:1.50.2 --cpu 2 --memory 4 --ports 5678 --dns-name-label n8n-regmonitor --environment-variables N8N_BASIC_AUTH_ACTIVE=true N8N_HOST=n8n-regmonitor.eastus.azurecontainer.io N8N_PROTOCOL=https --secure-environment-variables N8N_BASIC_AUTH_USER=admin N8N_BASIC_AUTH_PASSWORD=<STRONG_PASSWORD> N8N_ENCRYPTION_KEY=<RANDOM_32_CHAR_STRING>
Note

For production, use Azure Database for PostgreSQL Flexible Server as the n8n backend database instead of the default SQLite. This ensures workflow state persistence across container restarts. Budget ~$30/month for the smallest PostgreSQL instance. Generate the N8N_ENCRYPTION_KEY with: openssl rand -hex 16. Set up Azure Application Gateway or Cloudflare in front of n8n for SSL termination and DDoS protection.

Step 7: Clone and Configure the Agent Pipeline Repository

Clone the MSP's regulatory monitoring agent repository, configure environment variables, install Python dependencies, and prepare the LangGraph agent pipeline for deployment.

Clone repository and set up Python virtual environment
bash
git clone https://github.com/<msp-org>/regmonitor-agent.git
cd regmonitor-agent
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
requirements.txt contents
text
langgraph==0.2.28
langchain-openai==0.2.1
langchain-community==0.3.1
pinecone-client==3.2.2
httpx==0.27.0
pydantic==2.9.0
python-dotenv==1.0.1
azure-identity==1.17.1
azure-keyvault-secrets==4.8.0
msal==1.30.0
feedparser==6.0.11
beautifulsoup4==4.12.3
langsmith==0.1.120
Copy example environment file
bash
cp .env.example .env
.env configuration values to populate after copying .env.example
dotenv
AZURE_KEYVAULT_URI=https://kv-regmonitor.vault.azure.net/
LANGSMITH_TRACING=true
LANGSMITH_PROJECT=regmonitor-prod
PINECONE_INDEX_DOCS=regulatory-docs
PINECONE_INDEX_PROFILES=practice-profiles
POLLING_INTERVAL_HOURS=2
RELEVANCE_THRESHOLD=0.72
ALERT_EMAIL_FROM=regulatory-alerts@firmname.com
Note

All sensitive credentials are stored in Azure Key Vault and fetched at runtime—the .env file only contains non-sensitive configuration. The RELEVANCE_THRESHOLD (0.72) is a cosine similarity threshold that determines when a regulation is 'relevant enough' to trigger an alert. This will be tuned during the validation phase. Start with 0.72 and adjust based on attorney feedback.

Step 8: Deploy the LangGraph Agent Pipeline to Azure App Service

Package the multi-agent LangGraph pipeline as a Docker container and deploy it to the Azure App Service. The pipeline consists of three agents: Ingestion Agent, Relevance Classifier Agent, and Alert Generator Agent.

Create Dockerfile for the LangGraph agent pipeline
dockerfile
cat << 'DOCKERFILE' > Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
DOCKERFILE
Build and push image to Azure Container Registry
bash
az acr create --name acrregmonitor --resource-group rg-regmonitor-prod --sku Basic
az acr login --name acrregmonitor
docker build -t acrregmonitor.azurecr.io/regmonitor-agent:v1.0.0 .
docker push acrregmonitor.azurecr.io/regmonitor-agent:v1.0.0
Deploy container image to App Service
bash
az webapp config container set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --docker-custom-image-name acrregmonitor.azurecr.io/regmonitor-agent:v1.0.0 --docker-registry-server-url https://acrregmonitor.azurecr.io
Enable managed identity for Key Vault access
bash
az webapp identity assign --name app-regmonitor-agent --resource-group rg-regmonitor-prod
Grant Key Vault access using principal ID from identity assign output
bash
az keyvault set-policy --name kv-regmonitor --object-id <PRINCIPAL_ID> --secret-permissions get list
Configure application settings
bash
az webapp config appsettings set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --settings AZURE_KEYVAULT_URI=https://kv-regmonitor.vault.azure.net/ LANGSMITH_TRACING=true LANGSMITH_PROJECT=regmonitor-prod WEBSITES_PORT=8000
Note

The App Service uses Managed Identity to access Key Vault—no credentials stored in app settings. Enable Always On in the App Service to prevent cold starts: az webapp config set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --always-on true. Set up deployment slots for blue-green deployments during updates.

Step 9: Configure Azure Functions Scheduler

Deploy Azure Functions that trigger the agent pipeline on a scheduled basis. The primary schedule polls federal sources every 2 hours during business hours and state sources every 4 hours. A daily digest function runs at 6:00 AM to compile overnight changes.

1
Navigate to the scheduler-functions directory
2
Initialize a Python Azure Functions project
3
Create the polling trigger function by writing function_app.py
4
Publish the function app to Azure
Initialize Azure Functions Python project
bash
cd scheduler-functions
func init --python
function_app.py
python
# Azure Functions timer triggers for federal polling, state polling, and
# daily digest

import azure.functions as func
import httpx
import logging
import json

app = func.FunctionApp()

@app.timer_trigger(schedule='0 */2 6-20 * * 1-5', arg_name='timer', run_on_startup=False)
async def poll_federal_sources(timer: func.TimerRequest):
    """Poll federal regulatory sources every 2 hours, Mon-Fri 6AM-8PM"""
    async with httpx.AsyncClient(timeout=300) as client:
        response = await client.post(
            'https://app-regmonitor-agent.azurewebsites.net/api/v1/poll',
            json={'sources': ['federal_register', 'regulations_gov', 'congress_gov']},
            headers={'X-API-Key': '<INTERNAL_API_KEY>'}
        )
        logging.info(f'Federal poll completed: {response.status_code}')

@app.timer_trigger(schedule='0 0 */4 * * *', arg_name='timer', run_on_startup=False)
async def poll_state_sources(timer: func.TimerRequest):
    """Poll state legislative sources every 4 hours"""
    async with httpx.AsyncClient(timeout=300) as client:
        response = await client.post(
            'https://app-regmonitor-agent.azurewebsites.net/api/v1/poll',
            json={'sources': ['legiscan']},
            headers={'X-API-Key': '<INTERNAL_API_KEY>'}
        )
        logging.info(f'State poll completed: {response.status_code}')

@app.timer_trigger(schedule='0 0 6 * * 1-5', arg_name='timer', run_on_startup=False)
async def daily_digest(timer: func.TimerRequest):
    """Generate and send daily digest email at 6AM Mon-Fri"""
    async with httpx.AsyncClient(timeout=300) as client:
        response = await client.post(
            'https://app-regmonitor-agent.azurewebsites.net/api/v1/digest',
            headers={'X-API-Key': '<INTERNAL_API_KEY>'}
        )
        logging.info(f'Daily digest sent: {response.status_code}')
Publish the scheduler function app to Azure
bash
func azure functionapp publish func-regmonitor-scheduler
Note

The CRON expressions use NCrontab format (6 fields: second minute hour day month day-of-week). Adjust polling frequency based on the firm's needs—higher-volume practices may want hourly polling. The internal API key is a shared secret stored in Key Vault that authenticates the scheduler-to-agent communication. Weekend polling can be enabled by changing '1-5' to '*' in the schedule.

Step 10: Configure n8n Workflow for Alert Routing and Delivery

Import the pre-built n8n workflow that receives classified alerts from the LangGraph pipeline via webhook, routes them to the appropriate attorneys based on practice area, formats email alerts, posts notes to Clio matters, and handles escalation for high-priority regulatory changes.

1
Access n8n web UI at https://n8n-regmonitor.eastus.azurecontainer.io
2
Click 'Import from file' in n8n
3
Select 'regmonitor-alert-workflow.json' from the repository
4
Configure credentials for each node: a. Microsoft Graph OAuth2 (for email sending), b. Clio OAuth2 (for matter notes), c. Webhook authentication (for receiving from LangGraph)

The workflow contains these nodes:

1
Webhook Trigger: Receives POST from LangGraph with alert payload
2
Switch Node: Routes by priority (Critical/High/Medium/Low)
3
Code Node: Maps practice area to attorney email list
4
Microsoft Graph Node: Sends formatted HTML email alert
5
Clio API Node: Creates note on relevant matter
6
IF Node: If priority=Critical, also send Teams message
7
Error Handler: Logs failures and sends MSP notification
Test the webhook endpoint
bash
curl -X POST https://n8n-regmonitor.eastus.azurecontainer.io/webhook/regmonitor-alert -H 'Content-Type: application/json' -H 'Authorization: Bearer <WEBHOOK_TOKEN>' -d '{"alert_id": "test-001", "priority": "medium", "practice_areas": ["environmental"], "title": "Test Alert", "summary": "This is a test regulatory alert.", "source_url": "https://example.com", "effective_date": "2025-01-01", "attorneys": ["jdoe@firmname.com"]}'
Note

The n8n workflow JSON file is included in the agent repository under /workflows/regmonitor-alert-workflow.json. When configuring Microsoft Graph credentials in n8n, use the Application (client) ID and secret from Step 2. Set the 'from' address to the shared mailbox created for alerts. Test thoroughly with all priority levels before go-live.

Step 11: Configure Monitoring and Alerting

Set up Azure Monitor, Application Insights, and LangSmith dashboards to track system health, agent performance, and LLM costs. Configure alerts for failures, budget thresholds, and SLA breaches.

Enable Application Insights on the App Service
bash
az monitor app-insights component create --app ai-regmonitor --location eastus --resource-group rg-regmonitor-prod
az webapp config appsettings set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --settings APPLICATIONINSIGHTS_CONNECTION_STRING=<CONNECTION_STRING>
Alert 1: Agent pipeline failure (any HTTP 5xx from the agent API)
bash
az monitor metrics alert create --name 'RegMonitor-Pipeline-Failure' --resource-group rg-regmonitor-prod --scopes /subscriptions/<SUB_ID>/resourceGroups/rg-regmonitor-prod/providers/Microsoft.Web/sites/app-regmonitor-agent --condition 'count requests/failed > 3' --window-size 1h --evaluation-frequency 15m --action-group ag-msp-oncall
1
Alert 2: No polls executed in 6 hours (system may be down) — Configure in n8n: Add an error-handling workflow that posts to MSP webhook if main workflow hasn't run
2
Alert 3: Monthly LLM cost approaching budget — Configure in OpenAI dashboard: Settings > Billing > Usage limits > Set hard cap at $300/month
  • In LangSmith, create a dashboard tracking: Total traces per day
  • Average latency per agent
  • Token usage per agent
  • Error rate
  • Relevance score distribution
Note

Set up a shared MSP dashboard in LangSmith that shows all client regulatory monitoring pipelines. This enables centralized monitoring across multiple law firm clients. The MSP on-call action group should include email and SMS notifications. Review LangSmith traces weekly during the first month to identify prompt optimization opportunities.

Step 12: Load Historical Regulatory Data for Baseline

Backfill the system with 6 months of historical regulatory data from all configured sources. This populates the vector database, allows for relevance threshold tuning, and gives attorneys a familiar frame of reference when they start receiving alerts.

Run historical backfill and generate tuning report

1
Run the historical backfill script python3 scripts/backfill_historical.py --months 6 --sources federal_register,regulations_gov,legiscan --batch-size 50
2
This script:
3
1. Queries Federal Register API for documents from the past 6 months
4
GET https://www.federalregister.gov/api/v1/documents.json?conditions[publication_date][gte]=2024-07-01&per_page=100
5
2. Queries LegiScan for bills introduced in the past 6 months 3. For each document: generates embedding, stores in Pinecone, runs relevance classification 4. Outputs a CSV report: backfill_results.csv with columns:
6
document_id, title, source, publication_date, relevance_score, matched_practice_areas, would_have_alerted
7
Generate the tuning report python3 scripts/generate_tuning_report.py --input backfill_results.csv --output tuning_report.html
8
This creates an HTML report showing: - Distribution of relevance scores - False positive/negative analysis (requires attorney review) - Recommended threshold adjustments
Note

The backfill will process 2,000-10,000 documents depending on the practice areas configured. Estimated LLM cost: $15-$50 for the full backfill. The tuning report should be reviewed with 2-3 senior attorneys who can identify documents that should/should not have triggered alerts. Use their feedback to adjust the RELEVANCE_THRESHOLD in .env. This is the most critical calibration step.

Step 13: Attorney Training and User Acceptance Testing

Conduct a half-day training session with all attorneys who will receive alerts. Walk through the system architecture (at a high level), demonstrate how alerts are generated, show how to provide feedback on alert relevance, and collect initial feedback on alert format and frequency preferences.

1
training-deck.pptx - Overview presentation (provided in /docs/training/)
2
attorney-quick-reference.pdf - One-page guide: 'Understanding Your Regulatory Alerts'
3
feedback-form - Web form for attorneys to rate alert relevance (built in Microsoft Forms)
4
Sample alerts from the backfill data to walk through live

Training Agenda (3 hours)

  • 30 min: What the system does and doesn't do (emphasize: AI assists, attorney decides)
  • 30 min: Live demo of alert emails, Clio integration, dashboard
  • 30 min: How to provide feedback (thumbs up/down on alerts, suggest new keywords)
  • 30 min: Practice area configuration review with each practice group
  • 30 min: Q&A and concern collection
  • 30 min: Sign-off on go-live readiness
Note

ABA Formal Opinion 512 requires that lawyers understand the capacity and limitations of AI tools they use. This training session satisfies that requirement. Document attendance and keep the training materials as part of the compliance record. Schedule follow-up 30-minute check-ins at 2 weeks and 6 weeks post-launch.

Step 14: Go-Live and 2-Week Burn-In Period

Activate the production polling schedule and begin sending live alerts to attorneys. During the 2-week burn-in period, run the system in 'shadow mode' for the first 3 days (alerts go to MSP + managing partner only), then full deployment. Monitor closely and tune based on real-time feedback.

Day 1-3: Enable shadow mode (alerts to MSP and managing partner only)
bash
az webapp config appsettings set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --settings SHADOW_MODE=true SHADOW_RECIPIENTS=msp-team@mspname.com,managing.partner@firmname.com
Day 4: Disable shadow mode for full deployment
bash
az webapp config appsettings set --name app-regmonitor-agent --resource-group rg-regmonitor-prod --settings SHADOW_MODE=false
1
Check LangSmith dashboard for error rates and latency
2
Review attorney feedback form responses
3
Check Azure Monitor for resource utilization
4
Review n8n execution logs for failed deliveries
5
Track OpenAI usage dashboard for cost trajectory
After 2 weeks: Generate the burn-in report
bash
python3 scripts/generate_burnin_report.py --start-date 2025-01-15 --end-date 2025-01-29
Note

During the burn-in period, the MSP should have a dedicated point of contact available to attorneys for questions and issues. Common first-week adjustments: reducing alert frequency (increase relevance threshold), adding practice area keywords that were missed in discovery, and adjusting email formatting based on attorney preferences. The burn-in report will show alert volume, relevance scores, attorney feedback ratings, and system uptime—present this to the managing partner at the 2-week check-in.

Custom AI Components

Practice Area Taxonomy Schema

Type: prompt

JSON schema and example for the Practice Area Taxonomy document that the managing partner must complete during discovery. This document is the foundation for all relevance matching—the quality of alerts depends directly on the quality of this taxonomy.

Implementation:

Practice Area Taxonomy JSON Schema
json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Practice Area Taxonomy",
  "type": "object",
  "required": ["firm_name", "practice_areas"],
  "properties": {
    "firm_name": {"type": "string"},
    "last_updated": {"type": "string", "format": "date"},
    "practice_areas": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "name", "description", "regulatory_bodies", "keywords", "attorneys"],
        "properties": {
          "id": {"type": "string", "description": "Unique identifier, e.g., 'env-law'"},
          "name": {"type": "string", "description": "e.g., 'Environmental Law'"},
          "description": {"type": "string", "description": "2-3 sentence description of what this practice area covers and what types of regulatory changes are relevant"},
          "regulatory_bodies": {
            "type": "array",
            "items": {"type": "string"},
            "description": "e.g., ['EPA', 'Army Corps of Engineers', 'State DEQ', 'OSHA']"
          },
          "keywords": {
            "type": "array",
            "items": {"type": "string"},
            "description": "e.g., ['CERCLA', 'Clean Water Act', 'NEPA', 'emissions standards', 'PFAS', 'wetlands']"
          },
          "statutes": {
            "type": "array",
            "items": {"type": "string"},
            "description": "e.g., ['42 USC 6901', '33 USC 1251', '42 USC 4321']"
          },
          "cfr_titles": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Relevant CFR titles, e.g., ['40 CFR Part 261', '40 CFR Part 122']"
          },
          "states_of_interest": {
            "type": "array",
            "items": {"type": "string"},
            "description": "State abbreviations for state-level tracking, e.g., ['CA', 'TX', 'NY']"
          },
          "attorneys": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {"type": "string"},
                "email": {"type": "string"},
                "role": {"type": "string", "enum": ["lead", "associate", "paralegal"]},
                "alert_preference": {"type": "string", "enum": ["all", "high_only", "digest_only"]}
              }
            }
          },
          "exclusion_keywords": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Keywords that indicate a regulation is NOT relevant despite matching other criteria"
          }
        }
      }
    }
  }
}
Practice Area Taxonomy Example Instance
json
{
  "firm_name": "Smith & Associates LLP",
  "last_updated": "2025-01-15",
  "practice_areas": [
    {
      "id": "env-law",
      "name": "Environmental Law",
      "description": "Regulatory compliance and litigation related to environmental protection, hazardous waste, water quality, and air emissions. We advise manufacturing and energy clients on compliance with federal and state environmental regulations.",
      "regulatory_bodies": ["EPA", "Army Corps of Engineers", "California DTSC", "Texas CEQ", "OSHA"],
      "keywords": ["CERCLA", "RCRA", "Clean Water Act", "Clean Air Act", "NEPA", "PFAS", "emissions", "hazardous waste", "remediation", "Superfund", "wetlands", "stormwater", "NPDES"],
      "statutes": ["42 USC 6901", "33 USC 1251", "42 USC 7401", "42 USC 4321"],
      "cfr_titles": ["40 CFR Part 261", "40 CFR Part 122", "40 CFR Part 50"],
      "states_of_interest": ["CA", "TX", "NY", "PA"],
      "attorneys": [
        {"name": "Jane Smith", "email": "jsmith@smithlaw.com", "role": "lead", "alert_preference": "all"},
        {"name": "Bob Johnson", "email": "bjohnson@smithlaw.com", "role": "associate", "alert_preference": "high_only"}
      ],
      "exclusion_keywords": ["marine fisheries", "endangered species listing"]
    }
  ]
}

Ingestion Agent

Type: agent

The first agent in the LangGraph pipeline. It polls configured regulatory data sources (Federal Register API, Regulations.gov API, LegiScan API, Congress.gov), extracts structured metadata, caches full document text in Azure Blob Storage, generates vector embeddings, and stores them in Pinecone. It maintains a deduplication index to avoid reprocessing documents already seen.

Implementation:

agents/ingestion_agent.py
python
# agents/ingestion_agent.py
import httpx
import hashlib
import json
import logging
from datetime import datetime, timedelta
from typing import TypedDict, Annotated, Sequence
from langgraph.graph import StateGraph, END
from langchain_openai import OpenAIEmbeddings
from pinecone import Pinecone
from azure.storage.blob import BlobServiceClient

logger = logging.getLogger(__name__)

class IngestionState(TypedDict):
    source: str
    raw_documents: list[dict]
    processed_documents: list[dict]
    new_document_ids: list[str]
    errors: list[str]
    stats: dict

class FederalRegisterSource:
    BASE_URL = 'https://www.federalregister.gov/api/v1'
    
    async def fetch_recent(self, hours_back: int = 4) -> list[dict]:
        since = (datetime.utcnow() - timedelta(hours=hours_back)).strftime('%Y-%m-%d')
        params = {
            'conditions[publication_date][gte]': since,
            'conditions[type][]': ['RULE', 'PRORULE', 'NOTICE'],
            'per_page': 100,
            'order': 'newest',
            'fields[]': [
                'document_number', 'title', 'type', 'abstract',
                'publication_date', 'agencies', 'cfr_references',
                'regulation_id_numbers', 'html_url', 'raw_text_url',
                'effective_on', 'comments_close_on', 'docket_ids'
            ]
        }
        documents = []
        async with httpx.AsyncClient(timeout=60) as client:
            response = await client.get(f'{self.BASE_URL}/documents.json', params=params)
            response.raise_for_status()
            data = response.json()
            for doc in data.get('results', []):
                documents.append({
                    'source': 'federal_register',
                    'source_id': doc['document_number'],
                    'title': doc['title'],
                    'doc_type': doc['type'],
                    'abstract': doc.get('abstract', ''),
                    'publication_date': doc['publication_date'],
                    'agencies': [a['name'] for a in doc.get('agencies', [])],
                    'cfr_references': [f"{r['title']} CFR {r.get('part', '')}" for r in doc.get('cfr_references', [])],
                    'url': doc['html_url'],
                    'raw_text_url': doc.get('raw_text_url'),
                    'effective_date': doc.get('effective_on'),
                    'comment_deadline': doc.get('comments_close_on'),
                    'docket_ids': doc.get('docket_ids', []),
                    'content_hash': hashlib.sha256(json.dumps(doc, sort_keys=True).encode()).hexdigest()
                })
        return documents

class RegulationsGovSource:
    BASE_URL = 'https://api.regulations.gov/v4'
    
    def __init__(self, api_key: str):
        self.api_key = api_key
    
    async def fetch_recent(self, hours_back: int = 4) -> list[dict]:
        since = (datetime.utcnow() - timedelta(hours=hours_back)).strftime('%Y-%m-%d')
        documents = []
        async with httpx.AsyncClient(timeout=60) as client:
            response = await client.get(
                f'{self.BASE_URL}/documents',
                params={
                    'filter[postedDate][ge]': since,
                    'filter[documentType]': 'Rule,Proposed Rule,Notice',
                    'page[size]': 25,
                    'sort': '-postedDate'
                },
                headers={'X-Api-Key': self.api_key}
            )
            response.raise_for_status()
            data = response.json()
            for doc in data.get('data', []):
                attrs = doc['attributes']
                documents.append({
                    'source': 'regulations_gov',
                    'source_id': doc['id'],
                    'title': attrs.get('title', ''),
                    'doc_type': attrs.get('documentType', ''),
                    'abstract': attrs.get('summary', ''),
                    'publication_date': attrs.get('postedDate', ''),
                    'agencies': [attrs.get('agencyId', '')],
                    'url': f"https://www.regulations.gov/document/{doc['id']}",
                    'docket_id': attrs.get('docketId', ''),
                    'comment_deadline': attrs.get('commentEndDate'),
                    'content_hash': hashlib.sha256(json.dumps(attrs, sort_keys=True).encode()).hexdigest()
                })
        return documents

class LegiScanSource:
    BASE_URL = 'https://api.legiscan.com'
    
    def __init__(self, api_key: str, states: list[str]):
        self.api_key = api_key
        self.states = states
    
    async def fetch_recent(self, hours_back: int = 8) -> list[dict]:
        documents = []
        async with httpx.AsyncClient(timeout=60) as client:
            # Get master list for each state
            for state in self.states:
                response = await client.get(
                    self.BASE_URL,
                    params={'key': self.api_key, 'op': 'getMasterList', 'state': state}
                )
                response.raise_for_status()
                data = response.json()
                if data.get('status') == 'OK':
                    master = data.get('masterlist', {})
                    for bill_id, bill in master.items():
                        if bill_id == 'session':
                            continue
                        # Only include bills changed recently
                        last_action = bill.get('last_action_date', '')
                        if last_action:
                            action_date = datetime.strptime(last_action, '%Y-%m-%d')
                            if action_date >= datetime.utcnow() - timedelta(hours=hours_back * 6):
                                documents.append({
                                    'source': 'legiscan',
                                    'source_id': str(bill.get('bill_id', '')),
                                    'title': bill.get('title', ''),
                                    'doc_type': 'legislation',
                                    'abstract': bill.get('title', ''),
                                    'publication_date': last_action,
                                    'state': state,
                                    'bill_number': bill.get('number', ''),
                                    'status': bill.get('status', ''),
                                    'url': bill.get('url', ''),
                                    'last_action': bill.get('last_action', ''),
                                    'content_hash': hashlib.sha256(
                                        f"{bill.get('bill_id')}-{last_action}-{bill.get('status')}".encode()
                                    ).hexdigest()
                                })
        return documents


def build_ingestion_graph(config: dict) -> StateGraph:
    """Build the ingestion agent graph."""
    embeddings = OpenAIEmbeddings(model='text-embedding-3-large')
    pc = Pinecone(api_key=config['pinecone_api_key'])
    doc_index = pc.Index(config['pinecone_index_docs'])
    blob_service = BlobServiceClient.from_connection_string(config['azure_storage_conn'])
    container = blob_service.get_container_client('regulatory-docs')
    
    sources = {
        'federal_register': FederalRegisterSource(),
        'regulations_gov': RegulationsGovSource(config['regulations_gov_api_key']),
        'legiscan': LegiScanSource(config['legiscan_api_key'], config['legiscan_states']),
    }
    
    async def fetch_documents(state: IngestionState) -> IngestionState:
        source_name = state['source']
        try:
            source = sources[source_name]
            raw_docs = await source.fetch_recent(hours_back=config.get('polling_hours_back', 4))
            state['raw_documents'] = raw_docs
            state['stats']['fetched'] = len(raw_docs)
            logger.info(f'Fetched {len(raw_docs)} documents from {source_name}')
        except Exception as e:
            state['errors'].append(f'Fetch error from {source_name}: {str(e)}')
            logger.error(f'Fetch error from {source_name}: {e}')
        return state
    
    async def deduplicate(state: IngestionState) -> IngestionState:
        new_docs = []
        for doc in state['raw_documents']:
            # Check if content_hash already exists in Pinecone
            results = doc_index.query(
                vector=[0.0] * 3072,  # dummy vector
                filter={'content_hash': doc['content_hash']},
                top_k=1,
                include_metadata=True
            )
            if len(results['matches']) == 0:
                new_docs.append(doc)
        state['raw_documents'] = new_docs
        state['stats']['after_dedup'] = len(new_docs)
        logger.info(f'After dedup: {len(new_docs)} new documents')
        return state
    
    async def embed_and_store(state: IngestionState) -> IngestionState:
        processed = []
        for doc in state['raw_documents']:
            try:
                # Create embedding text from title + abstract
                embed_text = f"{doc['title']}\n\n{doc.get('abstract', '')}"
                if doc.get('agencies'):
                    embed_text += f"\n\nAgencies: {', '.join(doc['agencies'])}"
                if doc.get('cfr_references'):
                    embed_text += f"\nCFR References: {', '.join(doc['cfr_references'])}"
                
                # Generate embedding
                embedding = embeddings.embed_query(embed_text)
                
                # Store full document in Blob Storage
                blob_name = f"{doc['source']}/{doc['source_id']}.json"
                container.upload_blob(blob_name, json.dumps(doc), overwrite=True)
                
                # Store embedding in Pinecone
                doc_index.upsert(vectors=[{
                    'id': f"{doc['source']}-{doc['source_id']}",
                    'values': embedding,
                    'metadata': {
                        'source': doc['source'],
                        'source_id': doc['source_id'],
                        'title': doc['title'][:500],
                        'doc_type': doc.get('doc_type', ''),
                        'publication_date': doc.get('publication_date', ''),
                        'agencies': doc.get('agencies', []),
                        'content_hash': doc['content_hash'],
                        'blob_path': blob_name,
                        'ingested_at': datetime.utcnow().isoformat()
                    }
                }])
                
                doc['embedding_id'] = f"{doc['source']}-{doc['source_id']}"
                processed.append(doc)
                
            except Exception as e:
                state['errors'].append(f"Embed error for {doc.get('source_id')}: {str(e)}")
                logger.error(f"Embed error: {e}")
        
        state['processed_documents'] = processed
        state['new_document_ids'] = [d['embedding_id'] for d in processed]
        state['stats']['processed'] = len(processed)
        return state
    
    # Build the graph
    graph = StateGraph(IngestionState)
    graph.add_node('fetch', fetch_documents)
    graph.add_node('deduplicate', deduplicate)
    graph.add_node('embed_and_store', embed_and_store)
    
    graph.set_entry_point('fetch')
    graph.add_edge('fetch', 'deduplicate')
    graph.add_edge('deduplicate', 'embed_and_store')
    graph.add_edge('embed_and_store', END)
    
    return graph.compile()

Relevance Classifier Agent

Type: agent

The second agent in the pipeline. For each newly ingested regulatory document, it performs RAG against the practice area profile index to determine which practice areas are relevant, scores the relevance (0.0-1.0), and classifies priority (Critical/High/Medium/Low) based on document type, effective dates, and comment deadlines. Uses GPT-4.1 for nuanced legal text analysis.

Implementation

agents/relevance_classifier_agent.py
python
# agents/relevance_classifier_agent.py
import json
import logging
from datetime import datetime, timedelta
from typing import TypedDict
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.messages import SystemMessage, HumanMessage
from pinecone import Pinecone

logger = logging.getLogger(__name__)

class ClassificationState(TypedDict):
    document: dict
    practice_area_matches: list[dict]
    classifications: list[dict]
    priority: str
    should_alert: bool
    reasoning: str
    errors: list[str]

RELEVANCE_SYSTEM_PROMPT = """You are a regulatory analysis AI assistant working for a law firm. Your task is to analyze a regulatory document and determine its relevance to specific practice areas.

You will be given:
1. A regulatory document (title, abstract, agencies, CFR references)
2. A list of practice area profiles with their descriptions, keywords, and regulatory bodies

For each practice area, you must:
1. Determine if the document is relevant (true/false)
2. Assign a relevance score (0.0 to 1.0)
3. Classify the priority level
4. Provide a brief explanation of why this is or isn't relevant

Priority Classification Rules:
- CRITICAL: Final rules with effective dates within 90 days, or rules that fundamentally change compliance requirements
- HIGH: Proposed rules with comment deadlines within 30 days, or significant guidance changes
- MEDIUM: Proposed rules with comment deadlines > 30 days, or incremental regulatory updates
- LOW: Notices, requests for information, or tangentially related documents

IMPORTANT: Be precise. Law firms cannot afford false negatives (missing a relevant regulation). When in doubt, err on the side of inclusion with a lower relevance score rather than exclusion.

Respond in this exact JSON format:
{
  "analyses": [
    {
      "practice_area_id": "string",
      "practice_area_name": "string",
      "is_relevant": true/false,
      "relevance_score": 0.0-1.0,
      "priority": "CRITICAL|HIGH|MEDIUM|LOW",
      "reasoning": "2-3 sentence explanation",
      "key_impacts": ["specific impact 1", "specific impact 2"],
      "action_required": "brief description of what the attorney should do"
    }
  ],
  "overall_summary": "1-2 sentence summary of the regulatory change",
  "overall_priority": "CRITICAL|HIGH|MEDIUM|LOW"
}"""

def build_classifier_graph(config: dict) -> StateGraph:
    llm = ChatOpenAI(
        model='gpt-4.1-2025-04-14',
        temperature=0.1,
        api_key=config['openai_api_key']
    )
    
    llm_mini = ChatOpenAI(
        model='gpt-5.4-mini-2024-07-18',
        temperature=0.0,
        api_key=config['openai_api_key']
    )
    
    embeddings = OpenAIEmbeddings(model='text-embedding-3-large')
    pc = Pinecone(api_key=config['pinecone_api_key'])
    profile_index = pc.Index(config['pinecone_index_profiles'])
    relevance_threshold = config.get('relevance_threshold', 0.72)
    
    async def find_candidate_practice_areas(state: ClassificationState) -> ClassificationState:
        """Use vector similarity to find potentially relevant practice areas."""
        doc = state['document']
        embed_text = f"{doc['title']}\n{doc.get('abstract', '')}"
        query_embedding = embeddings.embed_query(embed_text)
        
        results = profile_index.query(
            vector=query_embedding,
            top_k=10,
            include_metadata=True
        )
        
        candidates = []
        for match in results['matches']:
            if match['score'] >= relevance_threshold * 0.8:  # Use 80% of threshold for candidate selection
                candidates.append({
                    'practice_area_id': match['id'].replace('pa-', ''),
                    'practice_area_name': match['metadata'].get('name', ''),
                    'vector_similarity': match['score'],
                    'attorney_emails': match['metadata'].get('attorney_emails', []),
                    'keywords': match['metadata'].get('keywords', []),
                    'regulatory_bodies': match['metadata'].get('regulatory_bodies', [])
                })
        
        state['practice_area_matches'] = candidates
        logger.info(f"Found {len(candidates)} candidate practice areas for '{doc['title'][:80]}'")
        return state
    
    async def classify_relevance(state: ClassificationState) -> ClassificationState:
        """Use GPT-4.1 to perform detailed relevance analysis."""
        doc = state['document']
        candidates = state['practice_area_matches']
        
        if not candidates:
            state['should_alert'] = False
            state['priority'] = 'NONE'
            state['reasoning'] = 'No practice areas matched above the similarity threshold.'
            state['classifications'] = []
            return state
        
        # Build the prompt
        doc_description = f"""REGULATORY DOCUMENT:
Title: {doc['title']}
Type: {doc.get('doc_type', 'Unknown')}
Source: {doc['source']}
Agencies: {', '.join(doc.get('agencies', ['Unknown']))}
CFR References: {', '.join(doc.get('cfr_references', []))}
Publication Date: {doc.get('publication_date', 'Unknown')}
Effective Date: {doc.get('effective_date', 'Not specified')}
Comment Deadline: {doc.get('comment_deadline', 'Not applicable')}
Abstract: {doc.get('abstract', 'No abstract available')}"""
        
        practice_areas_desc = "PRACTICE AREAS TO EVALUATE:\n"
        for c in candidates:
            practice_areas_desc += f"""\n---\nID: {c['practice_area_id']}
Name: {c['practice_area_name']}
Keywords: {', '.join(c['keywords'])}
Regulatory Bodies: {', '.join(c['regulatory_bodies'])}
Vector Similarity Score: {c['vector_similarity']:.3f}\n"""
        
        messages = [
            SystemMessage(content=RELEVANCE_SYSTEM_PROMPT),
            HumanMessage(content=f"{doc_description}\n\n{practice_areas_desc}")
        ]
        
        response = await llm.ainvoke(messages)
        
        try:
            # Parse JSON response (handle markdown code blocks)
            response_text = response.content.strip()
            if response_text.startswith('```'):
                response_text = response_text.split('\n', 1)[1].rsplit('```', 1)[0]
            result = json.loads(response_text)
            
            classifications = []
            for analysis in result.get('analyses', []):
                if analysis.get('is_relevant') and analysis.get('relevance_score', 0) >= relevance_threshold:
                    # Find matching candidate to get attorney emails
                    candidate = next(
                        (c for c in candidates if c['practice_area_id'] == analysis['practice_area_id']),
                        None
                    )
                    analysis['attorney_emails'] = candidate['attorney_emails'] if candidate else []
                    classifications.append(analysis)
            
            state['classifications'] = classifications
            state['should_alert'] = len(classifications) > 0
            state['priority'] = result.get('overall_priority', 'LOW')
            state['reasoning'] = result.get('overall_summary', '')
            
        except (json.JSONDecodeError, KeyError) as e:
            state['errors'].append(f'Classification parse error: {str(e)}')
            state['should_alert'] = False
            logger.error(f'Classification parse error: {e}')
        
        return state
    
    async def determine_routing(state: ClassificationState) -> str:
        if state['should_alert']:
            return 'alert'
        return 'skip'
    
    graph = StateGraph(ClassificationState)
    graph.add_node('find_candidates', find_candidate_practice_areas)
    graph.add_node('classify', classify_relevance)
    
    graph.set_entry_point('find_candidates')
    graph.add_edge('find_candidates', 'classify')
    graph.add_conditional_edges('classify', determine_routing, {'alert': END, 'skip': END})
    
    return graph.compile()

Alert Generator Agent

Type: agent

The third agent in the pipeline. Takes classified documents that passed the relevance threshold, generates attorney-friendly alert summaries, formats HTML emails with action items and deadlines, posts notes to the relevant Clio matters, and sends the alert payload to n8n for delivery routing.

Implementation:

agents/alert_generator_agent.py
python
# agents/alert_generator_agent.py
import json
import logging
import httpx
from datetime import datetime
from typing import TypedDict
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage

logger = logging.getLogger(__name__)

class AlertState(TypedDict):
    document: dict
    classifications: list[dict]
    priority: str
    reasoning: str
    alert_html: str
    alert_plain_text: str
    clio_notes: list[dict]
    delivery_status: dict
    errors: list[str]

ALERT_SUMMARY_PROMPT = """You are a legal regulatory analyst preparing an alert for attorneys. Write a concise but comprehensive summary of this regulatory change.

Your summary must include:
1. WHAT changed (1-2 sentences)
2. WHO is affected (agencies, industries, practice areas)
3. WHY it matters to the firm's clients (specific implications)
4. WHEN — key dates (effective date, comment deadline)
5. ACTION ITEMS — specific next steps for the attorney (review, file comments, notify clients, etc.)

Write in a professional, direct tone appropriate for busy attorneys. Do not use AI-sounding language. Do not hedge excessively. Be specific about deadlines and requirements.

Format the output as JSON:
{
  "headline": "10-word max headline",
  "executive_summary": "2-3 sentence overview",
  "key_changes": ["specific change 1", "specific change 2"],
  "affected_clients": "description of which client types are affected",
  "key_dates": [{"date": "YYYY-MM-DD", "description": "what this date means"}],
  "action_items": [{"priority": "HIGH|MEDIUM|LOW", "action": "specific action", "deadline": "YYYY-MM-DD or 'ASAP'"}],
  "source_citation": "proper legal citation of the source document"
}"""

def build_alert_generator_graph(config: dict) -> StateGraph:
    llm = ChatOpenAI(
        model='gpt-4.1-2025-04-14',
        temperature=0.2,
        api_key=config['openai_api_key']
    )
    
    n8n_webhook_url = config['n8n_webhook_url']
    n8n_webhook_token = config['n8n_webhook_token']
    
    async def generate_summary(state: AlertState) -> AlertState:
        doc = state['document']
        classifications = state['classifications']
        
        practice_area_context = '\n'.join([
            f"- {c['practice_area_name']}: {c.get('reasoning', '')}" 
            for c in classifications
        ])
        
        messages = [
            SystemMessage(content=ALERT_SUMMARY_PROMPT),
            HumanMessage(content=f"""DOCUMENT:
Title: {doc['title']}
Type: {doc.get('doc_type', '')}
Agencies: {', '.join(doc.get('agencies', []))}
Publication Date: {doc.get('publication_date', '')}
Effective Date: {doc.get('effective_date', 'Not specified')}
Comment Deadline: {doc.get('comment_deadline', 'Not applicable')}
Abstract: {doc.get('abstract', '')}
URL: {doc.get('url', '')}

RELEVANT PRACTICE AREAS:
{practice_area_context}""")
        ]
        
        response = await llm.ainvoke(messages)
        
        try:
            response_text = response.content.strip()
            if response_text.startswith('                response_text = response_text.split('\n', 1)[1].rsplit('            summary = json.loads(response_text)
        except json.JSONDecodeError:
            summary = {
                'headline': doc['title'][:60],
                'executive_summary': doc.get('abstract', 'See source document for details.'),
                'key_changes': [],
                'affected_clients': 'Review required',
                'key_dates': [],
                'action_items': [{'priority': 'HIGH', 'action': 'Review source document', 'deadline': 'ASAP'}],
                'source_citation': doc.get('url', '')
            }
        
        # Generate HTML email
        priority_colors = {'CRITICAL': '#dc3545', 'HIGH': '#fd7e14', 'MEDIUM': '#ffc107', 'LOW': '#28a745'}
        priority_color = priority_colors.get(state['priority'], '#6c757d')
        
        key_changes_html = ''.join([f'<li>{c}</li>' for c in summary.get('key_changes', [])])
        key_dates_html = ''.join([
            f"<tr><td style='padding:4px 8px;border:1px solid #dee2e6;'>{d['date']}</td><td style='padding:4px 8px;border:1px solid #dee2e6;'>{d['description']}</td></tr>"
            for d in summary.get('key_dates', [])
        ])
        action_items_html = ''.join([
            f"<li><strong>[{a['priority']}]</strong> {a['action']} — <em>by {a['deadline']}</em></li>"
            for a in summary.get('action_items', [])
        ])
        practice_areas_html = ', '.join([c['practice_area_name'] for c in classifications])
        
        state['alert_html'] = f"""
<div style='font-family:Calibri,Arial,sans-serif;max-width:700px;margin:0 auto;'>
  <div style='background:{priority_color};color:white;padding:12px 20px;border-radius:4px 4px 0 0;'>
    <h2 style='margin:0;font-size:18px;'>⚖️ Regulatory Alert: {state['priority']}</h2>
  </div>
  <div style='border:1px solid #dee2e6;border-top:none;padding:20px;'>
    <h3 style='margin-top:0;color:#333;'>{summary.get('headline', doc['title'][:60])}</h3>
    <p style='color:#555;font-size:15px;'>{summary.get('executive_summary', '')}</p>
    
    <h4 style='color:#333;border-bottom:1px solid #eee;padding-bottom:5px;'>Key Changes</h4>
    <ul>{key_changes_html}</ul>
    
    <h4 style='color:#333;border-bottom:1px solid #eee;padding-bottom:5px;'>Important Dates</h4>
    <table style='border-collapse:collapse;width:100%;'>
      <tr style='background:#f8f9fa;'><th style='padding:4px 8px;border:1px solid #dee2e6;text-align:left;'>Date</th><th style='padding:4px 8px;border:1px solid #dee2e6;text-align:left;'>Significance</th></tr>
      {key_dates_html}
    </table>
    
    <h4 style='color:#333;border-bottom:1px solid #eee;padding-bottom:5px;'>⚡ Action Items</h4>
    <ul>{action_items_html}</ul>
    
    <h4 style='color:#333;border-bottom:1px solid #eee;padding-bottom:5px;'>Affected Practice Areas</h4>
    <p>{practice_areas_html}</p>
    
    <div style='margin-top:20px;padding:12px;background:#f8f9fa;border-radius:4px;'>
      <strong>Source:</strong> <a href='{doc.get("url", "#")}'>{summary.get('source_citation', doc['title'])}</a><br/>
      <small style='color:#888;'>This alert was generated by AI regulatory monitoring. Attorney review is required before acting on this information or advising clients.</small>
    </div>
    
    <div style='margin-top:12px;text-align:center;'>
      <a href='{config.get("feedback_url", "#")}?alert_id={doc.get("source_id", "")}' style='display:inline-block;padding:8px 20px;background:#007bff;color:white;text-decoration:none;border-radius:4px;margin:0 5px;'>👍 Relevant</a>
      <a href='{config.get("feedback_url", "#")}?alert_id={doc.get("source_id", "")}&relevant=false' style='display:inline-block;padding:8px 20px;background:#6c757d;color:white;text-decoration:none;border-radius:4px;margin:0 5px;'>👎 Not Relevant</a>
    </div>
  </div>
</div>"""
        
        state['alert_plain_text'] = f"""REGULATORY ALERT [{state['priority']}]\n\n{summary.get('headline', '')}\n\n{summary.get('executive_summary', '')}\n\nSource: {doc.get('url', '')}\n\nThis alert was generated by AI regulatory monitoring. Attorney review required."""
        
        return state
    
    async def send_to_n8n(state: AlertState) -> AlertState:
        """Send the alert payload to n8n for routing and delivery."""
        all_attorney_emails = []
        for c in state['classifications']:
            all_attorney_emails.extend(c.get('attorney_emails', []))
        attorney_emails = list(set(all_attorney_emails))
        
        payload = {
            'alert_id': f"{state['document']['source']}-{state['document']['source_id']}",
            'priority': state['priority'],
            'practice_areas': [c['practice_area_name'] for c in state['classifications']],
            'title': state['document']['title'],
            'summary': state['reasoning'],
            'source_url': state['document'].get('url', ''),
            'effective_date': state['document'].get('effective_date'),
            'comment_deadline': state['document'].get('comment_deadline'),
            'attorneys': attorney_emails,
            'alert_html': state['alert_html'],
            'alert_plain_text': state['alert_plain_text'],
            'classifications': state['classifications'],
            'generated_at': datetime.utcnow().isoformat()
        }
        
        try:
            async with httpx.AsyncClient(timeout=30) as client:
                response = await client.post(
                    n8n_webhook_url,
                    json=payload,
                    headers={
                        'Authorization': f'Bearer {n8n_webhook_token}',
                        'Content-Type': 'application/json'
                    }
                )
                response.raise_for_status()
                state['delivery_status'] = {'sent': True, 'status_code': response.status_code}
                logger.info(f"Alert sent to n8n: {payload['alert_id']} -> {len(attorney_emails)} attorneys")
        except Exception as e:
            state['delivery_status'] = {'sent': False, 'error': str(e)}
            state['errors'].append(f'n8n delivery error: {str(e)}')
            logger.error(f'n8n delivery error: {e}')
        
        return state
    
    graph = StateGraph(AlertState)
    graph.add_node('generate_summary', generate_summary)
    graph.add_node('send_to_n8n', send_to_n8n)
    
    graph.set_entry_point('generate_summary')
    graph.add_edge('generate_summary', 'send_to_n8n')
    graph.add_edge('send_to_n8n', END)
    
    return graph.compile()

Master Pipeline Orchestrator

Type: workflow

The top-level LangGraph workflow that chains together the Ingestion Agent, Relevance Classifier Agent, and Alert Generator Agent. It processes each data source in sequence, fans out classification for each new document, and fans in alerts for batch delivery. Includes LangSmith tracing for full audit trail.

Implementation:

main.py — FastAPI application serving the agent pipeline
python
# main.py — FastAPI application serving the agent pipeline
import os
import json
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
from langsmith import traceable

from agents.ingestion_agent import build_ingestion_graph
from agents.relevance_classifier_agent import build_classifier_graph
from agents.alert_generator_agent import build_alert_generator_graph

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Load secrets from Azure Key Vault
credential = DefaultAzureCredential()
kv_uri = os.environ['AZURE_KEYVAULT_URI']
kv_client = SecretClient(vault_url=kv_uri, credential=credential)

def get_secret(name: str) -> str:
    return kv_client.get_secret(name).value

CONFIG = {
    'openai_api_key': get_secret('openai-api-key'),
    'pinecone_api_key': get_secret('pinecone-api-key'),
    'legiscan_api_key': get_secret('legiscan-api-key'),
    'regulations_gov_api_key': get_secret('regulations-gov-api-key'),
    'pinecone_index_docs': os.environ.get('PINECONE_INDEX_DOCS', 'regulatory-docs'),
    'pinecone_index_profiles': os.environ.get('PINECONE_INDEX_PROFILES', 'practice-profiles'),
    'azure_storage_conn': get_secret('azure-storage-connection-string'),
    'n8n_webhook_url': os.environ.get('N8N_WEBHOOK_URL', 'https://n8n-regmonitor.eastus.azurecontainer.io/webhook/regmonitor-alert'),
    'n8n_webhook_token': get_secret('n8n-webhook-token'),
    'relevance_threshold': float(os.environ.get('RELEVANCE_THRESHOLD', '0.72')),
    'legiscan_states': os.environ.get('LEGISCAN_STATES', 'CA,TX,NY,FL,IL').split(','),
    'polling_hours_back': int(os.environ.get('POLLING_HOURS_BACK', '4')),
    'feedback_url': os.environ.get('FEEDBACK_URL', 'https://app-regmonitor-agent.azurewebsites.net/feedback'),
    'internal_api_key': get_secret('internal-api-key')
}

# Build agent graphs
ingestion_graph = build_ingestion_graph(CONFIG)
classifier_graph = build_classifier_graph(CONFIG)
alert_graph = build_alert_generator_graph(CONFIG)

app = FastAPI(title='RegMonitor Agent Pipeline', version='1.0.0')

class PollRequest(BaseModel):
    sources: list[str] = ['federal_register', 'regulations_gov', 'legiscan']

@app.post('/api/v1/poll')
@traceable(name='regulatory_poll', project_name='regmonitor-prod')
async def poll_sources(request: PollRequest, x_api_key: str = Header()):
    if x_api_key != CONFIG['internal_api_key']:
        raise HTTPException(status_code=401, detail='Unauthorized')
    
    total_new = 0
    total_alerts = 0
    results = []
    
    for source in request.sources:
        logger.info(f'Polling source: {source}')
        
        # Step 1: Ingest
        ingestion_state = {
            'source': source,
            'raw_documents': [],
            'processed_documents': [],
            'new_document_ids': [],
            'errors': [],
            'stats': {}
        }
        ingestion_result = await ingestion_graph.ainvoke(ingestion_state)
        new_docs = ingestion_result['processed_documents']
        total_new += len(new_docs)
        
        # Step 2: Classify each new document
        for doc in new_docs:
            classification_state = {
                'document': doc,
                'practice_area_matches': [],
                'classifications': [],
                'priority': 'NONE',
                'should_alert': False,
                'reasoning': '',
                'errors': []
            }
            classification_result = await classifier_graph.ainvoke(classification_state)
            
            # Step 3: Generate and send alert if relevant
            if classification_result['should_alert']:
                alert_state = {
                    'document': doc,
                    'classifications': classification_result['classifications'],
                    'priority': classification_result['priority'],
                    'reasoning': classification_result['reasoning'],
                    'alert_html': '',
                    'alert_plain_text': '',
                    'clio_notes': [],
                    'delivery_status': {},
                    'errors': []
                }
                alert_result = await alert_graph.ainvoke(alert_state)
                total_alerts += 1
                results.append({
                    'source': source,
                    'document_id': doc.get('source_id'),
                    'title': doc.get('title', '')[:100],
                    'priority': classification_result['priority'],
                    'delivered': alert_result['delivery_status'].get('sent', False)
                })
    
    return {
        'status': 'completed',
        'sources_polled': request.sources,
        'new_documents': total_new,
        'alerts_generated': total_alerts,
        'alert_details': results
    }

@app.post('/api/v1/digest')
@traceable(name='daily_digest', project_name='regmonitor-prod')
async def daily_digest(x_api_key: str = Header()):
    if x_api_key != CONFIG['internal_api_key']:
        raise HTTPException(status_code=401, detail='Unauthorized')
    # Digest generation queries the last 24 hours of alerts
    # and compiles a summary email per attorney
    # Implementation follows same pattern as poll but aggregates
    return {'status': 'digest_sent'}

@app.get('/health')
async def health():
    return {'status': 'healthy', 'version': '1.0.0'}

n8n Alert Routing Workflow

Type: workflow

The n8n workflow JSON definition that receives alert payloads from the LangGraph pipeline, routes them by priority, sends formatted emails via Microsoft Graph API, posts notes to Clio matters, and sends Teams messages for critical alerts. Includes error handling with MSP notification.

Implementation:

n8n Alert Routing Workflow JSON definition
json
{
  "name": "RegMonitor Alert Routing",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "regmonitor-alert",
        "authentication": "headerAuth",
        "options": {}
      },
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "position": [250, 300]
    },
    {
      "parameters": {
        "rules": {
          "rules": [
            {"value": "CRITICAL", "output": 0},
            {"value": "HIGH", "output": 1},
            {"value": "MEDIUM", "output": 2},
            {"value": "LOW", "output": 3}
          ]
        },
        "dataPropertyName": "priority"
      },
      "name": "Route by Priority",
      "type": "n8n-nodes-base.switch",
      "position": [450, 300]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "fromEmail": "regulatory-alerts@firmname.com",
        "toRecipients": "={{ $json.attorneys.join(', ') }}",
        "subject": "[{{ $json.priority }}] Regulatory Alert: {{ $json.title }}",
        "bodyContent": "={{ $json.alert_html }}",
        "bodyContentType": "html",
        "additionalFields": {
          "importance": "={{ $json.priority === 'CRITICAL' ? 'high' : 'normal' }}"
        }
      },
      "name": "Send Email via Graph API",
      "type": "n8n-nodes-base.microsoftOutlook",
      "position": [700, 300],
      "credentials": {
        "microsoftOutlookOAuth2Api": "MS Graph - RegMonitor"
      }
    },
    {
      "parameters": {
        "url": "https://app.clio.com/api/v4/notes.json",
        "method": "POST",
        "authentication": "oAuth2",
        "body": {
          "data": {
            "subject": "Regulatory Alert: {{ $json.title }}",
            "detail": "{{ $json.alert_plain_text }}",
            "type": "Note"
          }
        }
      },
      "name": "Post to Clio Matter",
      "type": "n8n-nodes-base.httpRequest",
      "position": [900, 300],
      "credentials": {
        "oAuth2Api": "Clio OAuth2"
      }
    },
    {
      "parameters": {
        "url": "https://graph.microsoft.com/v1.0/teams/{team-id}/channels/{channel-id}/messages",
        "method": "POST",
        "body": {
          "body": {
            "contentType": "html",
            "content": "<b>🚨 CRITICAL Regulatory Alert</b><br/>{{ $json.title }}<br/><a href='{{ $json.source_url }}'>View Source</a>"
          }
        }
      },
      "name": "Teams Critical Alert",
      "type": "n8n-nodes-base.httpRequest",
      "position": [700, 100]
    },
    {
      "parameters": {
        "url": "https://msp-webhook.example.com/alerts",
        "method": "POST",
        "body": {
          "alert": "RegMonitor delivery failure",
          "client": "{{ $json.firm_name }}",
          "error": "{{ $json.error_message }}"
        }
      },
      "name": "MSP Error Notification",
      "type": "n8n-nodes-base.httpRequest",
      "position": [900, 500]
    }
  ],
  "connections": {
    "Webhook Trigger": {"main": [[{"node": "Route by Priority", "type": "main", "index": 0}]]},
    "Route by Priority": {
      "main": [
        [{"node": "Teams Critical Alert", "type": "main", "index": 0}, {"node": "Send Email via Graph API", "type": "main", "index": 0}],
        [{"node": "Send Email via Graph API", "type": "main", "index": 0}],
        [{"node": "Send Email via Graph API", "type": "main", "index": 0}],
        [{"node": "Send Email via Graph API", "type": "main", "index": 0}]
      ]
    },
    "Send Email via Graph API": {"main": [[{"node": "Post to Clio Matter", "type": "main", "index": 0}]]},
    "Teams Critical Alert": {"main": [[{"node": "Send Email via Graph API", "type": "main", "index": 0}]]}
  },
  "settings": {
    "errorWorkflow": "MSP Error Notification"
  }
}
Note

This is a simplified representation of the n8n workflow. When importing into n8n, you will need to complete the following configuration steps.

1
Configure the Microsoft Graph OAuth2 credentials with the App Registration from Step 2
2
Configure the Clio OAuth2 credentials with the client ID/secret from Step 3
3
Replace {team-id} and {channel-id} with actual Microsoft Teams identifiers
4
Replace the MSP webhook URL with your actual monitoring endpoint
5
Add the webhook authentication header token in the Webhook Trigger node settings

Attorney Feedback Collection Integration

Type: integration

A lightweight feedback mechanism embedded in every alert email. Attorneys click 'Relevant' or 'Not Relevant' buttons which hit a FastAPI endpoint. Feedback is stored and used to periodically retune the relevance threshold and improve practice area embeddings.

Implementation

FastAPI feedback endpoint — add to main.py
python
# Add to main.py

from fastapi import Query
from azure.storage.blob import BlobServiceClient

feedback_container = BlobServiceClient.from_connection_string(
    CONFIG['azure_storage_conn']
).get_container_client('audit-logs')

@app.get('/feedback')
async def record_feedback(
    alert_id: str = Query(...),
    relevant: bool = Query(default=True),
    attorney: Optional[str] = Query(default=None)
):
    """Record attorney feedback on alert relevance.
    Called when attorney clicks thumbs up/down in alert email."""
    feedback = {
        'alert_id': alert_id,
        'relevant': relevant,
        'attorney_email': attorney,
        'timestamp': datetime.utcnow().isoformat()
    }
    
    # Store feedback in Azure Blob Storage
    blob_name = f"feedback/{datetime.utcnow().strftime('%Y-%m')}/{alert_id}-{datetime.utcnow().timestamp()}.json"
    feedback_container.upload_blob(blob_name, json.dumps(feedback))
    
    logger.info(f"Feedback recorded: {alert_id} -> {'relevant' if relevant else 'not relevant'}")
    
    # Return a simple thank-you page
    return {
        'message': 'Thank you for your feedback. This helps improve alert accuracy.',
        'recorded': True
    }
Monthly threshold tuning script: scripts/tune_threshold.py
bash
python3 scripts/tune_threshold.py --month 2025-01
1
Loads all feedback for the month from Azure Blob Storage
2
Correlates feedback with the relevance scores from LangSmith traces
3
Calculates precision (% of alerts marked relevant) and recall estimation
4
Suggests threshold adjustment if precision < 80% or estimated recall < 95%
5
Outputs a report for MSP review before any changes are applied

Testing & Validation

  • FEDERAL REGISTER API CONNECTIVITY: Run the curl command below and verify a 200 response with valid JSON containing at least one document result.
  • REGULATIONS.GOV API CONNECTIVITY: Run the curl command below and verify a 200 response. Confirm rate limit headers show remaining quota.
  • LEGISCAN API CONNECTIVITY: Run the curl command below and verify a response with status=OK and a list of congressional sessions.
  • PINECONE INDEX VERIFICATION: Run the Python test script that queries both indexes (regulatory-docs and practice-profiles) with a dummy vector and verify they return results without errors. Confirm dimension matches (3072 for text-embedding-3-large).
  • PRACTICE AREA EMBEDDING QUALITY: Query the practice-profiles index with a known-relevant regulation title (e.g., 'EPA Proposes New PFAS Drinking Water Standards') and verify that the environmental law practice area appears in the top-3 results with a similarity score > 0.75.
  • INGESTION PIPELINE END-TO-END: Trigger a manual poll of federal_register source via POST to /api/v1/poll with sources=['federal_register']. Verify the response shows new_documents > 0 and no errors. Check Pinecone dashboard to confirm new vectors were added.
  • RELEVANCE CLASSIFICATION ACCURACY: Run the classifier against 20 manually-selected regulatory documents (10 known-relevant, 10 known-irrelevant to the firm's practice areas). Verify at least 90% classification accuracy. Document false positives and false negatives.
  • ALERT EMAIL DELIVERY: Trigger a test alert via the n8n webhook endpoint with a sample payload. Verify the email arrives in the test attorney's inbox within 5 minutes, renders correctly in Outlook (desktop and mobile), and contains all required fields (headline, summary, action items, source link, feedback buttons).
  • CLIO INTEGRATION: Verify that after an alert is delivered, a corresponding note appears on the correct matter in Clio Manage. Check that the note contains the alert summary, priority, and source URL.
  • FEEDBACK LOOP: Click both the 'Relevant' and 'Not Relevant' buttons in a test alert email. Verify that feedback records appear in the Azure Blob Storage audit-logs/feedback/ container with correct alert_id and relevance values.
  • LANGSMITH TRACE AUDIT: Open the LangSmith dashboard and verify that a complete trace exists for the test poll, showing all three agent stages (ingestion, classification, alert generation) with input/output data, token counts, and latency metrics.
  • SHADOW MODE VERIFICATION: Set SHADOW_MODE=true and trigger a poll. Verify that alerts are only delivered to the SHADOW_RECIPIENTS addresses and NOT to regular attorneys.
  • DAILY DIGEST: Trigger the /api/v1/digest endpoint manually. Verify that a compiled summary email is generated covering all alerts from the past 24 hours, grouped by practice area and sorted by priority.
  • ERROR HANDLING: Temporarily set an invalid OpenAI API key and trigger a poll. Verify that (a) the error is caught gracefully, (b) an error notification is sent to the MSP via the n8n error workflow, (c) the system does not crash, and (d) the error appears in Application Insights.
  • HISTORICAL BACKFILL VALIDATION: After running the 6-month backfill, generate the tuning report and review with 2-3 senior attorneys. Verify that at least 85% of documents they identify as relevant have relevance scores above the configured threshold. Adjust threshold if needed.
  • LOAD TESTING: Simulate processing 100 regulatory documents in a single poll cycle. Verify the pipeline completes within 15 minutes, Azure App Service CPU stays below 80%, and all alerts are delivered successfully.
  • SECURITY VALIDATION: Attempt to access /api/v1/poll without the X-API-Key header and verify a 401 response. Attempt to access the n8n webhook without the Bearer token and verify rejection. Verify all API traffic uses TLS 1.2+ by checking Azure App Service TLS settings.
Federal Register API connectivity test
bash
# run from Azure App Service console

curl https://www.federalregister.gov/api/v1/documents.json?per_page=1
Regulations.gov API connectivity test
bash
curl -H "X-Api-Key: <YOUR_KEY>" https://api.regulations.gov/v4/documents?page[size]=1
LegiScan API connectivity test
bash
curl "https://api.legiscan.com/?key=<YOUR_KEY>&op=getSessionList&state=US"

Client Handoff

The client handoff should be a structured half-day session with the managing partner and all attorneys who will receive alerts. Cover the following topics:

1
SYSTEM OVERVIEW (30 min): Explain at a high level how the system works—it polls government databases every 2-4 hours, AI analyzes relevance to the firm's practice areas, and delivers alerts via email. Emphasize that AI assists but does NOT provide legal advice—all alerts require attorney review.
2
ABA ETHICS COMPLIANCE (15 min): Review how the system satisfies Model Rules 1.1 (competence—attorneys understand the tool), 1.6 (confidentiality—no client PII sent to AI APIs), and 1.4 (communication—firm should disclose AI use to clients). Provide a template client disclosure letter.
3
ALERT WALKTHROUGH (30 min): Walk through 3-5 real alerts from the burn-in period. Show the email format, explain priority levels (Critical/High/Medium/Low), demonstrate how to read the action items, and show where to find the source document link.
4
FEEDBACK TRAINING (15 min): Show attorneys how to use the thumbs-up/thumbs-down buttons in each alert. Explain that their feedback directly improves future alert accuracy. Set expectation: provide feedback on at least 80% of alerts during the first month.
5
DASHBOARD DEMO (20 min): Walk through the monitoring dashboard showing recent alerts, coverage by practice area, and regulatory calendar with upcoming deadlines.
6
CLIO INTEGRATION (15 min): Show how alerts appear as notes in Clio matters. Explain the mapping between practice areas and matters.
7
ESCALATION PROCEDURES (10 min): Explain who to contact if (a) an alert seems wrong, (b) the system stops sending alerts, (c) they want to add a new practice area or keyword. Provide the MSP support contact and SLA.

Documentation to Leave Behind

  • Attorney Quick Reference Card (1-page PDF): Alert priority levels, feedback instructions, escalation contacts
  • Practice Area Taxonomy Document (editable): Current configuration showing all monitored practice areas, keywords, and attorney assignments
  • System Architecture Diagram: High-level diagram for the firm's IT records
  • ABA Compliance Memo: Documentation of how the system satisfies Formal Opinion 512 requirements
  • Client Disclosure Template: Suggested language for informing clients about AI-assisted regulatory monitoring
  • MSP Service Level Agreement: Response times, uptime guarantees, maintenance windows

Success Criteria to Review

Maintenance

ONGOING MSP RESPONSIBILITIES:

1. Daily Monitoring (5–10 min/day)

  • Check Azure Monitor dashboard for App Service health (CPU < 80%, memory < 85%, no 5xx errors)
  • Review LangSmith dashboard for pipeline execution success rate (target: > 99%)
  • Verify n8n workflow execution logs show successful deliveries
  • Check OpenAI usage dashboard to track cost trajectory against budget

2. Weekly Review (30 min/week)

  • Review attorney feedback data: calculate precision rate (target: > 80% alerts marked relevant)
  • Check for any new government API deprecation notices or rate limit changes
  • Review Application Insights for any recurring errors or performance degradation
  • Confirm Azure Functions scheduler ran all expected executions (verify no missed polls)

3. Monthly Maintenance (2–4 hours/month)

  • Run the threshold tuning script (scripts/tune_threshold.py) and review recommendations
  • Apply prompt optimizations based on LangSmith trace analysis (improve classification accuracy)
  • Update Python dependencies (pip install --upgrade) and test in staging before production
  • Review and rotate API keys if policy requires (update in Azure Key Vault)
  • Generate monthly report for the firm: alerts sent, feedback scores, coverage gaps, cost summary
  • Invoice the client for the monthly managed service fee

4. Quarterly Review (4–8 hours/quarter)

  • Meet with managing partner to review practice area taxonomy—add/remove areas as firm evolves
  • Re-embed practice area profiles if keywords or descriptions changed significantly
  • Evaluate new LLM models (e.g., GPT-4.1 successor) for cost/quality improvements
  • Review government API changes (Federal Register, Regulations.gov versioning)
  • Assess whether additional data sources are needed (e.g., specific state agency RSS feeds)
  • Update attorney roster (new hires, departures) in the practice area taxonomy

5. Annual Tasks

  • Full system security audit including API key rotation, access review, and penetration testing
  • ABA compliance review—update documentation for any new ethics opinions on AI use
  • Renegotiate SaaS vendor contracts (Pinecone, LegiScan) based on actual usage
  • Architecture review—assess whether to upgrade compute tier, add data sources, or migrate components

SLA Considerations

  • System Uptime: 99.5% (allows ~44 hours downtime/year for maintenance)
  • Alert Delivery: Within 4 hours of regulatory document publication during business hours
  • Critical Alert Escalation: MSP responds within 1 hour to system-down alerts during business hours
  • Maintenance Windows: Sundays 2–6 AM local time, with 48-hour advance notice to firm
  • Data Retention: All regulatory documents and audit logs retained for 7 years (legal industry standard)

Escalation Path

  • Tier 1 (MSP Help Desk): Attorney questions about alerts, feedback issues, basic troubleshooting
  • Tier 2 (MSP Engineer): Pipeline failures, API errors, Clio integration issues, threshold tuning
  • Tier 3 (MSP Solutions Architect): Architecture changes, new data source integration, major prompt rewrites
  • Vendor Escalation: OpenAI/Pinecone/LegiScan support for API issues beyond MSP control

Model Retraining / Prompt Update Triggers

  • Attorney feedback precision drops below 75% for two consecutive weeks
  • New practice area added or existing one significantly changed
  • Government API schema changes requiring ingestion pipeline updates
  • New LLM model release with significant cost or quality improvements
  • Regulatory landscape shift (e.g., new major legislation creating new compliance domains)

Alternatives

SaaS-First with Regology

Instead of building a custom agent pipeline, subscribe to Regology's regulatory change management platform. Regology provides a pre-built Smart Law Library with AI-powered regulatory tracking, automatic updates, and customizable coverage by practice area. The MSP configures the platform, sets up user accounts, and manages the integration with the firm's email and practice management systems.

  • COST: Higher per-user cost (~$1,250/user/month for Pro tier vs. ~$200-400/month total for custom build), but dramatically lower implementation cost ($8,000-$20,000 vs. $45,000-$75,000).
  • TIMELINE: 4-6 weeks vs. 12-19 weeks.
  • COMPLEXITY: Low (Tier 1-2 MSP technician) vs. Moderate (requires Python/AI developer).
  • CAPABILITY: Comprehensive out-of-box regulatory content but less customizable to firm-specific needs.
  • MSP MARGIN: Lower—reselling a SaaS product offers 10-20% margin vs. 50-150% on custom-built services.
  • BEST FOR: Firms wanting fast time-to-value, firms in highly regulated sectors (finance, healthcare law) where Regology has deep content, or MSPs without AI development capability.

LegiScan GAITS Pro + Email Automation (MVP Approach)

Deploy LegiScan GAITS Pro for legislative tracking with keyword-based email alerts, supplemented by Federal Register email subscriptions (federalregister.gov native email alerts). No custom AI agents—uses the built-in alerting of these government platforms plus simple email forwarding rules in Microsoft 365 to route alerts to the right attorneys. The MSP configures search queries, email rules, and provides a shared regulatory tracking spreadsheet.

Tradeoffs

  • COST: Very low—$50-$150/month for LegiScan + $0 for Federal Register email alerts. Implementation cost under $5,000.
  • TIMELINE: 1-2 weeks.
  • COMPLEXITY: Very low (Tier 1 MSP technician).
  • CAPABILITY: Basic keyword matching only—no AI relevance scoring, no summarization, no priority classification, no Clio integration. High false-positive rate (every keyword match triggers an alert). Attorneys must manually review and triage all alerts.
  • MSP MARGIN: Moderate—sell at $800-$1,500/month for a simple service.
  • BEST FOR: Very small firms (1-5 attorneys), firms with tight budgets, or as a Phase 1 MVP to demonstrate value before upselling to the full AI solution.

Azure OpenAI Service with Private Endpoints

Replace direct OpenAI API calls with Azure OpenAI Service, which provides the same GPT-4.1 and GPT-5.4 mini models but within the firm's Azure tenant. Data never leaves the Azure boundary and is not used for model training. Add Azure Private Endpoints to ensure all traffic between the App Service, Azure OpenAI, and other Azure resources stays on the Microsoft backbone network—never traversing the public internet.

  • COST: Same token pricing as direct OpenAI, plus ~$50-$100/month for Private Endpoints and Azure networking components. Slightly more complex Azure setup.
  • TIMELINE: Adds 1-2 weeks to implementation for Azure OpenAI provisioning (requires Microsoft approval for GPT-4 access) and Private Endpoint configuration.
  • CAPABILITY: Identical model performance but significantly stronger compliance posture. Required for firms handling classified, ITAR, or highly sensitive client data.
  • BEST FOR: AmLaw 200 firms, firms in financial services or healthcare law, firms with CJIS or FedRAMP requirements, or any firm where the managing partner requires maximum data sovereignty assurance.

CrewAI-Based Agent Pipeline

Replace LangGraph with CrewAI as the multi-agent orchestration framework. CrewAI uses a role-based agent paradigm where each agent has a defined role, goal, and backstory. The Ingestion Agent, Relevance Classifier, and Alert Generator would be defined as CrewAI agents with specific tools (API connectors, vector search) assigned to each.

  • COST: Similar—CrewAI is also open-source (Apache 2.0 license). CrewAI Enterprise available for managed hosting.
  • COMPLEXITY: CrewAI has a simpler API for defining agents but less fine-grained control over state management and routing compared to LangGraph.
  • CAPABILITY: CrewAI's built-in validation and approval nodes are convenient for human-in-the-loop workflows. However, LangGraph's state checkpointing and interrupt functionality provide stronger audit trail capabilities, which is why LangGraph is the primary recommendation for legal services.
  • BEST FOR: MSPs with existing CrewAI expertise, or projects where the agent interactions are more collaborative (agents discussing with each other) rather than sequential pipeline-style processing.

On-Premises Deployment with Local LLM

Deploy the entire solution on-premises using a Dell PowerEdge R360 server with an NVIDIA L4 GPU, running a local LLM (e.g., Llama 3.1 70B or Mistral Large via Ollama or vLLM). All data processing happens on the firm's hardware—no external API calls for LLM inference. Vector database runs locally using Qdrant instead of Pinecone.

Tradeoffs

  • COST: Higher upfront ($8,000-$12,000 for server + GPU + NAS) but lower recurring ($0 for LLM API calls). Break-even vs. cloud at approximately 18-24 months.
  • COMPLEXITY: Significantly higher—requires MSP to manage GPU drivers, model updates, and local infrastructure.
  • CAPABILITY: Local LLMs (even 70B parameter models) are currently less capable than GPT-4.1 for nuanced legal text analysis. Expect 10-20% lower classification accuracy. Inference is slower (~30-60 seconds per document vs. 5-10 seconds with GPT-4.1 API).
  • BEST FOR: Firms with strict data-sovereignty requirements where no client-adjacent data can leave the premises, firms in national security or government contracting law, or firms located in regions with poor internet connectivity.

Want early access to the full toolkit?