From 999bd2ccf96322f9d7f557c3e4fff843917ff703 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 12 Jan 2026 13:27:45 -0500 Subject: [PATCH] Add notification intelligence example Multi-agent notification routing system with 10+ specialist reasoners, priority classification, and a full-stack demo including: - Python agent with orchestration logic - FastAPI demo bridge with WebSocket support - Next.js frontend for interactive testing - Docker Compose for easy deployment Co-Authored-By: Claude --- examples/README.md | 2 + .../notification_intelligence/.env.example | 5 + .../notification_intelligence/.gitignore | 37 + .../notification_intelligence/ARCHITECTURE.md | 318 +++ .../notification_intelligence/Dockerfile | 25 + .../notification_intelligence/README.md | 893 +++++++ .../notification_intelligence/demo/Dockerfile | 16 + .../notification_intelligence/demo/api.py | 634 +++++ .../demo/requirements.txt | 6 + .../demo/scenarios.py | 320 +++ .../notification_intelligence/demo/state.py | 234 ++ .../demo/websocket.py | 314 +++ .../docker-compose.yml | 65 + .../notification_intelligence/main.py | 39 + .../notification_intelligence/models.py | 139 ++ .../notification_intelligence/reasoners.py | 928 ++++++++ .../requirements.txt | 2 + .../notification_intelligence/ui/Dockerfile | 40 + .../ui/app/globals.css | 169 ++ .../ui/app/layout.tsx | 19 + .../notification_intelligence/ui/app/page.tsx | 517 ++++ .../ui/components.json | 17 + .../ui/components/DashboardShell.tsx | 55 + .../ui/components/DecisionGraph.tsx | 594 +++++ .../ui/components/InputPanel.tsx | 514 ++++ .../ui/hooks/useAgentStream.ts | 125 + .../ui/next-env.d.ts | 6 + .../ui/next.config.js | 6 + .../ui/package-lock.json | 2091 +++++++++++++++++ .../notification_intelligence/ui/package.json | 25 + .../ui/postcss.config.js | 6 + .../ui/public/.gitkeep | 0 .../ui/tailwind.config.js | 12 + .../ui/tsconfig.json | 41 + 34 files changed, 8214 insertions(+) create mode 100644 examples/python_agent_nodes/notification_intelligence/.env.example create mode 100644 examples/python_agent_nodes/notification_intelligence/.gitignore create mode 100644 examples/python_agent_nodes/notification_intelligence/ARCHITECTURE.md create mode 100644 examples/python_agent_nodes/notification_intelligence/Dockerfile create mode 100644 examples/python_agent_nodes/notification_intelligence/README.md create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/Dockerfile create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/api.py create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/requirements.txt create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/scenarios.py create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/state.py create mode 100644 examples/python_agent_nodes/notification_intelligence/demo/websocket.py create mode 100644 examples/python_agent_nodes/notification_intelligence/docker-compose.yml create mode 100644 examples/python_agent_nodes/notification_intelligence/main.py create mode 100644 examples/python_agent_nodes/notification_intelligence/models.py create mode 100644 examples/python_agent_nodes/notification_intelligence/reasoners.py create mode 100644 examples/python_agent_nodes/notification_intelligence/requirements.txt create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/Dockerfile create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/app/globals.css create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/app/layout.tsx create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/app/page.tsx create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/components.json create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/components/DashboardShell.tsx create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/components/DecisionGraph.tsx create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/components/InputPanel.tsx create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/hooks/useAgentStream.ts create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/next-env.d.ts create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/next.config.js create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/package-lock.json create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/package.json create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/postcss.config.js create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/public/.gitkeep create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/tailwind.config.js create mode 100644 examples/python_agent_nodes/notification_intelligence/ui/tsconfig.json diff --git a/examples/README.md b/examples/README.md index f0fe07c82..402b6d00a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ This directory contains example agents demonstrating AgentField's capabilities a | **Deep Research** | [deep_research_agent](python_agent_nodes/deep_research_agent/) | - | - | | **Image Generation** | [image_generation_hello_world](python_agent_nodes/image_generation_hello_world/) | - | - | | **Multi-Agent Simulation** | [simulation_engine](python_agent_nodes/simulation_engine/) | [simulation](ts-node-examples/simulation/) | - | +| **Notification Intelligence** | [notification_intelligence](python_agent_nodes/notification_intelligence/) | - | - | | **Serverless Deployment** | [serverless_hello](python_agent_nodes/serverless_hello/) | [serverless-hello](ts-node-examples/serverless-hello/) | - | | **Verifiable Credentials** | - | [verifiable-credentials](ts-node-examples/verifiable-credentials/) | - | @@ -31,6 +32,7 @@ This directory contains example agents demonstrating AgentField's capabilities a | [documentation_chatbot](python_agent_nodes/documentation_chatbot/) | Enterprise RAG system | 3-reasoner architecture, Markdown-aware chunking, Inline citations | | [rag_evaluation](python_agent_nodes/rag_evaluation/) | Multi-metric QA assessment | Faithfulness, Relevance, Hallucination detection, Constitutional checks. [Docs →](https://agentfield.ai/examples/complete-agents/rag-evaluator) | | [simulation_engine](python_agent_nodes/simulation_engine/) | Domain-agnostic multi-agent simulation | 100+ parallel reasoners, Scenario analysis, Sentiment modeling | +| [notification_intelligence](python_agent_nodes/notification_intelligence/) | Smart notification routing | 10+ specialist reasoners, Priority classification, Full-stack demo (Docker) | | [serverless_hello](python_agent_nodes/serverless_hello/) | Serverless deployment pattern | Lambda/Cloud Functions handler, Cross-agent calling | ### TypeScript Examples diff --git a/examples/python_agent_nodes/notification_intelligence/.env.example b/examples/python_agent_nodes/notification_intelligence/.env.example new file mode 100644 index 000000000..296bf7256 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/.env.example @@ -0,0 +1,5 @@ +# Copy this to .env and fill in your values +OPENROUTER_API_KEY=your_key_here + +# Optional: Change the AI model (default: gpt-4o-mini) +# AI_MODEL=openrouter/anthropic/claude-3-haiku diff --git a/examples/python_agent_nodes/notification_intelligence/.gitignore b/examples/python_agent_nodes/notification_intelligence/.gitignore new file mode 100644 index 000000000..7727a709a --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ + +# Node +node_modules/ +.next/ +npm-debug.log +yarn-error.log + +# Environment (keep .env.example, ignore actual .env) +.env +.env.local +.env.*.local + +# Build artifacts +*.egg-info/ +dist/ +build/ +tsconfig.tsbuildinfo + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/examples/python_agent_nodes/notification_intelligence/ARCHITECTURE.md b/examples/python_agent_nodes/notification_intelligence/ARCHITECTURE.md new file mode 100644 index 000000000..ca2075197 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ARCHITECTURE.md @@ -0,0 +1,318 @@ +# Notification Intelligence - Multi-Agent Architecture + +## Philosophy: Autonomous Software as Backend Intelligence + +This agent demonstrates **guided autonomy** - a multi-reasoner system where: +- AI makes strategic decisions within bounded domains +- Multiple specialists run in parallel and synthesize results +- System adapts reasoning depth based on data quality +- Intelligence emerges from composition, not individual models + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ORCHESTRATION LAYER │ +│ (Main Reasoners - Visible in Workflow UI) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ learn_from_feedback decide_notification_strategy │ +│ │ │ │ +│ ├── Parallel ──────────────────────┤ │ +│ │ │ │ +│ ┌────┴────┐ ┌───┴────┐ │ +│ │ Intent │ │Decision│ │ +│ │Extract │ │Analysis│ │ +│ └────┬────┘ └───┬────┘ │ +└─────────┼───────────────────────────────────┼───────────────┘ + │ │ +┌─────────┼────────────────────────────────────┼───────────────┐ +│ │ SPECIALIST REASONERS │ │ +│ │ (Parallel Execution) │ │ +├─────────┼────────────────────────────────────┼───────────────┤ +│ │ │ │ +│ ┌────▼────────┬────────────┬─────────┐ │ │ +│ │ Importance │ Action │ Channel │ │ │ +│ │ Intent │ Intent │ Intent │ │ │ +│ └─────────────┴────────────┴─────────┘ │ │ +│ │ │ │ +│ ┌────▼─────┐ ┌────▼────────────┐ │ +│ │Synthesize│ │ Urgency │ │ +│ │ Insights │ │ Analysis │ │ +│ └──────────┘ ├─────────────────┤ │ +│ │ Channel │ │ +│ │ Analysis │ │ +│ ├─────────────────┤ │ +│ │ Timing │ │ +│ │ Analysis │ │ +│ └────┬────────────┘ │ +│ │ │ +│ ┌────▼──────┐ │ +│ │Synthesize │ │ +│ │ Decision │ │ +│ └───────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +## Reasoner Hierarchy + +### **Level 1: Orchestrators** (User-facing endpoints) + +These are called by external systems via HTTP API. + +#### `learn_from_feedback` +**Purpose:** Learn from user's interaction with a notification + +**Workflow:** +``` +1. Fetch user model from ACTOR memory +2. Call 3 specialist reasoners in PARALLEL: + - extract_importance_intent + - extract_action_intent + - extract_channel_intent +3. Call synthesize_feedback_insights (combines results) +4. Update user model in ACTOR memory +5. Return insights discovered +``` + +**Cost:** 4 AI calls (3 parallel + 1 synthesis) + +#### `decide_notification_strategy` +**Purpose:** Recommend how to handle a new notification + +**Adaptive Routing:** +- **Fast Path** (sample_size > 10): 1 AI call with learned model +- **Deep Path** (sample_size ≤ 10): 4 AI calls in sequence: + 1. analyze_urgency + analyze_channel (parallel) + 2. analyze_timing (uses urgency results) + 3. synthesize_decision (combines all) + +**Cost:** 1 AI call (fast) or 4 AI calls (deep) + +### **Level 2: Specialist Reasoners** (Focused analysis) + +Each specialist reasons within a **narrow domain** for precision. + +#### Intent Extraction Specialists + +**`extract_importance_intent`** +- Analyzes WHY user rated notification at specific importance +- Returns: priority category, mental model, implicit preferences + +**`extract_action_intent`** +- Analyzes WHY user took action (opened/dismissed/ignored) +- Returns: behavior signal, timing quality, engagement reason + +**`extract_channel_intent`** +- Analyzes channel effectiveness from user response +- Returns: effectiveness rating, numerical score, recommendation + +**`synthesize_feedback_insights`** +- Combines all intent analyses into unified insights +- Returns: patterns discovered, preference updates, confidence + +#### Decision Analysis Specialists + +**`analyze_urgency`** +- Focuses ONLY on how urgent notification is +- Returns: priority score (0-100), time sensitivity, can_wait flag + +**`analyze_channel`** +- Focuses ONLY on which channel to use +- Returns: best channel, reasoning, backup option + +**`analyze_timing`** +- Focuses ONLY on when to send +- Returns: timing decision (immediate/delay/batch), delay_until, reasoning + +**`synthesize_decision`** +- Combines urgency + channel + timing into final recommendation +- Returns: action, priority, channel, timing, comprehensive reasoning + +## Memory Architecture + +### ACTOR Scope (Per-User Models) + +```python +# Stored at: actor_mem.set("preference_model", ...) +# Scope: router.memory.actor(user_id) + +{ + "notification_preferences": { + "payment_failed": "critical", + "order_shipped": "high", + "marketing": "low" + }, + "channel_effectiveness": { + "email": 0.3, # 30% engagement + "sms": 0.8, # 80% engagement + "push": 0.6, # 60% engagement + "in-app": 0.0 # Not tested yet + }, + "timing_patterns": { + "best_hours": [9, 10, 14, 19], + "quiet_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7] + }, + "sample_size": 15 # Number of feedback samples +} +``` + +### Learning Loop + +``` +User interacts → Feedback → Intent extraction (parallel) → +Synthesis → Update model → Better decisions next time +``` + +## Guided Autonomy Principles + +###1. **Bounded Reasoning** +Each specialist has a **narrow focus** (urgency OR channel OR timing). +- Prevents hallucination +- Improves accuracy +- Enables parallelization + +### 2. **Adaptive Depth** +System **decides HOW to reason** based on data quality: +- **High confidence** (>10 samples): Fast path, trust the model +- **Low confidence** (≤10 samples): Deep path, multi-specialist analysis + +### 3. **Parallel Composition** +Independent analyses run **simultaneously**: +- Intent extraction: 3 specialists in parallel +- Decision analysis: urgency + channel in parallel +- **Reduces latency** while maintaining quality + +### 4. **Synthesis Over Single Shot** +Never ask one LLM to do everything: +- Specialists analyze their domain +- **Orchestrator synthesizes** results +- Emergent intelligence from composition + +## Cost Analysis + +### Per Notification Decision + +**New User (sample_size = 0):** +``` +Deep Path: +- analyze_urgency: 1 call +- analyze_channel: 1 call (parallel with urgency) +- analyze_timing: 1 call +- synthesize_decision: 1 call +Total: 4 AI calls +``` + +**Experienced User (sample_size > 10):** +``` +Fast Path: +- Single reasoner with learned model: 1 call +Total: 1 AI call +``` + +**Cost reduction:** 75% after learning phase! + +### Per Feedback Learning + +``` +Intent extraction (always): +- extract_importance_intent: 1 call +- extract_action_intent: 1 call (parallel) +- extract_channel_intent: 1 call (parallel) +- synthesize_feedback_insights: 1 call +Total: 4 AI calls +``` + +**Amortized cost:** Each feedback improves all future decisions. + +## Production Backend Features + +### 1. **Batch Feedback Processing** +```python +# Process 100 users' feedback in parallel +await app.call("notification-intelligence.process_feedback_batch", + feedback_items=[...]) +``` + +### 2. **Notification Queue Optimization** +```python +# Reorder/group pending notifications intelligently +await app.call("notification-intelligence.optimize_notification_queue", + pending_notifications=[...]) +``` + +### 3. **User Segmentation Analysis** +```python +# Discover user segments by behavior +await app.call("notification-intelligence.analyze_user_segments", + user_ids=[...]) +``` + +### 4. **Temporal Pattern Analysis** +```python +# Find optimal send times across population +await app.call("notification-intelligence.analyze_optimal_times", + historical_data=[...]) +``` + +## Observability + +Every reasoner creates **notes** visible in AgentField UI: +- Which path was taken (fast vs deep) +- Parallel execution indicators +- Synthesis reasoning +- Model updates + +**Workflow DAG shows:** +- Orchestrator → Specialist calls +- Parallel execution branches +- Synthesis points +- Memory reads/writes + +## Key Differentiators + +**vs. Rule-Based Systems:** +- ✅ Handles edge cases gracefully +- ✅ Adapts to user preferences implicitly +- ✅ Improves over time without code changes + +**vs. Single-LLM Approaches:** +- ✅ More accurate (specialists > generalist) +- ✅ Faster (parallelization) +- ✅ Cheaper (fast path after learning) +- ✅ Observable (workflow graph) + +**vs. Traditional ML:** +- ✅ No training data needed upfront +- ✅ Explainable (reasoning in plain English) +- ✅ Zero-shot works, improves with feedback +- ✅ Handles novel situations + +## Scalability + +**Horizontal:** +- Each reasoner is stateless +- Parallel execution across users +- Control plane handles routing + +**Vertical:** +- Fast path reduces cost 75% +- Batch operations amortize overhead +- Memory queries are O(1) + +**Cost-Effective:** +- New users: 4-5 calls per decision +- After 10 samples: 1 call per decision +- Self-optimizing system + +## Use Cases + +This architecture works for any domain needing **intelligent decision-making**: + +- **E-commerce:** Product recommendations, pricing optimization +- **Healthcare:** Patient triage, treatment suggestions +- **Finance:** Fraud detection, risk assessment +- **SaaS:** Feature recommendations, churn prevention +- **Logistics:** Route optimization, delivery scheduling + +**The pattern is universal:** Multiple specialists → Parallel execution → Synthesis diff --git a/examples/python_agent_nodes/notification_intelligence/Dockerfile b/examples/python_agent_nodes/notification_intelligence/Dockerfile new file mode 100644 index 000000000..25b2d490a --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY main.py . +COPY reasoners.py . +COPY models.py . + +# Expose port +EXPOSE 8001 + +# Health check endpoint +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD curl -f http://localhost:8001/health || exit 1 + +# Run the agent +CMD ["python", "main.py"] diff --git a/examples/python_agent_nodes/notification_intelligence/README.md b/examples/python_agent_nodes/notification_intelligence/README.md new file mode 100644 index 000000000..5c6ba7ec1 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/README.md @@ -0,0 +1,893 @@ +# Adaptive Multi-Agent Notification Intelligence + +**Visual multi-agent orchestration with self-improving AI reasoning** + +This example demonstrates cutting-edge multi-agent patterns: +- **Parallel specialist reasoners** creating impressive workflow graphs +- **Adaptive orchestration** that evolves with learning +- **Meta-level intelligence** that optimizes itself +- **Production-ready** cost/quality optimization + +## Architecture Highlights + +### Visual Workflow Graphs + +The system creates beautiful workflow visualizations in the AgentField UI: + +**New User (Full Analysis):** +``` + ┌─→ Urgency Specialist + ├─→ Channel Specialist +Route Notification ─┼─→ User State Specialist ─→ Synthesis ─→ Decision + ├─→ Timing Specialist + └─→ Context Specialist +``` +5 parallel specialists converging to synthesis (6 AI calls) + +**Learning User (Moderate Analysis):** +``` + ┌─→ Urgency Specialist +Route Notification ─┼─→ Channel Specialist ─→ Synthesis ─→ Decision + └─→ Timing Specialist +``` +3 core specialists (4 AI calls, 33% cost reduction) + +**Confident User (Streamlined):** +``` + ┌─→ Urgency Specialist +Route Notification ─┼─→ Channel Specialist ─→ Quick Synthesis ─→ Decision + └ +``` +2 specialists only (3 AI calls, 50% cost reduction) + +### Learning Graph + +Separate workflow for continuous improvement: +``` + ┌─→ Behavior Pattern Extractor +Learn from Feedback─┼─→ Channel Insight Extractor ─→ Store in Memory + └─→ Preference Signal Extractor +``` +3 parallel learning specialists (3 AI calls) + +## Quick Start (Docker) + +The recommended way to run this example is with Docker Compose, which starts all services automatically. + +### 1. Set Up Environment + +```bash +# Copy the example env file +cp .env.example .env + +# Edit .env and add your API key +# OPENROUTER_API_KEY=your_key_here +``` + +### 2. Start All Services + +```bash +docker compose up +``` + +This starts: +- **Control Plane** at `http://localhost:8080` - AgentField workflow visualization +- **Agent** at `http://localhost:8001` - Notification intelligence agent +- **Demo API** at `http://localhost:8000` - FastAPI backend with WebSocket support +- **Frontend UI** at `http://localhost:3000` - Interactive demo interface + +### 3. Open the Demo + +Visit `http://localhost:3000` to use the interactive UI, or use the API directly. + +--- + +## Manual Setup (Alternative) + +If you prefer to run services manually without Docker: + +### 1. Install Dependencies +```bash +pip install -r requirements.txt +``` + +### 2. Set Environment Variables +```bash +export OPENAI_API_KEY="your-openai-key" +# OR use OpenRouter +export OPENROUTER_API_KEY="your-openrouter-key" +``` + +### 3. Start AgentField Control Plane +```bash +# Terminal 1 +cd ../../control-plane +go run ./cmd/af dev +``` + +### 4. Start Agent +```bash +# Terminal 2 +cd examples/notification-intelligence +python main.py +``` + +### 5. Start Demo API (Optional) +```bash +# Terminal 3 +cd demo +uvicorn api:app --host 0.0.0.0 --port 8000 --reload +``` + +### 6. Start Frontend UI (Optional) +```bash +# Terminal 4 +cd ui +npm install && npm run dev +``` + +Agent endpoints: +- Direct: `http://localhost:8001` +- Via control plane: `http://localhost:8080/api/v1/execute/notification-intelligence.*` + +## Demo Scenarios + +### Scenario 1: New User - Full 5-Specialist Analysis + +**Context:** First time encountering this user. System uses ALL specialists for maximum insight. + +**Expected Graph:** Beautiful 5-way parallel star pattern → synthesis node + +```bash +# Step 1: First decision (impressive full analysis) +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_type": "payment_failed", + "notification_data": { + "amount": 149.99, + "card_last4": "4242", + "merchant": "Premium Service" + }, + "context": { + "user_timezone": "America/Los_Angeles", + "current_time": "2025-01-24T14:30:00Z", + "user_tier": "premium" + } + } + }' +``` + +**What to observe in AgentField UI:** +- 5 parallel specialist nodes (urgency, channel, user state, timing, context) +- Each specialist's AI reasoning visible in notes +- Convergence to synthesis node +- Final decision with priority score +- `pattern_count: 0` → triggers full analysis + +**Expected Output:** +```json +{ + "execution_id": "exec_xxx", + "status": "succeeded", + "result": { + "decision": { + "deliver": "now", + "channel": "sms", + "priority": 95, + "reasoning": "High-priority payment failure requires immediate attention..." + }, + "pattern_count": 0, + "analysis_depth": "full" + } +} +``` + +--- + +### Scenario 2: User Provides Feedback - Learning Graph + +**Context:** User interacted with the notification. System learns from their behavior. + +**Expected Graph:** 3 parallel learning specialists extracting insights + +```bash +# Step 2: User opened notification quickly - teach the system +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.learn_from_feedback \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_id": "notif-001", + "notification_type": "payment_failed", + "user_response": { + "action_taken": "opened", + "time_to_action": 12, + "importance_rating": 5, + "channel_used": "sms" + } + } + }' +``` + +**What to observe in AgentField UI:** +- Separate learning workflow graph +- 3 parallel insight extraction specialists +- Behavior, channel, and preference patterns extracted +- Storage to actor memory (user-specific) +- `sample_size` increments + +**Expected Output:** +```json +{ + "result": { + "patterns_learned": 3, + "sample_size": 1, + "insights": [ + { + "pattern": "User responds urgently to payment failures", + "strength": 0.9, + "applies_to": "payment_failed" + }, + { + "pattern": "SMS highly effective for this user", + "strength": 0.85, + "applies_to": "all" + }, + { + "pattern": "Critical financial notifications prioritized", + "strength": 0.9, + "applies_to": "payment_failed,billing_*" + } + ] + } +} +``` + +--- + +### Scenario 3: Add More Learning Data + +**Context:** Multiple interactions teach the system user preferences + +```bash +# Feedback 2: Order shipped notification +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.learn_from_feedback \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_id": "notif-002", + "notification_type": "order_shipped", + "user_response": { + "action_taken": "opened", + "time_to_action": 300, + "importance_rating": 3, + "channel_used": "email" + } + } + }' + +# Feedback 3: Marketing notification +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.learn_from_feedback \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_id": "notif-003", + "notification_type": "marketing_promo", + "user_response": { + "action_taken": "dismissed", + "time_to_action": 2, + "importance_rating": 1, + "channel_used": "push" + } + } + }' +``` + +**After 3 feedbacks:** User now has 9 learned patterns (3 per feedback, top 10 kept) + +--- + +### Scenario 4: Maturing User - Moderate 3-Specialist Analysis + +**Context:** After 3+ feedbacks, system uses moderate analysis (cost optimization) + +**Expected Graph:** 3 specialists (urgency, channel, timing) → streamlined synthesis + +```bash +# Step 3: New notification with learned context (3 specialists) +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_type": "order_shipped", + "notification_data": { + "order_id": "ORD-789", + "tracking": "1Z999AA1234567890", + "estimated_delivery": "2025-01-26" + }, + "context": { + "user_timezone": "America/Los_Angeles", + "current_time": "2025-01-24T18:00:00Z", + "user_tier": "premium" + } + } + }' +``` + +**What to observe in AgentField UI:** +- Only 3 specialists now (not 5!) +- Faster execution (40% fewer AI calls) +- Still high-quality decision +- `pattern_count: 9` → triggers moderate analysis +- System leverages learned patterns from memory + +**Expected Output:** +```json +{ + "result": { + "decision": { + "deliver": "now", + "channel": "email", + "priority": 65, + "reasoning": "Based on learned patterns, user prefers email for shipping updates..." + }, + "pattern_count": 9, + "analysis_depth": "moderate" + } +} +``` + +--- + +### Scenario 5: Continue Learning to Reach Streamlined Mode + +```bash +# Add 7 more feedbacks to reach 10+ patterns +for i in {4..10}; do + curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.learn_from_feedback \ + -H "Content-Type: application/json" \ + -d "{ + \"input\": { + \"user_id\": \"demo-user-001\", + \"notification_id\": \"notif-00$i\", + \"notification_type\": \"various_types\", + \"user_response\": { + \"action_taken\": \"opened\", + \"time_to_action\": 60, + \"importance_rating\": 3, + \"channel_used\": \"email\" + } + } + }" +done +``` + +--- + +### Scenario 6: Confident User - Streamlined 2-Specialist Analysis + +**Context:** After 10+ feedbacks, system is confident and uses minimal analysis + +**Expected Graph:** Only 2 specialists (urgency + channel) → quick synthesis + +```bash +# Step 4: Streamlined analysis (maximum efficiency) +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_type": "account_update", + "notification_data": { + "update_type": "profile_changed", + "fields": ["email", "phone"] + }, + "context": { + "user_timezone": "America/Los_Angeles", + "current_time": "2025-01-25T09:00:00Z", + "user_tier": "premium" + } + } + }' +``` + +**What to observe in AgentField UI:** +- Only 2 specialists! (urgency + channel) +- 50% cost reduction vs full analysis +- Fast execution +- Still intelligent decision based on learned patterns +- `pattern_count: 10+` → triggers streamlined + +**Expected Output:** +```json +{ + "result": { + "decision": { + "deliver": "now", + "channel": "email", + "priority": 50, + "reasoning": "Routine update, user prefers email based on history..." + }, + "pattern_count": 30, + "analysis_depth": "streamlined" + } +} +``` + +--- + +### Scenario 7: Multi-User Comparison + +**Context:** Show different users getting different orchestration depths + +```bash +# New user - gets full analysis +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "new-user-999", + "notification_type": "payment_failed", + "notification_data": {"amount": 99.99}, + "context": { + "user_timezone": "America/New_York", + "current_time": "2025-01-24T15:00:00Z", + "user_tier": "free" + } + } + }' + +# Learned user - gets moderate analysis +curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "user_id": "demo-user-001", + "notification_type": "payment_failed", + "notification_data": {"amount": 99.99}, + "context": { + "user_timezone": "America/Los_Angeles", + "current_time": "2025-01-24T15:00:00Z", + "user_tier": "premium" + } + } + }' +``` + +**Compare the workflow graphs side-by-side in AgentField UI!** + +--- + +## Understanding the Workflow Graphs + +### Graph Elements + +1. **Specialist Nodes** (parallel branches) + - Each specialist reasoner appears as a separate node + - Emit notes showing their analysis + - Run in parallel for speed + - Tag: `[specialist, urgency/channel/etc]` + +2. **Synthesis Node** (convergence point) + - Where parallel branches merge + - Combines all specialist perspectives + - Tag: `[synthesis, decision]` + +3. **Orchestrator Node** (entry point) + - `route_notification` - decides depth + - Tag: `[orchestration, full/moderate/streamlined]` + +4. **Learning Nodes** (separate graph) + - Appears when feedback is processed + - 3 parallel insight extractors + - Tag: `[learning, behavior/channel/preference]` + +### Reading the Notes + +Each node emits structured notes: + +``` +⚡ Urgency: 95/100 (immediate) # Urgency specialist +📱 Channel: sms (confidence: 0.92) # Channel specialist +👤 User: active, engagement 85% # User state specialist +⏰ Timing: now - critical payment issue # Timing specialist +🔍 Context: critical priority - ... # Context specialist +🧩 Synthesizing 5 specialist perspectives... # Synthesis +✨ Final: now via sms (priority 95/100) # Final decision +``` + +--- + +## Cost Optimization Notes + +### Current Configuration (Visual Demo Mode) + +- **New users:** 6 AI calls (5 specialists + synthesis) +- **Learning users:** 4 AI calls (3 specialists + synthesis) +- **Confident users:** 3 AI calls (2 specialists + synthesis) +- **Learning:** 3 AI calls per feedback (3 parallel specialists) + +**Average across user lifecycle:** ~4 AI calls per decision + +### Production Optimization Options + +To reduce costs while maintaining quality: + +#### Option 1: Remove Context Specialist +```python +# In orchestrate_full_analysis, remove: +# context_task = router.app.call(...) +``` +**Savings:** -17% AI calls (5→4 specialists) + +#### Option 2: Remove User State Specialist +```python +# Remove user_state_task +``` +**Savings:** -17% AI calls + +#### Option 3: Start at Moderate (skip full) +```python +# In route_notification, change: +if pattern_count < 3: + # Use moderate instead of full +``` +**Savings:** -33% for new users + +#### Option 4: Single-shot for confident users +```python +# Skip specialists entirely for 10+ patterns +# Use direct AI call with learned context +``` +**Savings:** -67% for experienced users + +**Recommended production config:** Remove context specialist, start at moderate +- **New users:** 4 AI calls +- **Learning users:** 4 AI calls +- **Confident:** 3 AI calls +- **Average:** ~3.5 AI calls per decision +- **Savings:** 30% cost reduction, minimal quality impact + +--- + +## Memory Architecture + +### Actor Scope (Per-User Learning) + +Each user has private memory storing: + +```python +{ + "learned_patterns": [ + { + "pattern": "Responds urgently to payment issues", + "strength": 0.9, + "applies_to": "payment_failed" + }, + # ... top 10 patterns by strength + ], + "channel_effectiveness": { + "email": 0.65, # 65% engagement rate + "sms": 0.92, # 92% engagement rate + "push": 0.45, # 45% engagement rate + "app": 0.55 # 55% engagement rate + }, + "sample_size": 30 # Number of feedback samples +} +``` + +**Key:** `router.memory.actor(user_id)` + +### Workflow Scope (Execution Context) + +Temporary state during decision workflow: + +```python +{ + "notification_type": "payment_failed", + "user_context": {...}, + "final_decision": {...} +} +``` + +**Key:** `router.memory.set()` (auto-scoped to workflow) + +### Global Scope (System-Wide Patterns) + +Cross-user insights (future enhancement): + +```python +{ + "optimal_timing_patterns": { + "peak_hours": [9, 10, 14, 15, 19], + "avoid_hours": [0, 1, 2, 3, 4, 5, 6, 7] + } +} +``` + +**Key:** `router.memory.global_scope` + +--- + +## API Reference + +### Main Endpoints + +#### route_notification +**Purpose:** Make notification decision with adaptive orchestration + +**Input:** +```json +{ + "user_id": "string", + "notification_type": "string", + "notification_data": { + "amount": 99.99, + "custom_field": "value" + }, + "context": { + "user_timezone": "America/Los_Angeles", + "current_time": "ISO-8601", + "user_tier": "premium" + } +} +``` + +**Output:** +```json +{ + "decision": { + "deliver": "now|delay|batch|skip", + "channel": "email|sms|push|app", + "priority": 85, + "reasoning": "explanation" + }, + "pattern_count": 5, + "analysis_depth": "full|moderate|streamlined" +} +``` + +#### learn_from_feedback +**Purpose:** Extract insights from user interaction + +**Input:** +```json +{ + "user_id": "string", + "notification_id": "string", + "notification_type": "string", + "user_response": { + "action_taken": "opened|dismissed|ignored", + "time_to_action": 120, + "importance_rating": 4, + "channel_used": "sms" + } +} +``` + +**Output:** +```json +{ + "patterns_learned": 3, + "sample_size": 15, + "insights": [ + { + "pattern": "description", + "strength": 0.85, + "applies_to": "notification_types" + } + ] +} +``` + +--- + +## Production Integration Example + +```python +import requests + +AGENTFIELD_API = "http://localhost:8080/api/v1/execute" + +class NotificationIntelligence: + """Production wrapper for notification intelligence agent.""" + + def decide(self, user_id, notification_type, data, context): + """Get AI recommendation for notification handling.""" + response = requests.post( + f"{AGENTFIELD_API}/notification-intelligence.route_notification", + json={ + "input": { + "user_id": user_id, + "notification_type": notification_type, + "notification_data": data, + "context": context + } + }, + timeout=10 + ) + return response.json()["result"]["decision"] + + def learn(self, user_id, notification_id, notification_type, user_response): + """Teach the system from user behavior.""" + response = requests.post( + f"{AGENTFIELD_API}/notification-intelligence.learn_from_feedback", + json={ + "input": { + "user_id": user_id, + "notification_id": notification_id, + "notification_type": notification_type, + "user_response": user_response + } + }, + timeout=10 + ) + return response.json()["result"] + +# Usage +agent = NotificationIntelligence() + +# Get decision +decision = agent.decide( + user_id="user-123", + notification_type="payment_failed", + data={"amount": 99.99}, + context={ + "user_timezone": "America/Los_Angeles", + "current_time": "2025-01-24T15:00:00Z", + "user_tier": "premium" + } +) + +# Execute notification based on decision +if decision["deliver"] == "now": + send_notification( + channel=decision["channel"], + priority=decision["priority"] + ) + +# Later, when user interacts +learning = agent.learn( + user_id="user-123", + notification_id="notif-456", + notification_type="payment_failed", + user_response={ + "action_taken": "opened", + "time_to_action": 30, + "channel_used": "sms" + } +) + +print(f"System learned {learning['patterns_learned']} new patterns") +``` + +--- + +## Key Innovations + +### 1. Visual Multi-Agent Orchestration +- Each specialist appears as a node in workflow graph +- Parallel execution creates impressive star patterns +- Synthesis nodes show convergence +- Educational and production-ready + +### 2. Adaptive Intelligence +- New users: Deep analysis (learn quickly) +- Learning users: Balanced analysis (optimize) +- Confident users: Streamlined (minimize cost) +- Automatically evolves per user + +### 3. Continuous Learning +- Every feedback creates learning workflow +- 3 parallel insight extractors +- Top-10 pattern retention +- Channel effectiveness tracking + +### 4. Cost Optimization +- Starts expensive, gets cheaper +- 50-67% cost reduction for learned users +- Quality maintained through learned patterns +- Configurable depth vs cost tradeoff + +--- + +## Use Cases + +### E-commerce +- Payment failures → instant SMS +- Order updates → email digest +- Marketing → skip low-engagement users + +### SaaS +- Critical alerts → immediate push +- Feature updates → batch weekly +- Usage warnings → adaptive timing + +### Healthcare +- Test results → urgent SMS +- Appointment reminders → day-before email +- Wellness tips → skip non-engaged + +### Fintech +- Fraud alerts → instant multi-channel +- Transaction updates → learned preference +- Account notices → optimized timing + +--- + +## Development Tips + +### Viewing Workflow Graphs + +1. Open the control plane UI at `http://localhost:8080` +2. Navigate to "Workflows" tab +3. Each execution shows as a card +4. Click to see full graph visualization +5. Hover nodes to see notes and timing + +### Using the Frontend UI + +1. Open `http://localhost:3000` in your browser +2. Select a pre-built scenario or configure a custom one +3. Click "Run" to execute and watch the decision graph animate in real-time +4. View the agent's reasoning and final decision + +### Docker Commands + +```bash +# Start all services +docker compose up + +# Start in background +docker compose up -d + +# View logs +docker compose logs -f + +# Rebuild after code changes +docker compose up --build + +# Stop all services +docker compose down + +# Reset data (clear AgentField state) +docker compose down -v +``` + +### Testing Learning Progression + +```bash +# Quick script to simulate user lifecycle +for i in {1..15}; do + # Make decision + curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.route_notification \ + -H "Content-Type: application/json" \ + -d "{\"input\": {\"user_id\": \"test-user\", \"notification_type\": \"test_$i\", \"notification_data\": {}, \"context\": {\"user_timezone\": \"UTC\", \"current_time\": \"2025-01-24T12:00:00Z\", \"user_tier\": \"free\"}}}" + + # Provide feedback + curl -X POST http://localhost:8080/api/v1/execute/notification-intelligence.learn_from_feedback \ + -H "Content-Type: application/json" \ + -d "{\"input\": {\"user_id\": \"test-user\", \"notification_id\": \"n$i\", \"notification_type\": \"test_$i\", \"user_response\": {\"action_taken\": \"opened\", \"time_to_action\": 60, \"channel_used\": \"email\"}}}" +done +``` + +Watch the workflow graphs evolve from 5-specialist → 3-specialist → 2-specialist! + +--- + +## Architecture Philosophy + +This example embodies **guided autonomy**: + +- **Guided**: Specialists have focused domains, orchestrator controls depth +- **Autonomous**: AI self-determines optimal paths, learns patterns +- **Visual**: Every decision visible in workflow graph +- **Production**: Cost-optimized, scalable, enterprise-ready + +**The future of backend systems: software that thinks strategically and improves continuously.** diff --git a/examples/python_agent_nodes/notification_intelligence/demo/Dockerfile b/examples/python_agent_nodes/notification_intelligence/demo/Dockerfile new file mode 100644 index 000000000..3d786f7a5 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the API +CMD ["python", "api.py"] diff --git a/examples/python_agent_nodes/notification_intelligence/demo/api.py b/examples/python_agent_nodes/notification_intelligence/demo/api.py new file mode 100644 index 000000000..28e0c6305 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/api.py @@ -0,0 +1,634 @@ +""" +Demo API for Notification Intelligence UI. + +Provides REST endpoints for triggering scenarios, submitting feedback, +and WebSocket for real-time agent event streaming. +""" + +import os +import uuid +import httpx +from datetime import datetime +from contextlib import asynccontextmanager +from typing import Optional +from fastapi import FastAPI, HTTPException, WebSocket +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from scenarios import ( + get_scenario, + get_all_scenarios, + get_demo_user, + get_all_demo_users, + Scenario, +) +from state import demo_state, ExecutionStatus, AgentEvent +from websocket import ( + websocket_endpoint, + init_sse_bridge, + sse_bridge, + manager, +) + + +# Configuration +NOTIFICATION_AGENT_URL = os.getenv("NOTIFICATION_AGENT", "http://localhost:8001") +AGENTFIELD_SERVER_URL = os.getenv("AGENTFIELD_SERVER", "http://localhost:8080") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown events.""" + import sys + print(f"[Lifespan] Starting up, AGENTFIELD_SERVER_URL={AGENTFIELD_SERVER_URL}", flush=True) + # Initialize SSE bridge + bridge = init_sse_bridge(AGENTFIELD_SERVER_URL) + print(f"[Lifespan] Bridge initialized, starting...", flush=True) + await bridge.start() + print(f"[Lifespan] Bridge started", flush=True) + yield + # Cleanup + print(f"[Lifespan] Shutting down...", flush=True) + await bridge.stop() + + +app = FastAPI( + title="Notification Intelligence Demo API", + description="Interactive demo for multi-agent notification orchestration", + version="1.0.0", + lifespan=lifespan, +) + +# CORS for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Request/Response Models +class TriggerRequest(BaseModel): + scenario_id: str + user_id: str + overrides: Optional[dict] = None + + +class CustomTriggerRequest(BaseModel): + """Request for custom user-defined scenarios.""" + notification_type: str # abandoned_cart, flash_sale, back_in_stock, price_drop, shipping_update + notification_data: dict # Type-specific data + context: Optional[dict] = None # Optional context overrides + user_id: str = "user_new_001" # Default demo user + + +class TriggerResponse(BaseModel): + execution_id: str + scenario_id: str + user_id: str + status: str + + +class FeedbackRequest(BaseModel): + user_id: str + notification_id: str + response: str # opened, ignored, dismissed + + +class UserStatsResponse(BaseModel): + user_id: str + pattern_count: int + analysis_mode: str + specialists_used: int + notifications_sent: int + open_rate: float + channel_effectiveness: dict + + +# REST Endpoints + +@app.get("/") +async def root(): + """Health check.""" + return {"status": "ok", "service": "notification-intelligence-demo"} + + +@app.get("/scenarios") +async def list_scenarios(): + """List all available demo scenarios.""" + scenarios = get_all_scenarios() + return { + "scenarios": [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "notification_type": s.notification_type.value, + } + for s in scenarios + ] + } + + +@app.get("/scenarios/{scenario_id}") +async def get_scenario_detail(scenario_id: str): + """Get full details of a scenario.""" + scenario = get_scenario(scenario_id) + if not scenario: + raise HTTPException(status_code=404, detail="Scenario not found") + + return { + "id": scenario.id, + "name": scenario.name, + "description": scenario.description, + "notification_type": scenario.notification_type.value, + "notification_data": scenario.notification_data, + "context": scenario.context, + } + + +@app.get("/users") +async def list_users(): + """List all demo users with their learning states.""" + users = get_all_demo_users() + states = demo_state.get_all_user_states() + state_map = {s.user_id: s for s in states} + + return { + "users": [ + { + "id": u["id"], + "name": u["name"], + "description": u["description"], + "pattern_count": state_map.get(u["id"], u).pattern_count + if hasattr(state_map.get(u["id"]), "pattern_count") + else u.get("pattern_count", 0), + "analysis_mode": state_map.get(u["id"]).analysis_mode + if u["id"] in state_map + else u.get("analysis_mode", "full"), + "specialists_used": state_map.get(u["id"]).specialists_used + if u["id"] in state_map + else u.get("specialists_used", 5), + } + for u in users + ] + } + + +@app.get("/users/{user_id}/stats") +async def get_user_stats(user_id: str) -> UserStatsResponse: + """Get detailed stats for a user.""" + state = demo_state.get_user_state(user_id) + if not state: + raise HTTPException(status_code=404, detail="User not found") + + return UserStatsResponse( + user_id=state.user_id, + pattern_count=state.pattern_count, + analysis_mode=state.analysis_mode, + specialists_used=state.specialists_used, + notifications_sent=state.notifications_sent, + open_rate=state.open_rate, + channel_effectiveness=state.channel_effectiveness, + ) + + +@app.post("/trigger", response_model=TriggerResponse) +async def trigger_notification(request: TriggerRequest): + """ + Trigger a notification scenario. + + This calls the notification-intelligence agent with the scenario + data and streams the execution events via WebSocket. + """ + scenario = get_scenario(request.scenario_id) + if not scenario: + raise HTTPException(status_code=404, detail="Scenario not found") + + user = demo_state.get_user_state(request.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate execution ID + execution_id = f"exec_{uuid.uuid4().hex[:12]}" + + # Create execution in state + execution = await demo_state.create_execution( + execution_id=execution_id, + user_id=request.user_id, + scenario_id=request.scenario_id, + ) + + # Set SSE bridge to filter to this execution + if sse_bridge: + sse_bridge.set_execution_id(execution_id) + + # Broadcast execution started + await manager.broadcast({ + "type": "execution_started", + "execution_id": execution_id, + "scenario": { + "id": scenario.id, + "name": scenario.name, + "notification_type": scenario.notification_type.value, + }, + "user": { + "id": user.user_id, + "analysis_mode": user.analysis_mode, + "specialists_used": user.specialists_used, + }, + "timestamp": datetime.now().isoformat(), + }) + + # Call notification-intelligence agent asynchronously + # Don't await - let it run and stream events + import asyncio + asyncio.create_task( + _execute_notification(execution_id, scenario, request.user_id, request.overrides) + ) + + return TriggerResponse( + execution_id=execution_id, + scenario_id=request.scenario_id, + user_id=request.user_id, + status="started", + ) + + +@app.post("/trigger-custom", response_model=TriggerResponse) +async def trigger_custom_notification(request: CustomTriggerRequest): + """ + Trigger a custom user-defined notification scenario. + + Allows users to specify their own notification data and context + instead of using predefined scenarios. + """ + # Validate notification type + valid_types = ["abandoned_cart", "flash_sale", "back_in_stock", "price_drop", "shipping_update"] + if request.notification_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"Invalid notification_type. Must be one of: {valid_types}" + ) + + user = demo_state.get_user_state(request.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate execution ID + execution_id = f"exec_{uuid.uuid4().hex[:12]}" + + # Create execution in state + await demo_state.create_execution( + execution_id=execution_id, + user_id=request.user_id, + scenario_id=f"custom_{request.notification_type}", + ) + + # Set SSE bridge to filter to this execution + if sse_bridge: + sse_bridge.set_execution_id(execution_id) + + # Broadcast execution started + await manager.broadcast({ + "type": "execution_started", + "execution_id": execution_id, + "scenario": { + "id": f"custom_{request.notification_type}", + "name": f"Custom {request.notification_type.replace('_', ' ').title()}", + "notification_type": request.notification_type, + }, + "user": { + "id": user.user_id, + "analysis_mode": user.analysis_mode, + "specialists_used": user.specialists_used, + }, + "timestamp": datetime.now().isoformat(), + }) + + # Execute the custom notification asynchronously + import asyncio + asyncio.create_task( + _execute_custom_notification( + execution_id, + request.notification_type, + request.notification_data, + request.context or {}, + request.user_id, + ) + ) + + return TriggerResponse( + execution_id=execution_id, + scenario_id=f"custom_{request.notification_type}", + user_id=request.user_id, + status="started", + ) + + +async def _execute_custom_notification( + execution_id: str, + notification_type: str, + notification_data: dict, + custom_context: dict, + user_id: str, +): + """Execute a custom notification scenario.""" + try: + await demo_state.update_execution_status(execution_id, ExecutionStatus.RUNNING) + + # Get user state for demo overrides + user_state = demo_state.get_user_state(user_id) + + # Build context with defaults and user overrides + # Use the custom context's current_hour_user_timezone if provided for demo consistency + custom_hour = custom_context.get("current_hour_user_timezone") + if custom_hour is not None: + demo_time = datetime.now().replace(hour=custom_hour, minute=0, second=0, microsecond=0) + else: + demo_time = datetime.now() + + context = { + "current_time": demo_time.isoformat(), + "user_timezone": "America/New_York", + "user_tier": custom_context.get("user_tier", "standard"), + "demo_analysis_depth": user_state.analysis_mode if user_state else "full", + "demo_pattern_count": user_state.pattern_count if user_state else 0, + **custom_context, + } + + payload = { + "user_id": user_id, + "notification_type": notification_type, + "notification_data": notification_data, + "context": context, + } + + # Call the notification-intelligence agent + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{NOTIFICATION_AGENT_URL}/reasoners/route_notification", + json=payload, + ) + + if response.status_code == 200: + result = response.json() + await demo_state.set_result(execution_id, result) + await demo_state.update_execution_status( + execution_id, ExecutionStatus.COMPLETED + ) + + await manager.broadcast({ + "type": "execution_completed", + "execution_id": execution_id, + "result": result, + "timestamp": datetime.now().isoformat(), + }) + elif response.status_code == 202: + result = response.json() + await manager.broadcast({ + "type": "execution_processing", + "execution_id": execution_id, + "agent_execution_id": result.get("execution_id"), + "timestamp": datetime.now().isoformat(), + }) + else: + error = f"Agent returned {response.status_code}: {response.text}" + await demo_state.set_error(execution_id, error) + await demo_state.update_execution_status( + execution_id, ExecutionStatus.FAILED + ) + + await manager.broadcast({ + "type": "execution_failed", + "execution_id": execution_id, + "error": error, + "timestamp": datetime.now().isoformat(), + }) + + except Exception as e: + error = str(e) + await demo_state.set_error(execution_id, error) + await demo_state.update_execution_status(execution_id, ExecutionStatus.FAILED) + + await manager.broadcast({ + "type": "execution_failed", + "execution_id": execution_id, + "error": error, + "timestamp": datetime.now().isoformat(), + }) + + +async def _execute_notification( + execution_id: str, + scenario: Scenario, + user_id: str, + overrides: Optional[dict], +): + """Execute the notification intelligence agent.""" + try: + await demo_state.update_execution_status(execution_id, ExecutionStatus.RUNNING) + + # Get user state for demo overrides + user_state = demo_state.get_user_state(user_id) + + # Build request payload with required runtime context + # Use the scenario's current_hour_user_timezone to derive current_time for demo consistency + scenario_hour = scenario.context.get("current_hour_user_timezone", datetime.now().hour) + demo_time = datetime.now().replace(hour=scenario_hour, minute=0, second=0, microsecond=0) + + context = { + **scenario.context, + "current_time": demo_time.isoformat(), + "user_timezone": "America/New_York", + "user_tier": scenario.context.get("loyalty_tier", "standard") or "standard", + # Demo-only override so the agent can render different orchestration depths + # without requiring pre-seeded long-term memory. + "demo_analysis_depth": user_state.analysis_mode if user_state else "full", + "demo_pattern_count": user_state.pattern_count if user_state else 0, + } + payload = { + "user_id": user_id, + "notification_type": scenario.notification_type.value, + "notification_data": scenario.notification_data, + "context": context, + } + + # Apply overrides if any + if overrides: + if "urgency_boost" in overrides: + payload["context"]["urgency_override"] = overrides["urgency_boost"] + if "time_override" in overrides: + payload["context"]["time_override"] = overrides["time_override"] + if "channel_lock" in overrides: + payload["context"]["channel_lock"] = overrides["channel_lock"] + + # Call the notification-intelligence agent (synchronous mode) + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{NOTIFICATION_AGENT_URL}/reasoners/route_notification", + json=payload, + ) + + if response.status_code == 200: + result = response.json() + await demo_state.set_result(execution_id, result) + await demo_state.update_execution_status( + execution_id, ExecutionStatus.COMPLETED + ) + + # Broadcast completion + await manager.broadcast({ + "type": "execution_completed", + "execution_id": execution_id, + "result": result, + "timestamp": datetime.now().isoformat(), + }) + elif response.status_code == 202: + # Async processing - results will come via SSE stream + result = response.json() + await manager.broadcast({ + "type": "execution_processing", + "execution_id": execution_id, + "agent_execution_id": result.get("execution_id"), + "timestamp": datetime.now().isoformat(), + }) + # Keep status as RUNNING - SSE events will update it + else: + error = f"Agent returned {response.status_code}: {response.text}" + await demo_state.set_error(execution_id, error) + await demo_state.update_execution_status( + execution_id, ExecutionStatus.FAILED + ) + + await manager.broadcast({ + "type": "execution_failed", + "execution_id": execution_id, + "error": error, + "timestamp": datetime.now().isoformat(), + }) + + except Exception as e: + error = str(e) + await demo_state.set_error(execution_id, error) + await demo_state.update_execution_status(execution_id, ExecutionStatus.FAILED) + + await manager.broadcast({ + "type": "execution_failed", + "execution_id": execution_id, + "error": error, + "timestamp": datetime.now().isoformat(), + }) + + +@app.post("/feedback") +async def submit_feedback(request: FeedbackRequest): + """ + Submit user feedback on a notification. + + This triggers the learning pipeline in the agent and + updates the demo user's learning state. + """ + # Update local demo state + await demo_state.record_feedback(request.user_id, request.response) + + # Call the learning endpoint on the agent + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{NOTIFICATION_AGENT_URL}/reasoners/learn_from_feedback", + json={ + "user_id": request.user_id, + "notification_id": request.notification_id, + "notification_type": "demo", # Simplified for demo + "user_response": request.response, + }, + ) + + learning_result = response.json() if response.status_code == 200 else None + except Exception: + learning_result = None + + # Get updated state + state = demo_state.get_user_state(request.user_id) + + # Broadcast learning update + await manager.broadcast({ + "type": "learning_update", + "user_id": request.user_id, + "response": request.response, + "new_state": { + "pattern_count": state.pattern_count if state else 0, + "analysis_mode": state.analysis_mode if state else "full", + "specialists_used": state.specialists_used if state else 5, + "open_rate": state.open_rate if state else 0, + }, + "learning_result": learning_result, + "timestamp": datetime.now().isoformat(), + }) + + return { + "status": "recorded", + "user_id": request.user_id, + "response": request.response, + "new_pattern_count": state.pattern_count if state else 0, + "new_analysis_mode": state.analysis_mode if state else "full", + } + + +@app.get("/executions/{execution_id}") +async def get_execution(execution_id: str): + """Get details of an execution including all events.""" + execution = demo_state.get_execution(execution_id) + if not execution: + raise HTTPException(status_code=404, detail="Execution not found") + + return { + "id": execution.id, + "user_id": execution.user_id, + "scenario_id": execution.scenario_id, + "status": execution.status.value, + "started_at": execution.started_at.isoformat(), + "completed_at": execution.completed_at.isoformat() if execution.completed_at else None, + "events": [ + { + "timestamp": e.timestamp.isoformat(), + "event_type": e.event_type, + "agent_name": e.agent_name, + "message": e.message, + "tags": e.tags, + } + for e in execution.events + ], + "result": execution.result, + "error": execution.error, + } + + +@app.post("/reset") +async def reset_demo(): + """Reset all demo state.""" + demo_state.reset() + return {"status": "reset", "message": "Demo state has been reset"} + + +# WebSocket endpoint +@app.websocket("/ws/events") +async def ws_events(websocket: WebSocket): + """WebSocket endpoint for real-time agent events.""" + await websocket_endpoint(websocket) + + +if __name__ == "__main__": + import uvicorn + + port = int(os.getenv("PORT", "8000")) + # Use wsproto for WebSocket handling - better handles large headers + uvicorn.run( + app, + host="0.0.0.0", + port=port, + ws="wsproto", + ) diff --git a/examples/python_agent_nodes/notification_intelligence/demo/requirements.txt b/examples/python_agent_nodes/notification_intelligence/demo/requirements.txt new file mode 100644 index 000000000..3601ac080 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +httpx>=0.26.0 +pydantic>=2.0.0 +websockets>=12.0 +wsproto>=1.2.0 diff --git a/examples/python_agent_nodes/notification_intelligence/demo/scenarios.py b/examples/python_agent_nodes/notification_intelligence/demo/scenarios.py new file mode 100644 index 000000000..99ec61d1c --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/scenarios.py @@ -0,0 +1,320 @@ +""" +Pre-built e-commerce notification scenarios for the demo. + +Each scenario simulates a real-world notification trigger that +the notification-intelligence system would need to decide on. +""" + +from typing import Dict, Any +from dataclasses import dataclass +from enum import Enum + + +class NotificationType(str, Enum): + ABANDONED_CART = "abandoned_cart" + FLASH_SALE = "flash_sale" + BACK_IN_STOCK = "back_in_stock" + PRICE_DROP = "price_drop" + SHIPPING_UPDATE = "shipping_update" + + +@dataclass +class Scenario: + """A pre-built notification scenario.""" + id: str + name: str + description: str + notification_type: NotificationType + notification_data: Dict[str, Any] + context: Dict[str, Any] + + +# Pre-built scenarios for the demo +SCENARIOS: Dict[str, Scenario] = { + "abandoned_cart_high": Scenario( + id="abandoned_cart_high", + name="Abandoned Cart - High Value", + description="User left $189 worth of running gear in cart 45 minutes ago. Current time: 2:00 PM", + notification_type=NotificationType.ABANDONED_CART, + notification_data={ + "cart_value": 189.99, + "items": ["Nike Air Zoom Pegasus", "Running Socks (3-pack)", "Water Bottle"], + "abandoned_minutes_ago": 45, + "cart_id": "cart_12345" + }, + context={ + "user_browsing": False, + "previous_purchases": 3, + "loyalty_tier": "silver", + "current_hour_user_timezone": 14, # 2 PM - good time + "notifications_sent_today": 1, + "recent_notifications_ignored": 0, + "user_engagement_level": "high" + } + ), + "abandoned_cart_low": Scenario( + id="abandoned_cart_low", + name="Abandoned Cart - Low Value", + description="User left a $12 item in cart 2 hours ago. Current time: 1:00 PM", + notification_type=NotificationType.ABANDONED_CART, + notification_data={ + "cart_value": 12.99, + "items": ["Phone Case"], + "abandoned_minutes_ago": 120, + "cart_id": "cart_12346" + }, + context={ + "user_browsing": False, + "previous_purchases": 0, + "loyalty_tier": None, + "current_hour_user_timezone": 13 + } + ), + "flash_sale_electronics": Scenario( + id="flash_sale_electronics", + name="Flash Sale - Electronics", + description="40% off electronics, expires in 2 hours. Current time: 3:00 PM", + notification_type=NotificationType.FLASH_SALE, + notification_data={ + "discount_percent": 40, + "expires_in_hours": 2, + "category": "Electronics", + "featured_items": ["AirPods Pro", "iPad Mini", "Apple Watch"] + }, + context={ + "user_interest_categories": ["Electronics", "Tech"], + "last_purchase_category": "Electronics", + "time_since_last_visit_hours": 48, + "current_hour_user_timezone": 15, # 3 PM + "notifications_sent_today": 0, + "recent_notifications_ignored": 0, + "user_engagement_level": "high" + } + ), + "flash_sale_fashion": Scenario( + id="flash_sale_fashion", + name="Flash Sale - Fashion", + description="Summer clearance, 60% off ending tonight. Current time: 4:00 PM", + notification_type=NotificationType.FLASH_SALE, + notification_data={ + "discount_percent": 60, + "expires_in_hours": 6, + "category": "Fashion", + "featured_items": ["Summer Dresses", "Sandals", "Sunglasses"] + }, + context={ + "user_interest_categories": ["Fashion", "Accessories"], + "last_purchase_category": "Fashion", + "time_since_last_visit_hours": 24, + "current_hour_user_timezone": 16 + } + ), + "back_in_stock": Scenario( + id="back_in_stock", + name="Back in Stock - Wishlisted Item", + description="Previously wishlisted item is back in stock. Current time: 10:00 AM", + notification_type=NotificationType.BACK_IN_STOCK, + notification_data={ + "item_name": "Sony WH-1000XM5 Headphones", + "item_price": 349.99, + "stock_quantity": 15, + "wishlisted_days_ago": 14 + }, + context={ + "user_checked_availability": 3, + "similar_items_viewed": True, + "price_alert_set": True, + "current_hour_user_timezone": 10, # 10 AM + "notifications_sent_today": 0, + "recent_notifications_ignored": 0, + "user_engagement_level": "high" + } + ), + "price_drop": Scenario( + id="price_drop", + name="Price Drop Alert", + description="Item user viewed dropped 25%. Current time: 7:00 PM", + notification_type=NotificationType.PRICE_DROP, + notification_data={ + "item_name": "Dyson V15 Vacuum", + "original_price": 749.99, + "new_price": 562.49, + "discount_percent": 25, + "price_valid_until": "2024-12-31" + }, + context={ + "times_viewed": 5, + "added_to_cart_before": True, + "comparison_shopping": True, + "current_hour_user_timezone": 19, # 7 PM + "notifications_sent_today": 1, + "recent_notifications_ignored": 0, + "user_engagement_level": "high" + } + ), + "shipping_update": Scenario( + id="shipping_update", + name="Shipping Update", + description="Package arriving today. Current time: 9:00 AM", + notification_type=NotificationType.SHIPPING_UPDATE, + notification_data={ + "order_id": "ORD-789456", + "status": "out_for_delivery", + "estimated_delivery": "Today by 6 PM", + "carrier": "UPS", + "items_count": 2 + }, + context={ + "delivery_instructions": "Leave at door", + "high_value_order": True, + "signature_required": False, + "current_hour_user_timezone": 9 + } + ), + # ============================================ + # NEGATIVE SCENARIOS - Should trigger skip/delay/batch + # ============================================ + "fatigue_overload": Scenario( + id="fatigue_overload", + name="Notification Fatigue", + description="User received 8 notifications today, ignored last 5. Current time: 3:00 PM", + notification_type=NotificationType.FLASH_SALE, + notification_data={ + "discount_percent": 20, + "expires_in_hours": 24, + "category": "Clothing", + "featured_items": ["T-Shirts", "Jeans"] + }, + context={ + "notifications_sent_today": 8, + "recent_notifications_ignored": 5, + "user_engagement_level": "low", + "current_hour_user_timezone": 15, + "user_currently_browsing": False + } + ), + "midnight_promo": Scenario( + id="midnight_promo", + name="Late Night Promo", + description="Flash sale notification triggered. Current time: 3:00 AM", + notification_type=NotificationType.FLASH_SALE, + notification_data={ + "discount_percent": 35, + "expires_in_hours": 12, + "category": "Electronics", + "featured_items": ["Headphones", "Speakers"] + }, + context={ + "current_hour_user_timezone": 3, + "notifications_sent_today": 0, + "user_engagement_level": "high", + "user_currently_browsing": False + } + ), + "browsing_interrupt": Scenario( + id="browsing_interrupt", + name="User Currently Browsing", + description="User is actively shopping on the site. Current time: 2:00 PM", + notification_type=NotificationType.ABANDONED_CART, + notification_data={ + "cart_value": 89.99, + "items": ["Wireless Mouse", "Keyboard"], + "abandoned_minutes_ago": 15, + "cart_id": "cart_active" + }, + context={ + "user_currently_browsing": True, + "user_engagement_level": "high", + "notifications_sent_today": 1, + "current_hour_user_timezone": 14 + } + ), + "low_value_spam": Scenario( + id="low_value_spam", + name="Low Value + Ignored User", + description="$5 item price drop, user ignored 3 recent notifications. Current time: 2:00 PM", + notification_type=NotificationType.PRICE_DROP, + notification_data={ + "item_name": "Phone Charger Cable", + "original_price": 7.99, + "new_price": 4.99, + "discount_percent": 37, + "price_valid_until": "2024-12-31" + }, + context={ + "notifications_sent_today": 4, + "recent_notifications_ignored": 3, # Ignored 3 recent = skip trigger + "times_viewed": 1, + "user_engagement_level": "medium", + "current_hour_user_timezone": 14 + } + ), + "batch_candidate": Scenario( + id="batch_candidate", + name="Multiple Pending Notifications", + description="Non-urgent stock alert, 3 pending notifications. Current time: 11:00 AM", + notification_type=NotificationType.BACK_IN_STOCK, + notification_data={ + "item_name": "Desk Organizer", + "item_price": 24.99, + "stock_quantity": 200, + "wishlisted_days_ago": 30 + }, + context={ + "notifications_sent_today": 2, + "user_engagement_level": "medium", + "current_hour_user_timezone": 11, + "pending_notifications": 3, + "user_checked_availability": 1 + } + ), +} + + +# Demo users at different learning stages +DEMO_USERS = { + "new_user": { + "id": "user_new_001", + "name": "New User", + "description": "Just signed up, no learned patterns", + "pattern_count": 0, + "analysis_mode": "full", + "specialists_used": 5 + }, + "learning_user": { + "id": "user_learning_002", + "name": "Learning User", + "description": "Some interaction history, patterns emerging", + "pattern_count": 5, + "analysis_mode": "moderate", + "specialists_used": 3 + }, + "power_user": { + "id": "user_power_003", + "name": "Power User", + "description": "Extensive history, confident predictions", + "pattern_count": 15, + "analysis_mode": "streamlined", + "specialists_used": 2 + } +} + + +def get_scenario(scenario_id: str) -> Scenario | None: + """Get a scenario by ID.""" + return SCENARIOS.get(scenario_id) + + +def get_all_scenarios() -> list[Scenario]: + """Get all available scenarios.""" + return list(SCENARIOS.values()) + + +def get_demo_user(user_id: str) -> dict | None: + """Get a demo user by ID.""" + return DEMO_USERS.get(user_id) + + +def get_all_demo_users() -> list[dict]: + """Get all demo users.""" + return list(DEMO_USERS.values()) diff --git a/examples/python_agent_nodes/notification_intelligence/demo/state.py b/examples/python_agent_nodes/notification_intelligence/demo/state.py new file mode 100644 index 000000000..9012173ba --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/state.py @@ -0,0 +1,234 @@ +""" +In-memory state management for the demo. + +Tracks: +- Active executions and their events +- User learning progress (simulated) +- Recent notification history +- Demo statistics +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Any, Optional +from enum import Enum +import asyncio + + +class ExecutionStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass +class AgentEvent: + """A single event from agent execution.""" + timestamp: datetime + event_type: str # specialist_started, specialist_result, synthesis, decision, etc. + agent_name: str + message: str + data: Dict[str, Any] = field(default_factory=dict) + tags: List[str] = field(default_factory=list) + + +@dataclass +class Execution: + """Tracks a single notification decision execution.""" + id: str + user_id: str + scenario_id: str + status: ExecutionStatus + started_at: datetime + completed_at: Optional[datetime] = None + events: List[AgentEvent] = field(default_factory=list) + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +@dataclass +class UserLearningState: + """Simulated learning state for a demo user.""" + user_id: str + pattern_count: int = 0 + analysis_mode: str = "full" # full, moderate, streamlined + notifications_sent: int = 0 + notifications_opened: int = 0 + notifications_ignored: int = 0 + channel_effectiveness: Dict[str, float] = field(default_factory=dict) + + @property + def open_rate(self) -> float: + total = self.notifications_opened + self.notifications_ignored + return self.notifications_opened / total if total > 0 else 0.0 + + @property + def specialists_used(self) -> int: + if self.analysis_mode == "full": + return 5 + elif self.analysis_mode == "moderate": + return 3 + return 2 + + +class DemoState: + """Global demo state manager.""" + + def __init__(self): + self._executions: Dict[str, Execution] = {} + self._user_states: Dict[str, UserLearningState] = {} + self._event_subscribers: List[asyncio.Queue] = [] + self._lock = asyncio.Lock() + + # Initialize demo users + self._init_demo_users() + + def _init_demo_users(self): + """Initialize demo users with different learning states.""" + # New user - full analysis + self._user_states["user_new_001"] = UserLearningState( + user_id="user_new_001", + pattern_count=0, + analysis_mode="full", + notifications_sent=0 + ) + + # Learning user - moderate analysis + self._user_states["user_learning_002"] = UserLearningState( + user_id="user_learning_002", + pattern_count=4, + analysis_mode="moderate", + notifications_sent=12, + notifications_opened=8, + notifications_ignored=4, + channel_effectiveness={"push": 0.75, "email": 0.45, "sms": 0.60} + ) + + # Power user - streamlined analysis + self._user_states["user_power_003"] = UserLearningState( + user_id="user_power_003", + pattern_count=12, + analysis_mode="streamlined", + notifications_sent=45, + notifications_opened=38, + notifications_ignored=7, + channel_effectiveness={"push": 0.85, "email": 0.52, "sms": 0.78, "app": 0.90} + ) + + async def create_execution(self, execution_id: str, user_id: str, scenario_id: str) -> Execution: + """Create a new execution.""" + async with self._lock: + execution = Execution( + id=execution_id, + user_id=user_id, + scenario_id=scenario_id, + status=ExecutionStatus.PENDING, + started_at=datetime.now() + ) + self._executions[execution_id] = execution + return execution + + async def update_execution_status(self, execution_id: str, status: ExecutionStatus): + """Update execution status.""" + async with self._lock: + if execution_id in self._executions: + self._executions[execution_id].status = status + if status in (ExecutionStatus.COMPLETED, ExecutionStatus.FAILED): + self._executions[execution_id].completed_at = datetime.now() + + async def add_event(self, execution_id: str, event: AgentEvent): + """Add an event to an execution and notify subscribers.""" + async with self._lock: + if execution_id in self._executions: + self._executions[execution_id].events.append(event) + + # Notify all subscribers + event_data = { + "execution_id": execution_id, + "event": { + "timestamp": event.timestamp.isoformat(), + "event_type": event.event_type, + "agent_name": event.agent_name, + "message": event.message, + "data": event.data, + "tags": event.tags + } + } + await self._broadcast_event(event_data) + + async def set_result(self, execution_id: str, result: Dict[str, Any]): + """Set the final result of an execution.""" + async with self._lock: + if execution_id in self._executions: + self._executions[execution_id].result = result + + async def set_error(self, execution_id: str, error: str): + """Set error for a failed execution.""" + async with self._lock: + if execution_id in self._executions: + self._executions[execution_id].error = error + + def get_execution(self, execution_id: str) -> Optional[Execution]: + """Get an execution by ID.""" + return self._executions.get(execution_id) + + def get_user_state(self, user_id: str) -> Optional[UserLearningState]: + """Get user learning state.""" + return self._user_states.get(user_id) + + def get_all_user_states(self) -> List[UserLearningState]: + """Get all user learning states.""" + return list(self._user_states.values()) + + async def record_feedback(self, user_id: str, response: str): + """Record user feedback and update learning state.""" + async with self._lock: + state = self._user_states.get(user_id) + if state: + state.notifications_sent += 1 + if response == "opened": + state.notifications_opened += 1 + elif response == "ignored": + state.notifications_ignored += 1 + elif response == "dismissed": + state.notifications_ignored += 1 + + # Simulate learning from any explicit feedback signal + state.pattern_count = min(state.pattern_count + 1, 20) + + # Update analysis mode based on patterns + if state.pattern_count < 3: + state.analysis_mode = "full" + elif state.pattern_count < 10: + state.analysis_mode = "moderate" + else: + state.analysis_mode = "streamlined" + + def subscribe(self) -> asyncio.Queue: + """Subscribe to execution events.""" + queue = asyncio.Queue(maxsize=100) + self._event_subscribers.append(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue): + """Unsubscribe from execution events.""" + if queue in self._event_subscribers: + self._event_subscribers.remove(queue) + + async def _broadcast_event(self, event_data: Dict[str, Any]): + """Broadcast event to all subscribers.""" + for queue in self._event_subscribers: + try: + queue.put_nowait(event_data) + except asyncio.QueueFull: + pass # Skip slow subscribers + + def reset(self): + """Reset all state to initial values.""" + self._executions.clear() + self._init_demo_users() + + +# Global state instance +demo_state = DemoState() diff --git a/examples/python_agent_nodes/notification_intelligence/demo/websocket.py b/examples/python_agent_nodes/notification_intelligence/demo/websocket.py new file mode 100644 index 000000000..ba01ad72d --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/demo/websocket.py @@ -0,0 +1,314 @@ +""" +WebSocket bridge for real-time agent events. + +Connects to AgentField SSE endpoints and forwards events +to frontend WebSocket clients. +""" + +import os +import json +import asyncio +import httpx +from datetime import datetime +from typing import Set, Optional +from fastapi import WebSocket, WebSocketDisconnect + +from state import demo_state, AgentEvent + + +class ConnectionManager: + """Manages WebSocket connections.""" + + def __init__(self): + self.active_connections: Set[WebSocket] = set() + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket): + """Accept and track a new connection.""" + await websocket.accept() + async with self._lock: + self.active_connections.add(websocket) + + async def disconnect(self, websocket: WebSocket): + """Remove a connection.""" + async with self._lock: + self.active_connections.discard(websocket) + + async def broadcast(self, message: dict): + """Send message to all connected clients.""" + msg_type = message.get('type', 'unknown') + agent = message.get('agent_name', 'none') + print(f"[Manager] broadcast called: type={msg_type}, agent={agent}, connections={len(self.active_connections)}", flush=True) + + if not self.active_connections: + print(f"[Manager] No connections, skipping", flush=True) + return + + data = json.dumps(message) + async with self._lock: + dead_connections = set() + for i, connection in enumerate(self.active_connections): + try: + await connection.send_text(data) + print(f"[Manager] Sent to connection {i}: {msg_type}/{agent}", flush=True) + except Exception as e: + print(f"[Manager] Failed to send to connection {i}: {e}", flush=True) + dead_connections.add(connection) + + # Clean up dead connections + self.active_connections -= dead_connections + + +# Global connection manager +manager = ConnectionManager() + + +class AgentFieldSSEBridge: + """ + Bridges AgentField SSE events to WebSocket clients. + + Subscribes to execution events and notes from AgentField, + parses them, and broadcasts to connected WebSocket clients. + """ + + def __init__(self, agentfield_url: str): + self.agentfield_url = agentfield_url.rstrip("/") + self._running = False + self._task: Optional[asyncio.Task] = None + self._current_execution_id: Optional[str] = None + + async def start(self): + """Start listening to AgentField events.""" + print(f"[SSE] start() called, already running: {self._running}", flush=True) + if self._running: + return + self._running = True + print(f"[SSE] Creating listen loop task...", flush=True) + self._task = asyncio.create_task(self._listen_loop()) + print(f"[SSE] Listen loop task created", flush=True) + + async def stop(self): + """Stop listening.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + def set_execution_id(self, execution_id: str): + """Set the current execution ID to filter events.""" + self._current_execution_id = execution_id + + async def _listen_loop(self): + """Main loop that listens to SSE events.""" + print(f"[SSE] _listen_loop started, running: {self._running}", flush=True) + while self._running: + try: + await self._connect_and_listen() + except Exception as e: + print(f"[SSE] connection error: {e}", flush=True) + await asyncio.sleep(2) # Reconnect delay + + async def _connect_and_listen(self): + """Connect to SSE endpoint and process events.""" + url = f"{self.agentfield_url}/api/ui/v1/executions/events" + print(f"[SSE] Connecting to: {url}", flush=True) + + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream("GET", url) as response: + print(f"[SSE] Connected, status: {response.status_code}", flush=True) + async for line in response.aiter_lines(): + print(f"[SSE] Line: {line[:100] if line else '(empty)'}", flush=True) + if not self._running: + break + + if line.startswith("data:"): + data_str = line[5:].strip() + if data_str: + try: + event_data = json.loads(data_str) + await self._process_event(event_data) + except json.JSONDecodeError: + pass + + async def _process_event(self, event_data: dict): + """Process an incoming SSE event.""" + event_type = event_data.get("type", "") + execution_id = event_data.get("execution_id") + + # Debug: log all incoming SSE events + print(f"[SSE] Received event: type={event_type}, exec_id={execution_id}, data_keys={list(event_data.keys())}", flush=True) + + # For demo simplicity, forward all events (no filtering) + + # Map SSE event to demo event + demo_event = None + + if event_type == "execution_started": + demo_event = { + "type": "execution_started", + "execution_id": execution_id, + "timestamp": datetime.now().isoformat() + } + + elif event_type == "workflow_note_added": + note = event_data.get("data", {}).get("note", {}) + message = note.get("message", "") + tags = note.get("tags", []) + + # Determine agent/specialist from tags or message + agent_name = self._extract_agent_name(message, tags) + event_type_name = self._determine_event_type(message, tags) + + agent_event = AgentEvent( + timestamp=datetime.now(), + event_type=event_type_name, + agent_name=agent_name, + message=message, + data=note, + tags=tags + ) + + # Store in demo state + if execution_id: + await demo_state.add_event(execution_id, agent_event) + + demo_event = { + "type": "agent_event", + "execution_id": execution_id, + "agent_name": agent_name, + "event_type": event_type_name, + "message": message, + "tags": tags, + "timestamp": datetime.now().isoformat() + } + + elif event_type == "execution_completed": + demo_event = { + "type": "execution_completed", + "execution_id": execution_id, + "result": event_data.get("data", {}), + "timestamp": datetime.now().isoformat() + } + + elif event_type == "execution_failed": + demo_event = { + "type": "execution_failed", + "execution_id": execution_id, + "error": event_data.get("error", "Unknown error"), + "timestamp": datetime.now().isoformat() + } + + if demo_event: + print(f"[SSE->WS] Broadcasting: type={demo_event.get('type')}, agent={demo_event.get('agent_name')}") + await manager.broadcast(demo_event) + + def _extract_agent_name(self, message: str, tags: list) -> str: + """Extract agent/specialist name from message or tags.""" + # Check tags first + if "specialist" in tags: + # Parse emoji prefix to determine specialist + if message.startswith("⚡"): + return "urgency" + elif message.startswith("📱"): + return "channel" + elif message.startswith("👤"): + return "user_state" + elif message.startswith("⏰"): + return "timing" + elif message.startswith("🔍"): + return "context" + + if "synthesis" in tags: + return "synthesis" + if "orchestration" in tags: + return "orchestration" + if "parallel" in tags: + return "parallel" + if "learning" in tags: + if "behavior" in tags: + return "behavior_learner" + elif "channel" in tags: + return "channel_learner" + elif "preference" in tags: + return "preference_learner" + return "learning" + + return "system" + + def _determine_event_type(self, message: str, tags: list) -> str: + """Determine the event type from message/tags.""" + if "specialist" in tags: + return "specialist_result" + if "synthesis" in tags: + if "Synthesizing" in message: + return "synthesis_started" + return "synthesis_result" + if "orchestration" in tags: + return "routing_decision" + if "parallel" in tags: + if "Launching" in message: + return "specialists_started" + elif "complete" in message.lower(): + return "specialists_completed" + if "learning" in tags: + return "learning_insight" + + return "note" + + +# Create bridge instance (URL configured at runtime) +sse_bridge: Optional[AgentFieldSSEBridge] = None + + +def init_sse_bridge(agentfield_url: str) -> AgentFieldSSEBridge: + """Initialize the SSE bridge with AgentField URL.""" + global sse_bridge + print(f"[SSE] init_sse_bridge called with URL: {agentfield_url}", flush=True) + sse_bridge = AgentFieldSSEBridge(agentfield_url) + return sse_bridge + + +async def websocket_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for frontend clients. + + Clients connect here to receive real-time agent events. + """ + await manager.connect(websocket) + try: + # Also subscribe to demo state events + queue = demo_state.subscribe() + + # Create task to forward demo state events + async def forward_state_events(): + while True: + try: + event = await queue.get() + await websocket.send_json(event) + except Exception: + break + + forward_task = asyncio.create_task(forward_state_events()) + + try: + # Keep connection alive and handle client messages + while True: + data = await websocket.receive_text() + # Handle client messages (e.g., ping/pong) + try: + msg = json.loads(data) + if msg.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except json.JSONDecodeError: + pass + finally: + forward_task.cancel() + demo_state.unsubscribe(queue) + + except WebSocketDisconnect: + pass + finally: + await manager.disconnect(websocket) diff --git a/examples/python_agent_nodes/notification_intelligence/docker-compose.yml b/examples/python_agent_nodes/notification_intelligence/docker-compose.yml new file mode 100644 index 000000000..8fff80be1 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/docker-compose.yml @@ -0,0 +1,65 @@ +services: + # AgentField Control Plane + control-plane: + image: agentfield/control-plane:latest + ports: + - "8080:8080" + environment: + AGENTFIELD_HOME: /data + AGENTFIELD_STORAGE_MODE: local + AGENTFIELD_HTTP_ADDR: 0.0.0.0:8080 + volumes: + - agentfield-data:/data + + # Notification Intelligence Agent + notification-intelligence: + build: + context: . + dockerfile: Dockerfile + ports: + - "8001:8001" + environment: + - PORT=8001 + - AGENTFIELD_SERVER=http://control-plane:8080 + - AGENT_CALLBACK_URL=http://notification-intelligence:8001 + - AI_MODEL=${AI_MODEL:-openrouter/openai/gpt-4o-mini} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + depends_on: + - control-plane + + # Demo API (FastAPI bridge) - Development mode with hot reload + demo-api: + build: + context: ./demo + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + - PORT=8000 + - NOTIFICATION_AGENT=http://notification-intelligence:8001 + - AGENTFIELD_SERVER=http://control-plane:8080 + volumes: + - ./demo:/app + command: uvicorn api:app --host 0.0.0.0 --port 8000 --reload --ws wsproto + depends_on: + - notification-intelligence + + # Frontend UI (Next.js) - Development mode with hot reload + frontend: + image: node:20-alpine + working_dir: /app + command: sh -c "npm install && npm run dev" + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + - NEXT_PUBLIC_WS_URL=ws://localhost:8000 + - WATCHPACK_POLLING=true + volumes: + - ./ui:/app + - /app/node_modules + depends_on: + - demo-api + +volumes: + agentfield-data: diff --git a/examples/python_agent_nodes/notification_intelligence/main.py b/examples/python_agent_nodes/notification_intelligence/main.py new file mode 100644 index 000000000..2839c7863 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/main.py @@ -0,0 +1,39 @@ +""" +Adaptive Multi-Agent Notification Intelligence + +Visual multi-agent orchestration demonstrating: +- Parallel specialist reasoners creating impressive workflow graphs +- Adaptive routing based on learning maturity +- Continuous learning from user feedback +- Meta-level intelligence optimization + +Production-ready backend AI for notification systems. +""" + +import os +from agentfield import Agent, AIConfig +from reasoners import router + + +# Initialize agent +app = Agent( + node_id="notification-intelligence", + version="2.0.0", + description="Adaptive multi-agent notification intelligence with visual orchestration", + agentfield_server=os.getenv("AGENTFIELD_SERVER", "http://localhost:8080"), + ai_config=AIConfig( + model=os.getenv("AI_MODEL", "openrouter/openai/gpt-oss-120b"), + ), +) + +# Include the enhanced router with all specialist reasoners +app.include_router(router) + + +if __name__ == "__main__": + # Start the agent server + port_env = os.getenv("PORT") + if port_env is None: + app.run(auto_port=True, host="0.0.0.0") + else: + app.run(port=int(port_env), host="0.0.0.0") diff --git a/examples/python_agent_nodes/notification_intelligence/models.py b/examples/python_agent_nodes/notification_intelligence/models.py new file mode 100644 index 000000000..35c2a671f --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/models.py @@ -0,0 +1,139 @@ +""" +Data models for Adaptive Multi-Agent Notification Intelligence + +Minimal schemas (3-4 fields) optimized for smaller, cheaper LLMs. +Each schema represents a focused output from a specialist reasoner. +""" + +from pydantic import BaseModel, Field +from typing import Literal, Optional, Dict, Any, List + + +# ============================================ +# SPECIALIST OUTPUT MODELS (3-4 fields max) +# ============================================ + +class UrgencyAssessment(BaseModel): + """Urgency specialist output.""" + score: int = Field(ge=0, le=100, description="Urgency score") + time_sensitive: bool = Field(description="Must act immediately?") + reason: str = Field(description="Why this urgency level") + + +class ChannelRecommendation(BaseModel): + """Channel specialist output.""" + channel: Literal["email", "sms", "push", "app"] + confidence: float = Field(ge=0.0, le=1.0) + backup: Optional[str] = Field(None, description="Alternative channel") + + +class UserStateAnalysis(BaseModel): + """User state specialist output.""" + engagement_likelihood: float = Field(ge=0.0, le=1.0) + attention_state: Literal["active", "busy", "offline", "unknown"] + preference_signal: str = Field(description="Key preference insight") + + +class TimingRecommendation(BaseModel): + """Timing specialist output.""" + when: Literal["now", "delay", "batch", "skip"] + delay_until: Optional[str] = Field(None, description="ISO timestamp if delaying") + rationale: str = Field(description="Timing reasoning") + + +class ContextSignals(BaseModel): + """Context specialist output.""" + priority_indicator: int = Field(ge=0, le=100) + content_urgency: Literal["critical", "high", "medium", "low"] + situational_factor: str = Field(description="Key context insight") + + +class NotificationAction(BaseModel): + """Final synthesized decision.""" + deliver: Literal["now", "delay", "batch", "skip"] + channel: Literal["email", "sms", "push", "app"] + priority: int = Field(ge=0, le=100) + reasoning: str = Field(description="Decision explanation") + + +# ============================================ +# LEARNING MODELS (3-4 fields max) +# ============================================ + +class LearningInsight(BaseModel): + """Single insight from user feedback.""" + pattern: str = Field(description="What we learned") + strength: float = Field(ge=0.0, le=1.0, description="Confidence in insight") + applies_to: str = Field(description="Notification types this affects") + + +class BehaviorPattern(BaseModel): + """Behavior pattern insight.""" + pattern: str + strength: float = Field(ge=0.0, le=1.0) + applies_to: str + + +class ChannelInsight(BaseModel): + """Channel effectiveness insight.""" + pattern: str + strength: float = Field(ge=0.0, le=1.0) + applies_to: str + + +class PreferenceSignal(BaseModel): + """User preference signal.""" + pattern: str + strength: float = Field(ge=0.0, le=1.0) + applies_to: str + + +# ============================================ +# MEMORY STATE MODELS +# ============================================ + +class UserPreferenceModel(BaseModel): + """ + User-specific learned patterns stored in actor memory. + This model grows smarter over time with each interaction. + """ + learned_patterns: List[Dict[str, Any]] = Field( + default_factory=list, + description="Extracted patterns from user feedback" + ) + channel_effectiveness: Dict[str, float] = Field( + default_factory=dict, + description="Channel engagement rates" + ) + sample_size: int = Field( + default=0, + description="Number of feedback samples" + ) + + +class GlobalPatternModel(BaseModel): + """ + System-wide patterns stored in global memory. + Represents cross-user insights and typical behaviors. + """ + common_action: str = Field(description="Most common action for this type") + typical_channel: str = Field(description="Most effective channel") + confidence: float = Field(ge=0.0, le=1.0) + + +# ============================================ +# META-LEARNING MODELS (3-4 fields max) +# ============================================ + +class RouteStrategy(BaseModel): + """Meta-learning routing strategy.""" + prefer: Literal["full", "moderate", "streamlined"] + threshold: int = Field(description="Pattern count threshold") + rationale: str = Field(description="Why this strategy") + + +class OptimizationInsight(BaseModel): + """System optimization recommendation.""" + change: str = Field(description="What to change") + impact: float = Field(ge=0.0, le=1.0, description="Expected improvement") + priority: Literal["high", "medium", "low"] diff --git a/examples/python_agent_nodes/notification_intelligence/reasoners.py b/examples/python_agent_nodes/notification_intelligence/reasoners.py new file mode 100644 index 000000000..4ee118074 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/reasoners.py @@ -0,0 +1,928 @@ +""" +Adaptive Multi-Agent Notification Intelligence Reasoners + +Visual multi-agent orchestration with parallel specialist analysis. +Creates impressive workflow graphs while maintaining production efficiency. +""" + +import asyncio +from datetime import datetime, timedelta +from typing import Dict, Any, List +from agentfield import AgentRouter +from models import ( + UrgencyAssessment, + ChannelRecommendation, + UserStateAnalysis, + TimingRecommendation, + ContextSignals, + NotificationAction, + BehaviorPattern, + ChannelInsight, + PreferenceSignal, + UserPreferenceModel, +) + +router = AgentRouter(tags=["notification-intelligence"]) + + +# ============================================ +# TIER 1: SPECIALIST REASONERS +# Each creates a visible node in workflow graph +# ============================================ + +@router.reasoner() +async def analyze_urgency( + notification_type: str, + notification_data: dict, + user_context: dict +) -> dict: + """ + Urgency specialist: Analyzes time-sensitivity and priority signals. + + Evaluates how quickly this notification requires user attention + based on notification content, user tier, and temporal context. + Forms one branch of the parallel analysis star in workflow graph. + """ + + # Extract key data points for urgency assessment + cart_value = notification_data.get("cart_value", 0) + expires_hours = notification_data.get("expires_in_hours", 24) + abandoned_mins = notification_data.get("abandoned_minutes_ago", 0) + discount_percent = notification_data.get("discount_percent", 0) + stock_qty = notification_data.get("stock_quantity", 100) + shipping_status = notification_data.get("status", "") + + result = await router.ai( + f"""Notification Type: {notification_type} +Data: {notification_data} +Context: {user_context} + +URGENCY SCORING RULES BY TYPE: + +ABANDONED_CART: +- Cart value >$150 AND abandoned 30-90 mins: score 75-90, time_sensitive=true +- Cart value $50-150 AND abandoned 30-90 mins: score 55-75 +- Cart value <$50 OR abandoned <20 mins OR >3 hours: score 30-50, time_sensitive=false +- Current: value=${cart_value}, abandoned={abandoned_mins} mins ago + +FLASH_SALE: +- Expires in <3 hours AND discount >30%: score 85-95, time_sensitive=true +- Expires in 3-6 hours: score 65-80 +- Expires in >12 hours OR discount <20%: score 35-55, time_sensitive=false +- Current: expires in {expires_hours} hours, {discount_percent}% off + +BACK_IN_STOCK: +- Low stock (<20 units) AND wishlisted: score 65-80 +- Normal stock OR not wishlisted: score 40-55, time_sensitive=false +- Current: {stock_qty} units available + +PRICE_DROP: +- Discount >30% AND user previously carted: score 70-85 +- Moderate discount (15-30%): score 50-65 +- Small discount (<15%): score 30-45, time_sensitive=false +- Current: {discount_percent}% off + +SHIPPING_UPDATE: +- "out_for_delivery": score 70-85, time_sensitive=true (action may be needed) +- "delayed": score 60-75 (user should know) +- "shipped" or "delivered": score 25-40, time_sensitive=false (informational) +- Current status: {shipping_status} + +Based on the rules above, what is the urgency assessment?""", + schema=UrgencyAssessment + ) + + router.note(f"⚡ Urgency: {result.score}/100 ({'immediate' if result.time_sensitive else 'flexible'}) | {result.reason}", + tags=["specialist", "urgency"]) + + return result.model_dump() + + +@router.reasoner() +async def analyze_channel_fit( + notification_type: str, + user_id: str, + time_context: dict +) -> dict: + """ + Channel specialist: Determines optimal delivery channel. + + Analyzes learned channel effectiveness from user history and + current timing to recommend best delivery mechanism. Uses + actor memory to retrieve per-user channel engagement patterns. + """ + + # Retrieve learned channel effectiveness + user_mem = router.memory.actor(user_id) + channel_stats = await user_mem.get("channel_effectiveness", default={ + "email": 0.5, "sms": 0.5, "push": 0.5, "app": 0.5 + }) + + current_hour = time_context.get("current_hour_user_timezone", 12) + + result = await router.ai( + f"""Notification Type: {notification_type} +Time Context: {time_context} +Current hour: {current_hour} (0-23) +Learned channel effectiveness: {channel_stats} + +CHANNEL SELECTION RULES: + +ABANDONED_CART: +- Primary: "push" (immediate visibility, high engagement) +- Backup: "email" (for details, longer shelf life) +- Confidence: 0.8-0.9 for push during day hours + +FLASH_SALE: +- Time-sensitive: "push" primary (immediate action needed) +- If discount >40%: consider "sms" as backup (high priority) +- Confidence: 0.75-0.9 based on time remaining + +BACK_IN_STOCK: +- Primary: "email" (allows browsing details, not urgent) +- If low stock: "push" for urgency +- Confidence: 0.6-0.8 + +PRICE_DROP: +- Primary: "push" (quick action opportunity) +- Backup: "email" (comparison details) +- Confidence: 0.7-0.85 + +SHIPPING_UPDATE: +- "out_for_delivery": "push" primary (action may be needed) +- "shipped": "app" or "email" (informational) +- "delivered": "app" primary (just FYI) +- Confidence: varies by status 0.6-0.9 + +TIME MODIFIERS: +- Night hours (22-7): prefer "email" over "push" (less intrusive) +- Business hours (9-18): "push" is acceptable +- Peak engagement hours (lunch, evening): higher confidence + +Best channel recommendation?""", + schema=ChannelRecommendation + ) + + backup_info = f", backup: {result.backup}" if result.backup else "" + router.note(f"📱 Channel: {result.channel} (confidence: {result.confidence:.2f}{backup_info})", + tags=["specialist", "channel"]) + + return result.model_dump() + + +@router.reasoner() +async def analyze_user_state( + user_id: str, + notification_type: str, + user_context: dict +) -> dict: + """ + User state specialist: Analyzes user engagement patterns and preferences. + + Examines learned user patterns to predict engagement likelihood + and current attention state. Leverages historical interaction + data stored in actor memory. + """ + + user_mem = router.memory.actor(user_id) + patterns = await user_mem.get("learned_patterns", default=[]) + + # Extract relevant patterns + relevant = [p for p in patterns if notification_type in p.get("applies_to", "")][:3] + + # Extract fatigue signals from context + notifications_today = user_context.get("notifications_sent_today", 0) + ignored_recent = user_context.get("recent_notifications_ignored", 0) + engagement = user_context.get("user_engagement_level", "medium") + + result = await router.ai( + f"""User learned patterns: {relevant} +Notification: {notification_type} +Notifications sent today: {notifications_today} +Recent notifications ignored: {ignored_recent} +User engagement level: {engagement} + +FATIGUE RULES: +- If ignored 3+: attention_state should be "busy", engagement_likelihood < 0.3 +- If 5+ notifications today: engagement_likelihood should be low +- If engagement is "low": engagement_likelihood < 0.4 + +User state?""", + schema=UserStateAnalysis + ) + + router.note(f"👤 User: {result.attention_state}, engagement {result.engagement_likelihood:.0%} | {result.preference_signal}", + tags=["specialist", "user-state"]) + + return result.model_dump() + + +@router.reasoner() +async def analyze_timing_window( + time_context: dict, + urgency_score: int, + user_timezone: str +) -> dict: + """ + Timing specialist: Determines optimal send timing. + + Considers urgency level, user timezone, and learned timing + patterns to recommend when notification should be delivered. + Balances immediacy with user convenience. + """ + + # Get global timing patterns + timing_patterns = await router.memory.global_scope.get( + "optimal_timing_patterns", + default={"peak_hours": [9, 10, 14, 15, 19], "avoid_hours": [0, 1, 2, 3, 4, 5, 6, 7]} + ) + + # Extract key signals for timing decision + current_hour = time_context.get("current_hour_user_timezone", 12) + user_browsing = time_context.get("user_currently_browsing", False) + + # Pre-compute time of day to avoid LLM math errors + is_night = 0 <= current_hour <= 7 + is_peak = current_hour in timing_patterns.get("peak_hours", [9, 10, 14, 15, 19]) + + # Deterministic timing decision - no LLM involvement for reliability + if is_night: + when = "delay" + # Delay until 8 AM + now = datetime.now() + morning = now.replace(hour=8, minute=0, second=0, microsecond=0) + if morning <= now: + morning += timedelta(days=1) + delay_until = morning.isoformat() + rationale = f"Hour {current_hour} is during night hours (0-7). Delaying until morning at 8 AM for better engagement." + elif user_browsing and urgency_score < 70: + when = "delay" + delay_until = None + rationale = f"User is currently browsing and urgency ({urgency_score}) is below 70. Delay to avoid interrupting their session." + elif not user_browsing and urgency_score >= 50: + when = "now" + delay_until = None + peak_note = " This is a peak engagement hour." if is_peak else "" + rationale = f"User is not browsing and urgency ({urgency_score}) is high enough to re-engage them.{peak_note} Send now." + elif urgency_score < 30: + when = "batch" + delay_until = None + rationale = f"Low urgency ({urgency_score}). Can be batched with other notifications." + else: + when = "now" + delay_until = None + rationale = f"Hour {current_hour} is within acceptable range (8-22). Sending now." + + delay_info = f" (until {delay_until})" if delay_until else "" + router.note(f"⏰ Timing: {when}{delay_info} | {rationale}", + tags=["specialist", "timing"]) + + return {"when": when, "delay_until": delay_until, "rationale": rationale} + + +@router.reasoner() +async def analyze_context_signals( + notification_data: dict, + user_tier: str, + current_time: str +) -> dict: + """ + Context specialist: Extracts implicit priority signals. + + Analyzes notification content and situational factors to + identify urgency indicators that may not be explicit. + Considers user tier and temporal context. + """ + + # Extract data for context analysis + cart_value = notification_data.get("cart_value", 0) + item_price = notification_data.get("item_price", 0) or notification_data.get("new_price", 0) + discount = notification_data.get("discount_percent", 0) + stock_qty = notification_data.get("stock_quantity", 100) + expires_hours = notification_data.get("expires_in_hours", 24) + + result = await router.ai( + f"""Notification data: {notification_data} +User tier: {user_tier} +Current time: {current_time} + +CONTEXT PRIORITY RULES: + +VALUE-BASED SIGNALS: +- High value (cart >$100 or item >$200): priority_indicator 70-90, content_urgency="high" +- Medium value ($50-100): priority_indicator 50-70, content_urgency="medium" +- Low value (<$50): priority_indicator 30-50, content_urgency="low" +- Current value context: cart=${cart_value}, item=${item_price} + +USER TIER MODIFIERS: +- Gold tier: +10 to priority (valuable customer) +- Silver tier: +5 to priority +- Standard: no modifier + +TIME-SENSITIVE SIGNALS: +- Sale expiring <3 hours: content_urgency="critical", priority 80+ +- Sale expiring <12 hours: content_urgency="high" +- Low stock (<20): adds urgency +- Current: expires in {expires_hours}h, stock={stock_qty} + +DISCOUNT SIGNALS: +- >40% off: content_urgency="high", "exceptional discount opportunity" +- 20-40% off: content_urgency="medium", "good value proposition" +- <20% off: content_urgency="low", "modest savings" +- Current discount: {discount}% + +SITUATIONAL FACTORS (choose one that best describes the situation): +- "Limited time opportunity - action needed soon" +- "High-value cart at risk - recovery priority" +- "Wishlist item available - strong user intent signal" +- "Routine notification - no special urgency" +- "VIP customer - prioritize engagement" + +What are the implicit priority signals?""", + schema=ContextSignals + ) + + router.note(f"🔍 Context: {result.content_urgency} priority ({result.priority_indicator}/100) | {result.situational_factor}", + tags=["specialist", "context"]) + + return result.model_dump() + + +# ============================================ +# TIER 2: SYNTHESIS REASONER +# Convergence node in workflow graph +# ============================================ + +@router.reasoner() +async def synthesize_decision( + urgency_analysis: dict, + channel_analysis: dict, + user_state_analysis: dict, + timing_analysis: dict, + context_signals: dict, + user_context: dict +) -> dict: + """ + Synthesis orchestrator: Merges all specialist analyses. + + Creates the convergence point in workflow graph where all + parallel specialist branches merge. Weighs each perspective + to produce final unified decision. + """ + + # Get fatigue signals from user context + notifs_today = user_context.get("notifications_sent_today", 0) + ignored = user_context.get("recent_notifications_ignored", 0) + engagement = user_context.get("user_engagement_level", "medium") + hour = user_context.get("current_hour_user_timezone", 12) + browsing = user_context.get("user_currently_browsing", False) + + # Pre-compute time conditions to avoid LLM math errors + is_outside_hours = hour < 8 or hour > 22 + should_skip = ignored >= 3 or notifs_today >= 5 or engagement == "low" + should_delay = is_outside_hours or browsing + + router.note(f"🧩 Synthesizing: hour={hour}, ignored={ignored}, notifs={notifs_today}, engagement={engagement}, browsing={browsing}", + tags=["synthesis"]) + + final = await router.ai( + f"""Specialist Analyses: + +Urgency: {urgency_analysis} +Channel: {channel_analysis} +User State: {user_state_analysis} +Timing: {timing_analysis} +Context: {context_signals} + +FATIGUE SIGNALS (from user context): +- Notifications sent today: {notifs_today} +- Recent notifications ignored: {ignored} +- User engagement level: {engagement} +- Current hour in user timezone: {hour} +- User currently browsing site: {browsing} + +PRE-COMPUTED CONDITIONS (trust these boolean values): +- SHOULD_SKIP = {should_skip} (ignored >= 3 OR notifs >= 5 OR engagement low) +- SHOULD_DELAY = {should_delay} (hour outside 8-22 OR browsing) +- IS_OUTSIDE_HOURS = {is_outside_hours} (hour {hour} is {'NOT ' if not is_outside_hours else ''}outside 8-22 range) + +MANDATORY DECISION RULES - FOLLOW THESE EXACTLY: +1. IF SHOULD_SKIP == True THEN deliver="skip" +2. ELSE IF SHOULD_DELAY == True THEN deliver="delay" +3. ELSE IF urgency < 40 AND notifs_today >= 2 THEN deliver="batch" +4. ELSE deliver="now" + +Current: SHOULD_SKIP={should_skip}, SHOULD_DELAY={should_delay}, hour={hour} + +Based on these pre-computed values, which rule applies? + +Synthesize final notification decision:""", + schema=NotificationAction + ) + + router.note(f"✨ Final: {final.deliver} via {final.channel} (priority {final.priority}/100)", + tags=["synthesis", "decision"]) + + return final.model_dump() + + +# ============================================ +# TIER 3: ADAPTIVE ORCHESTRATORS +# Control which specialists are invoked +# ============================================ + +@router.reasoner() +async def route_notification( + user_id: str, + notification_type: str, + notification_data: dict, + context: dict +) -> dict: + """ + Adaptive orchestrator: Routes to appropriate analysis depth. + + Determines orchestration strategy based on user learning maturity: + - New users (0-2 patterns): Full 5-specialist analysis + - Learning users (3-9 patterns): Moderate 3-specialist analysis + - Confident users (10+ patterns): Streamlined 2-specialist analysis + + Creates progressively simpler workflow graphs as system gains + confidence in user understanding. + """ + + # Store context in workflow memory (guard against None memory in edge cases) + if router.memory is not None: + await router.memory.set("notification_type", notification_type) + await router.memory.set("user_context", context) + + # Demo-only override: allow upstream callers (the UI demo) to force + # orchestration depth so the graph clearly shows full → moderate → streamlined + # without requiring pre-seeded long-term memory for the demo users. + demo_depth = context.get("demo_analysis_depth") + demo_pattern_count = context.get("demo_pattern_count") + if demo_depth in ("full", "moderate", "streamlined"): + try: + pattern_count = int(demo_pattern_count) if demo_pattern_count is not None else 0 + except Exception: + pattern_count = 0 + + router.note( + f"🧪 Demo override: {demo_depth.upper()} analysis ({pattern_count} patterns)", + tags=["orchestration", "demo", demo_depth], + ) + + if demo_depth == "full": + decision = await orchestrate_full_analysis( + user_id, notification_type, notification_data, context + ) + elif demo_depth == "moderate": + decision = await orchestrate_moderate_analysis( + user_id, notification_type, notification_data, context + ) + else: + decision = await orchestrate_streamlined_analysis( + user_id, notification_type, notification_data, context + ) + + if router.memory is not None: + await router.memory.set("final_decision", decision) + return { + "decision": decision, + "pattern_count": pattern_count, + "analysis_depth": demo_depth, + } + + # Get user's learning maturity + if router.memory is not None: + user_mem = router.memory.actor(user_id) + patterns = await user_mem.get("learned_patterns", default=[]) + else: + patterns = [] + pattern_count = len(patterns) + + router.note(f"📊 User learning maturity: {pattern_count} patterns", tags=["orchestration"]) + + # Adaptive routing based on maturity + if pattern_count < 3: + # NEW USER - Full visual analysis + router.note("🌟 Routing: FULL analysis (5 specialists) - learning phase", + tags=["orchestration", "full"]) + decision = await orchestrate_full_analysis( + user_id, notification_type, notification_data, context + ) + + elif pattern_count < 10: + # LEARNING USER - Moderate analysis + router.note("⚡ Routing: MODERATE analysis (3 specialists) - maturing", + tags=["orchestration", "moderate"]) + decision = await orchestrate_moderate_analysis( + user_id, notification_type, notification_data, context + ) + + else: + # CONFIDENT - Streamlined analysis + router.note("🎯 Routing: STREAMLINED analysis (2 specialists) - confident", + tags=["orchestration", "streamlined"]) + decision = await orchestrate_streamlined_analysis( + user_id, notification_type, notification_data, context + ) + + # Store decision for learning + if router.memory is not None: + await router.memory.set("final_decision", decision) + + return { + "decision": decision, + "pattern_count": pattern_count, + "analysis_depth": "full" if pattern_count < 3 else "moderate" if pattern_count < 10 else "streamlined" + } + + +@router.reasoner() +async def orchestrate_full_analysis( + user_id: str, + notification_type: str, + notification_data: dict, + context: dict +) -> dict: + """ + Full 5-specialist parallel orchestration. + + Launches all specialist reasoners in parallel, creating an + impressive star-pattern workflow graph with 5 branches + converging to synthesis. Used for new users or complex cases + where maximum insight is valuable. + + Graph pattern: 5 parallel specialists → 1 synthesis + AI calls: 6 total (5 parallel + 1 synthesis) + """ + + router.note("🔄 Launching 5 parallel specialists...", tags=["parallel"]) + + # Launch ALL specialists in parallel (creates star graph) + urgency_task = router.app.call( + "notification-intelligence.analyze_urgency", + notification_type=notification_type, + notification_data=notification_data, + user_context=context + ) + + channel_task = router.app.call( + "notification-intelligence.analyze_channel_fit", + notification_type=notification_type, + user_id=user_id, + time_context={"current_time": context["current_time"], "timezone": context["user_timezone"]} + ) + + user_state_task = router.app.call( + "notification-intelligence.analyze_user_state", + user_id=user_id, + notification_type=notification_type, + user_context=context + ) + + timing_task = router.app.call( + "notification-intelligence.analyze_timing_window", + time_context={ + "current_time": context.get("current_time"), + "current_hour_user_timezone": context.get("current_hour_user_timezone", 12), + "user_currently_browsing": context.get("user_currently_browsing", False), + }, + urgency_score=85, # Placeholder, will be refined + user_timezone=context.get("user_timezone", "America/New_York") + ) + + context_task = router.app.call( + "notification-intelligence.analyze_context_signals", + notification_data=notification_data, + user_tier=context["user_tier"], + current_time=context["current_time"] + ) + + # Wait for all parallel analyses + urgency, channel, user_state, timing, ctx_signals = await asyncio.gather( + urgency_task, channel_task, user_state_task, timing_task, context_task + ) + + router.note("✅ All 5 specialists complete", tags=["parallel"]) + + # Synthesize (creates convergence node) + final_decision = await router.app.call( + "notification-intelligence.synthesize_decision", + urgency_analysis=urgency, + channel_analysis=channel, + user_state_analysis=user_state, + timing_analysis=timing, + context_signals=ctx_signals, + user_context=context + ) + + return final_decision + + +@router.reasoner() +async def orchestrate_moderate_analysis( + user_id: str, + notification_type: str, + notification_data: dict, + context: dict +) -> dict: + """ + Moderate 3-specialist parallel orchestration. + + Balanced approach using core specialists: urgency, channel, timing. + Creates clear workflow graph while reducing AI cost by 40%. + + Graph pattern: 3 parallel specialists → 1 synthesis + AI calls: 4 total (3 parallel + 1 synthesis) + """ + + router.note("🔄 Launching 3 core specialists...", tags=["parallel"]) + + # Launch core specialists in parallel + urgency_task = router.app.call( + "notification-intelligence.analyze_urgency", + notification_type=notification_type, + notification_data=notification_data, + user_context=context + ) + + channel_task = router.app.call( + "notification-intelligence.analyze_channel_fit", + notification_type=notification_type, + user_id=user_id, + time_context={"current_time": context["current_time"], "timezone": context["user_timezone"]} + ) + + timing_task = router.app.call( + "notification-intelligence.analyze_timing_window", + time_context={ + "current_time": context.get("current_time"), + "current_hour_user_timezone": context.get("current_hour_user_timezone", 12), + "user_currently_browsing": context.get("user_currently_browsing", False), + }, + urgency_score=75, + user_timezone=context.get("user_timezone", "America/New_York") + ) + + urgency, channel, timing = await asyncio.gather( + urgency_task, channel_task, timing_task + ) + + router.note("✅ Core specialists complete", tags=["parallel"]) + + # Pre-compute conditions to avoid LLM math errors + hour = context.get('current_hour_user_timezone', 12) + ignored = context.get('recent_notifications_ignored', 0) + notifs = context.get('notifications_sent_today', 0) + engagement = context.get('user_engagement_level', 'medium') + browsing = context.get('user_currently_browsing', False) + + should_skip = ignored >= 3 or notifs >= 5 or engagement == "low" + is_outside_hours = hour < 8 or hour > 22 + should_delay = is_outside_hours or browsing + + # Simplified synthesis with decision rules + final = await router.ai( + f"""Urgency: {urgency} +Channel: {channel} +Timing: {timing} +User context: notifications_today={notifs}, ignored={ignored}, engagement={engagement}, hour={hour}, browsing={browsing} + +PRE-COMPUTED (trust these): SHOULD_SKIP={should_skip}, SHOULD_DELAY={should_delay} +Hour {hour} is {'OUTSIDE' if is_outside_hours else 'INSIDE'} acceptable range (8-22). + +DECISION RULES: +- IF SHOULD_SKIP=True THEN skip +- ELSE IF SHOULD_DELAY=True THEN delay +- ELSE deliver now + +Final decision?""", + schema=NotificationAction + ) + + router.note(f"✨ Decision: {final.deliver} via {final.channel}", tags=["synthesis"]) + + return final.model_dump() + + +@router.reasoner() +async def orchestrate_streamlined_analysis( + user_id: str, + notification_type: str, + notification_data: dict, + context: dict +) -> dict: + """ + Streamlined 2-specialist orchestration. + + Minimal orchestration for well-understood users. Uses only + urgency and channel analysis, reducing AI cost by 67%. + + Graph pattern: 2 parallel specialists → direct synthesis + AI calls: 3 total (2 parallel + 1 synthesis) + """ + + router.note("🔄 Launching streamlined analysis...", tags=["parallel"]) + + # Just urgency and channel + urgency_task = router.app.call( + "notification-intelligence.analyze_urgency", + notification_type=notification_type, + notification_data=notification_data, + user_context=context + ) + + channel_task = router.app.call( + "notification-intelligence.analyze_channel_fit", + notification_type=notification_type, + user_id=user_id, + time_context={"current_time": context["current_time"], "timezone": context["user_timezone"]} + ) + + urgency, channel = await asyncio.gather(urgency_task, channel_task) + + router.note("✅ Streamlined complete", tags=["parallel"]) + + # Pre-compute conditions to avoid LLM math errors + hour = context.get('current_hour_user_timezone', 12) + ignored = context.get('recent_notifications_ignored', 0) + notifs = context.get('notifications_sent_today', 0) + browsing = context.get('user_currently_browsing', False) + + should_skip = ignored >= 3 or notifs >= 5 + is_outside_hours = hour < 8 or hour > 22 + should_delay = is_outside_hours or browsing + + # Quick synthesis with decision rules + final = await router.ai( + f"""Urgency: {urgency} +Channel: {channel} +Context: notifications_today={notifs}, ignored={ignored}, hour={hour}, browsing={browsing} + +PRE-COMPUTED (trust these): SHOULD_SKIP={should_skip}, SHOULD_DELAY={should_delay} +Hour {hour} is {'OUTSIDE' if is_outside_hours else 'INSIDE'} acceptable range (8-22). + +RULES: IF SHOULD_SKIP=True THEN skip. ELSE IF SHOULD_DELAY=True THEN delay. ELSE NOW. + +Decision?""", + schema=NotificationAction + ) + + return final.model_dump() + + +# ============================================ +# TIER 4: LEARNING REASONERS +# Extract insights from user feedback +# ============================================ + +@router.reasoner() +async def learn_from_feedback( + user_id: str, + notification_id: str, + notification_type: str, + user_response: dict +) -> dict: + """ + Learning orchestrator: Extracts actionable insights from user feedback. + + Launches parallel insight extraction creating a separate learning + workflow graph. Each specialist extracts a different type of pattern: + behavior, channel effectiveness, preference signals. + + Graph pattern: 3 parallel learning specialists → pattern storage + AI calls: 3 total (all parallel) + """ + + router.note(f"📚 Initiating learning from user feedback", tags=["learning"]) + + # Launch parallel insight extraction (creates learning graph) + behavior_task = router.app.call( + "notification-intelligence.extract_behavior_pattern", + user_id=user_id, + notification_type=notification_type, + response=user_response + ) + + channel_task = router.app.call( + "notification-intelligence.extract_channel_insight", + user_id=user_id, + response=user_response + ) + + preference_task = router.app.call( + "notification-intelligence.extract_preference_signal", + notification_type=notification_type, + response=user_response + ) + + # Gather all insights + behavior, channel_insight, preference = await asyncio.gather( + behavior_task, channel_task, preference_task + ) + + router.note("✅ All learning specialists complete", tags=["learning"]) + + # Store in user memory + user_mem = router.memory.actor(user_id) + patterns = await user_mem.get("learned_patterns", default=[]) + channel_stats = await user_mem.get("channel_effectiveness", default={}) + + # Add new insights + new_insights = [behavior, channel_insight, preference] + patterns.extend(new_insights) + + # Keep top 10 patterns by strength + patterns = sorted(patterns, key=lambda x: x.get("strength", 0), reverse=True)[:10] + + # Update channel effectiveness + if "action_taken" in user_response and user_response["action_taken"] == "opened": + channel_used = user_response.get("channel_used", "email") + current_score = channel_stats.get(channel_used, 0.5) + # Moving average: 80% old + 20% new (opened = 1.0) + channel_stats[channel_used] = current_score * 0.8 + 1.0 * 0.2 + + # Save to memory + await user_mem.set("learned_patterns", patterns) + await user_mem.set("channel_effectiveness", channel_stats) + + # Increment sample size + sample_size = await user_mem.get("sample_size", default=0) + await user_mem.set("sample_size", sample_size + 1) + + router.note(f"💾 Stored {len(patterns)} patterns, sample size: {sample_size + 1}", + tags=["learning", "storage"]) + + return { + "patterns_learned": len(patterns), + "sample_size": sample_size + 1, + "insights": new_insights + } + + +@router.reasoner() +async def extract_behavior_pattern( + user_id: str, + notification_type: str, + response: dict +) -> dict: + """ + Learning specialist: Extracts user behavior patterns. + + Analyzes how user responded to notification to identify + engagement patterns and attention preferences. + """ + + result = await router.ai( + f"Notification: {notification_type}\nUser response: {response}\nBehavior pattern?", + schema=BehaviorPattern + ) + + router.note(f"🔍 Behavior: {result.pattern}", tags=["learning", "behavior"]) + + return result.model_dump() + + +@router.reasoner() +async def extract_channel_insight( + user_id: str, + response: dict +) -> dict: + """ + Learning specialist: Extracts channel effectiveness signals. + + Analyzes response time and engagement to determine how + effective the delivery channel was for this user. + """ + + result = await router.ai( + f"User response: {response}\nChannel effectiveness insight?", + schema=ChannelInsight + ) + + router.note(f"📱 Channel: {result.pattern}", tags=["learning", "channel"]) + + return result.model_dump() + + +@router.reasoner() +async def extract_preference_signal( + notification_type: str, + response: dict +) -> dict: + """ + Learning specialist: Extracts user preference signals. + + Infers implicit user preferences about notification importance + and desired handling based on their response behavior. + """ + + result = await router.ai( + f"Notification: {notification_type}\nResponse: {response}\nPreference signal?", + schema=PreferenceSignal + ) + + router.note(f"⭐ Preference: {result.pattern}", tags=["learning", "preference"]) + + return result.model_dump() diff --git a/examples/python_agent_nodes/notification_intelligence/requirements.txt b/examples/python_agent_nodes/notification_intelligence/requirements.txt new file mode 100644 index 000000000..0749fd7db --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/requirements.txt @@ -0,0 +1,2 @@ +agentfield>=0.1.8 +pydantic>=2.0.0 diff --git a/examples/python_agent_nodes/notification_intelligence/ui/Dockerfile b/examples/python_agent_nodes/notification_intelligence/ui/Dockerfile new file mode 100644 index 000000000..71600f14a --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package.json ./ +RUN npm install + +# Copy source +COPY . . + +# Build +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + +# Production image +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/examples/python_agent_nodes/notification_intelligence/ui/app/globals.css b/examples/python_agent_nodes/notification_intelligence/ui/app/globals.css new file mode 100644 index 000000000..8eb56aebd --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/app/globals.css @@ -0,0 +1,169 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + min-height: 100vh; +} + +/* Thinking animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + width: 100%; + } + 50% { + transform: translateX(0%); + width: 100%; + } + 100% { + transform: translateX(100%); + width: 100%; + } +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1); + } +} + +@keyframes edge-flow { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: -36; + } +} + +@keyframes graph-float { + 0%, 100% { + transform: translateX(-50%) translateY(0); + } + 50% { + transform: translateX(-50%) translateY(-2px); + } +} + +.animate-fadeIn { + animation: fadeIn 0.4s ease-out forwards; +} + +.animate-scaleIn { + animation: scaleIn 0.3s ease-out forwards; +} + +.animate-shimmer { + animation: shimmer 1.5s ease-in-out infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +.animate-graph-float { + animation: graph-float 6s ease-in-out infinite; +} + +.graph-edge { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +.graph-edge--idle { + opacity: 0.18; +} + +.graph-edge--running { + opacity: 0.35; + stroke-dasharray: 2 10; + animation: edge-flow 2.6s linear infinite; +} + +.graph-edge--solid { + opacity: 0.75; + stroke-dasharray: 6 12; + animation: edge-flow 2s linear infinite; +} + +.graph-edge--active-base { + opacity: 0.42; +} + +.graph-edge--active { + opacity: 0.92; + stroke-dasharray: 6 12; + animation: edge-flow 1.6s linear infinite; +} + +.graph-grid { + background-image: + linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 1) 40%, rgba(0, 0, 0, 0) 78%); + -webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 1) 40%, rgba(0, 0, 0, 0) 78%); +} + +.graph-vignette { + background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 38%, rgba(0, 0, 0, 0.72) 100%); +} + +@media (prefers-reduced-motion: reduce) { + .animate-fadeIn, + .animate-scaleIn, + .animate-shimmer, + .animate-pulse-glow, + .animate-graph-float, + .graph-edge--running, + .graph-edge--active { + animation: none !important; + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #27272a; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: #52525b; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #71717a; +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/app/layout.tsx b/examples/python_agent_nodes/notification_intelligence/ui/app/layout.tsx new file mode 100644 index 000000000..a8d1dd06e --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'Notification Intelligence Demo', + description: 'Interactive multi-agent notification orchestration demo', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/app/page.tsx b/examples/python_agent_nodes/notification_intelligence/ui/app/page.tsx new file mode 100644 index 000000000..5538cf8f8 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/app/page.tsx @@ -0,0 +1,517 @@ +'use client' + +import { useCallback, useEffect, useState, useRef } from 'react' +import { useAgentStream } from '@/hooks/useAgentStream' +import { DashboardShell, DashboardHeader, DashboardMain } from '@/components/DashboardShell' +import { InputPanel, CustomFormData } from '@/components/InputPanel' +import { DecisionGraph } from '@/components/DecisionGraph' + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' + +interface Scenario { + id: string + name: string + description: string +} + +interface Decision { + deliver: string + channel: string + priority: number + reasoning: string +} + +interface ParsedInsight { + raw: string + value?: string + score?: number + secondary?: string + reasoning?: string +} + +interface SpecialistData { + emoji: string + label: string + color: string + insight: ParsedInsight | null + thinkingState: 'idle' | 'thinking' | 'revealed' + thinkingStartTime?: number +} + +type Phase = 'idle' | 'routing' | 'specialists' | 'synthesis' | 'complete' +type ScenarioMode = 'predefined' | 'custom' + +const SPECIALISTS: Record = { + urgency: { emoji: '⚡', color: 'amber', label: 'Urgency' }, + channel: { emoji: '📱', color: 'blue', label: 'Channel' }, + user_state: { emoji: '👤', color: 'purple', label: 'User State' }, + timing: { emoji: '⏰', color: 'green', label: 'Timing' }, + context: { emoji: '🔍', color: 'cyan', label: 'Context' }, +} + +function parseInsight(agentName: string, message: string): ParsedInsight { + const raw = message + + let cleaned = message + for (const spec of Object.values(SPECIALISTS)) { + if (message.startsWith(spec.emoji)) { + cleaned = message.slice(spec.emoji.length).trim() + break + } + } + + const [mainPart, reasoningPart] = cleaned.split(' | ') + const reasoning = reasoningPart?.trim() + + if (agentName === 'urgency') { + const match = mainPart.match(/Urgency:\s*(\d+)\/100\s*\(([^)]+)\)/) + if (match) { + return { raw, score: parseInt(match[1]), value: match[2], secondary: `${match[1]}/100`, reasoning } + } + } + + if (agentName === 'channel') { + const match = mainPart.match(/Channel:\s*(\w+)\s*\(confidence:\s*([\d.]+)(?:,\s*backup:\s*(\w+))?\)/) + if (match) { + const backup = match[3] ? ` (backup: ${match[3]})` : '' + return { + raw, + value: match[1], + score: Math.round(parseFloat(match[2]) * 100), + secondary: `${Math.round(parseFloat(match[2]) * 100)}% confidence${backup}`, + reasoning, + } + } + } + + if (agentName === 'user_state') { + const match = mainPart.match(/User:\s*([^,]+),\s*engagement\s*([\d.]+)%/) + if (match) { + return { raw, value: match[1], score: parseFloat(match[2]), secondary: `${match[2]}% engagement`, reasoning } + } + } + + if (agentName === 'timing') { + const match = mainPart.match(/Timing:\s*(\w+)(?:\s*\(until\s*([^)]+)\))?/) + if (match) { + const delayInfo = match[2] ? `until ${match[2]}` : undefined + return { raw, value: match[1], secondary: delayInfo, reasoning } + } + } + + if (agentName === 'context') { + const match = mainPart.match(/Context:\s*(\w+)\s*priority\s*\((\d+)\/100\)/) + if (match) { + return { raw, value: match[1], score: parseInt(match[2]), secondary: `${match[2]}/100 priority`, reasoning } + } + } + + return { raw, value: cleaned, reasoning } +} + +function deriveSpecialistInsights(decision: Decision): Record { + const priority = decision.priority + const deliver = decision.deliver + const channel = decision.channel + const reasoning = decision.reasoning || '' + + const urgencyValue = priority >= 80 ? 'immediate' : priority >= 60 ? 'moderate' : 'flexible' + const urgencyInsight: ParsedInsight = { + raw: `Urgency: ${priority}/100 (${urgencyValue})`, + value: urgencyValue, + score: priority, + secondary: `${priority}/100`, + reasoning: priority >= 70 ? 'Time-sensitive action needed' : 'Can be delayed if needed', + } + + const channelConfidence = priority >= 70 ? 0.85 : 0.7 + const channelInsight: ParsedInsight = { + raw: `Channel: ${channel} (confidence: ${channelConfidence.toFixed(2)})`, + value: channel, + score: Math.round(channelConfidence * 100), + secondary: `${Math.round(channelConfidence * 100)}% confidence`, + reasoning: `Best channel for ${deliver === 'now' ? 'immediate' : 'scheduled'} delivery`, + } + + const userActive = deliver === 'now' || deliver === 'delay' + const engagement = deliver === 'skip' ? 30 : deliver === 'batch' ? 55 : deliver === 'delay' ? 65 : 75 + const userStateInsight: ParsedInsight = { + raw: `User: ${userActive ? 'receptive' : 'fatigued'}, engagement ${engagement}%`, + value: userActive ? 'receptive' : 'fatigued', + score: engagement, + secondary: `${engagement}% engagement`, + reasoning: reasoning.toLowerCase().includes('fatigue') + ? 'High notification fatigue detected' + : reasoning.toLowerCase().includes('browsing') + ? 'User currently active' + : 'Normal engagement level', + } + + const timingValue = + deliver === 'now' ? 'now' : deliver === 'delay' ? 'delay' : deliver === 'batch' ? 'batch' : 'skip' + const timingInsight: ParsedInsight = { + raw: `Timing: ${timingValue}`, + value: timingValue, + secondary: deliver === 'delay' ? 'until optimal window' : undefined, + reasoning: + reasoning.toLowerCase().includes('hour') || reasoning.toLowerCase().includes('morning') + ? 'Outside peak engagement hours' + : reasoning.toLowerCase().includes('browsing') + ? 'User currently browsing' + : 'Optimal timing window', + } + + const contextPriority = priority >= 80 ? 'high' : priority >= 50 ? 'medium' : 'low' + const contextInsight: ParsedInsight = { + raw: `Context: ${contextPriority} priority (${priority}/100)`, + value: contextPriority, + score: priority, + secondary: `${priority}/100 priority`, + reasoning: reasoning.slice(0, 60) + (reasoning.length > 60 ? '...' : ''), + } + + return { + urgency: urgencyInsight, + channel: channelInsight, + user_state: userStateInsight, + timing: timingInsight, + context: contextInsight, + } +} + +export default function Home() { + const { connected, events, clearEvents } = useAgentStream() + const [scenarios, setScenarios] = useState([]) + const [selectedScenarioId, setSelectedScenarioId] = useState('') + const [isRunning, setIsRunning] = useState(false) + const [decision, setDecision] = useState(null) + const [phase, setPhase] = useState('idle') + const [specialists, setSpecialists] = useState>(() => { + const initial: Record = {} + for (const [key, config] of Object.entries(SPECIALISTS)) { + initial[key] = { ...config, insight: null, thinkingState: 'idle' } + } + return initial + }) + const [synthesisMessage, setSynthesisMessage] = useState(null) + const [routingInfo, setRoutingInfo] = useState(null) + const [error, setError] = useState(null) + const [scenarioMode, setScenarioMode] = useState('predefined') + const [thinkingPhraseIndex, setThinkingPhraseIndex] = useState(0) + const [customForm, setCustomForm] = useState({ + notification_type: 'abandoned_cart', + cart_value: 150, + items: 'Running Shoes, Sports Watch', + abandoned_minutes: 30, + user_tier: 'standard', + previous_purchases: 2, + notifications_today: 1, + user_currently_browsing: false, + hour_in_user_timezone: 14, + recent_notifications_ignored: 0, + user_engagement_level: 'high', + }) + + // Cycle through thinking phrases + useEffect(() => { + if (!isRunning) return + const interval = setInterval(() => { + setThinkingPhraseIndex((i: number) => (i + 1) % 4) + }, 800) + return () => clearInterval(interval) + }, [isRunning]) + + // Load scenarios on mount + useEffect(() => { + fetch(`${API_URL}/scenarios`) + .then((res) => res.json()) + .then((data) => { + setScenarios(data.scenarios || []) + if (data.scenarios?.length > 0) { + setSelectedScenarioId(data.scenarios[0].id) + } + }) + .catch(console.error) + }, []) + + // Track how many events we've processed to avoid reprocessing + const processedEventsCountRef = useRef(0) + // Flag to prevent reprocessing old events during reset + const isResettingRef = useRef(false) + + // Process events - simple immediate updates + useEffect(() => { + // Skip processing if we're in the middle of a reset + if (isResettingRef.current) { + // Reset complete when events are cleared + if (events.length === 0) { + isResettingRef.current = false + processedEventsCountRef.current = 0 + } + return + } + + const newEvents = events.slice(processedEventsCountRef.current) + if (newEvents.length === 0) return + processedEventsCountRef.current = events.length + + for (const event of newEvents) { + if (event.type === 'execution_started') { + if (phase === 'idle') { + setPhase('specialists') + } + } + + if (event.type === 'agent_event') { + const msg = event.message || '' + let agentName = event.agent_name || '' + const tags = event.tags || [] + + if (!agentName || agentName === 'system') { + if (tags.includes('specialist')) { + if (tags.includes('urgency') || msg.startsWith('⚡')) agentName = 'urgency' + else if (tags.includes('channel') || msg.startsWith('📱')) agentName = 'channel' + else if (tags.includes('user-state') || msg.startsWith('👤')) agentName = 'user_state' + else if (tags.includes('timing') || msg.startsWith('⏰')) agentName = 'timing' + else if (tags.includes('context') || msg.startsWith('🔍')) agentName = 'context' + } else if (tags.includes('synthesis')) { + agentName = 'synthesis' + } + } + + if (agentName in SPECIALISTS) { + const parsed = parseInsight(agentName, msg) + setSpecialists((prev: Record) => ({ + ...prev, + [agentName]: { + ...prev[agentName], + insight: parsed, + thinkingState: 'revealed', + }, + })) + } + + if (agentName === 'synthesis') { + setPhase('synthesis') + if (msg.includes('Final:') || msg.includes('Decision:')) { + setSynthesisMessage(msg) + } + } + } + + if (event.type === 'execution_completed' && event.result) { + setIsRunning(false) + setPhase('complete') + const result = event.result.decision || event.result + const dec = result as Decision + setDecision(dec) + setError(null) + + setSpecialists((prev: Record) => { + const hasAnyInsight = Object.values(prev).some((s) => s.insight !== null) + if (!hasAnyInsight) { + const derived = deriveSpecialistInsights(dec) + const updated = { ...prev } + Object.keys(updated).forEach((key) => { + if (derived[key]) { + updated[key] = { + ...updated[key], + insight: derived[key], + thinkingState: 'revealed', + } + } + }) + return updated + } + return prev + }) + } + + if (event.type === 'execution_failed') { + setIsRunning(false) + setPhase('idle') + setError(event.error || 'Unknown error') + } + } + }, [events, phase]) + + const handleRun = useCallback(async () => { + if (!selectedScenarioId) return + + // Block event processing during reset to prevent reprocessing old events + isResettingRef.current = true + + // Reset all state BEFORE setting isRunning to prevent flash of old data with new running state + const reset: Record = {} + for (const [key, config] of Object.entries(SPECIALISTS)) { + reset[key] = { ...config, insight: null, thinkingState: 'idle' } + } + setSpecialists(reset) + setDecision(null) + setPhase('idle') + setSynthesisMessage(null) + setRoutingInfo(null) + setError(null) + setThinkingPhraseIndex(0) + clearEvents() + + // Set running state AFTER all data is reset + setIsRunning(true) + + try { + await fetch(`${API_URL}/trigger`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scenario_id: selectedScenarioId, + user_id: 'user_new_001', + }), + }) + } catch (err) { + setIsRunning(false) + setError(err instanceof Error ? err.message : 'Failed to trigger') + } + }, [selectedScenarioId, clearEvents]) + + const handleCustomRun = useCallback(async () => { + // Block event processing during reset to prevent reprocessing old events + isResettingRef.current = true + + // Reset all state BEFORE setting isRunning to prevent flash of old data with new running state + const reset: Record = {} + for (const [key, config] of Object.entries(SPECIALISTS)) { + reset[key] = { ...config, insight: null, thinkingState: 'idle' } + } + setSpecialists(reset) + setDecision(null) + setPhase('idle') + setSynthesisMessage(null) + setRoutingInfo(null) + setError(null) + setThinkingPhraseIndex(0) + clearEvents() + + // Set running state AFTER all data is reset + setIsRunning(true) + + let notification_data: Record = {} + const { notification_type } = customForm + + if (notification_type === 'abandoned_cart') { + notification_data = { + cart_value: customForm.cart_value || 100, + items: (customForm.items || '').split(',').map((s: string) => s.trim()).filter(Boolean), + abandoned_minutes_ago: customForm.abandoned_minutes || 30, + cart_id: `cart_${Date.now()}`, + } + } else if (notification_type === 'flash_sale') { + notification_data = { + discount_percent: customForm.discount_percent || 30, + expires_in_hours: customForm.expires_hours || 4, + category: customForm.category || 'General', + featured_items: (customForm.items || '').split(',').map((s: string) => s.trim()).filter(Boolean), + } + } else if (notification_type === 'back_in_stock') { + notification_data = { + item_name: customForm.item_name || 'Product', + item_price: customForm.item_price || 99.99, + stock_quantity: customForm.stock_quantity || 10, + wishlisted_days_ago: 7, + } + } else if (notification_type === 'price_drop') { + notification_data = { + item_name: customForm.item_name || 'Product', + original_price: customForm.original_price || 199.99, + new_price: customForm.new_price || 149.99, + discount_percent: + customForm.original_price && customForm.new_price + ? Math.round((1 - customForm.new_price / customForm.original_price) * 100) + : 25, + price_valid_until: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + } + } else if (notification_type === 'shipping_update') { + notification_data = { + order_id: customForm.order_id || `ORD-${Date.now()}`, + status: customForm.delivery_status || 'out_for_delivery', + estimated_delivery: customForm.estimated_delivery || 'Today by 6 PM', + carrier: 'UPS', + items_count: 1, + } + } + + const context: Record = { + user_tier: customForm.user_tier || 'standard', + previous_purchases: customForm.previous_purchases || 0, + notifications_sent_today: customForm.notifications_today || 0, + user_currently_browsing: customForm.user_currently_browsing || false, + current_hour_user_timezone: customForm.hour_in_user_timezone ?? 14, + recent_notifications_ignored: customForm.recent_notifications_ignored || 0, + user_engagement_level: customForm.user_engagement_level || 'high', + } + + try { + await fetch(`${API_URL}/trigger-custom`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + notification_type, + notification_data, + context, + user_id: 'user_new_001', + }), + }) + } catch (err) { + setIsRunning(false) + setError(err instanceof Error ? err.message : 'Failed to trigger custom scenario') + } + }, [customForm, clearEvents]) + + return ( + + +
+
+ 🔔 +
+
+

Notification Intelligence

+

Watch AI agents analyze and decide on notification delivery

+
+
+
+ + {connected ? 'Live' : 'Offline'} +
+
+ + + + + +
+ ) +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/components.json b/examples/python_agent_nodes/notification_intelligence/ui/components.json new file mode 100644 index 000000000..39a4fd3e8 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/components/DashboardShell.tsx b/examples/python_agent_nodes/notification_intelligence/ui/components/DashboardShell.tsx new file mode 100644 index 000000000..302dd6a07 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/components/DashboardShell.tsx @@ -0,0 +1,55 @@ +'use client' + +import React from 'react' + +function cn(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' ') +} + +interface DashboardShellProps extends React.HTMLAttributes { + children: React.ReactNode +} + +export function DashboardShell({ children, className, ...props }: DashboardShellProps) { + return ( +
+ {children} +
+ ) +} + +interface DashboardHeaderProps extends React.HTMLAttributes { + children: React.ReactNode +} + +export function DashboardHeader({ children, className, ...props }: DashboardHeaderProps) { + return ( +
+ {children} +
+ ) +} + +interface DashboardMainProps extends React.HTMLAttributes { + children: React.ReactNode +} + +export function DashboardMain({ children, className, ...props }: DashboardMainProps) { + return ( +
+ {children} +
+ ) +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/components/DecisionGraph.tsx b/examples/python_agent_nodes/notification_intelligence/ui/components/DecisionGraph.tsx new file mode 100644 index 000000000..232e14f57 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/components/DecisionGraph.tsx @@ -0,0 +1,594 @@ +'use client' + +import React from 'react' + +function cn(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' ') +} + +interface ParsedInsight { + raw: string + value?: string + score?: number + secondary?: string + reasoning?: string +} + +interface SpecialistData { + emoji: string + label: string + color: string + insight: ParsedInsight | null + thinkingState: 'idle' | 'thinking' | 'revealed' + thinkingStartTime?: number +} + +interface Decision { + deliver: string + channel: string + priority: number + reasoning: string +} + +type Phase = 'idle' | 'routing' | 'specialists' | 'synthesis' | 'complete' + +const GRAPH_ANALYST_KEYS = ['urgency', 'channel', 'user_state', 'timing'] as const +type GraphAnalystKey = (typeof GRAPH_ANALYST_KEYS)[number] + +const ANALYST_POSITIONS: Record = { + urgency: { left: '14%', top: '2%' }, + user_state: { left: '38%', top: '2%' }, + channel: { left: '62%', top: '2%' }, + timing: { left: '86%', top: '2%' }, +} + +const ANALYST_LINE_COLORS: Record = { + urgency: '#f59e0b', + channel: '#60a5fa', + user_state: '#a855f7', + timing: '#22c55e', +} + +const ACTION_NODES = [ + { key: 'now', label: 'Send Now', subtitle: 'Immediate delivery', icon: '📣' }, + { key: 'delay', label: 'Delay / Batch', subtitle: 'Wait for the best window', icon: '⏳' }, + { key: 'skip', label: 'Skip', subtitle: 'Suppress the notification', icon: '🛑' }, +] as const + +type ActionKey = (typeof ACTION_NODES)[number]['key'] + +const ACTION_POSITIONS: Record = { + now: { left: '20%', top: '58%' }, + delay: { left: '50%', top: '58%' }, + skip: { left: '80%', top: '58%' }, +} + +const ACTION_STYLES: Record = { + now: { border: 'border-emerald-400/50', bg: 'bg-emerald-500/10', text: 'text-emerald-200', line: '#34d399' }, + delay: { border: 'border-amber-400/50', bg: 'bg-amber-500/10', text: 'text-amber-200', line: '#f59e0b' }, + skip: { border: 'border-rose-400/50', bg: 'bg-rose-500/10', text: 'text-rose-200', line: '#f87171' }, +} + +const ANALYST_LINE_POINTS: Record = { + urgency: { x: 140, y: 75 }, + user_state: { x: 380, y: 75 }, + channel: { x: 620, y: 75 }, + timing: { x: 860, y: 75 }, +} + +const ACTION_LINE_POINTS: Record = { + now: { x: 200, y: 540 }, + delay: { x: 500, y: 540 }, + skip: { x: 800, y: 540 }, +} + +const CONSENSUS_POINT = { x: 500, y: 310 } +const OUTCOME_POINT = { x: 500, y: 740 } + +function buildBezierPath(start: { x: number; y: number }, end: { x: number; y: number }) { + const midY = (start.y + end.y) / 2 + return `M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}` +} + +const THINKING_PHRASES: Record = { + urgency: [ + 'Evaluating time sensitivity...', + 'Analyzing priority signals...', + 'Checking expiration windows...', + 'Assessing action urgency...', + ], + channel: [ + 'Matching delivery channels...', + 'Analyzing engagement history...', + 'Optimizing reach strategy...', + 'Selecting best medium...', + ], + user_state: [ + 'Reading user patterns...', + 'Checking fatigue signals...', + 'Analyzing engagement level...', + 'Predicting receptiveness...', + ], + timing: [ + 'Checking timezone context...', + 'Evaluating optimal windows...', + 'Analyzing user activity...', + 'Finding best moment...', + ], + context: [ + 'Extracting value signals...', + 'Analyzing situational factors...', + 'Weighing priority indicators...', + 'Building context picture...', + ], +} + +const colorClasses: Record = { + amber: { border: 'border-amber-500/40', bg: 'bg-amber-500/10', text: 'text-amber-400', bar: 'bg-amber-500' }, + blue: { border: 'border-blue-500/40', bg: 'bg-blue-500/10', text: 'text-blue-400', bar: 'bg-blue-500' }, + purple: { border: 'border-purple-500/40', bg: 'bg-purple-500/10', text: 'text-purple-400', bar: 'bg-purple-500' }, + green: { border: 'border-green-500/40', bg: 'bg-green-500/10', text: 'text-green-400', bar: 'bg-green-500' }, + cyan: { border: 'border-cyan-500/40', bg: 'bg-cyan-500/10', text: 'text-cyan-400', bar: 'bg-cyan-500' }, +} + +const channelConfig: Record = { + push: { icon: '📱', label: 'Push' }, + email: { icon: '📧', label: 'Email' }, + sms: { icon: '💬', label: 'SMS' }, + app: { icon: '🔔', label: 'In-App' }, +} + +interface DecisionGraphProps { + specialists: Record + phase: Phase + decision: Decision | null + isRunning: boolean + synthesisMessage: string | null + thinkingPhraseIndex: number + error: string | null + routingInfo: string | null + className?: string +} + +export function DecisionGraph({ + specialists, + phase, + decision, + isRunning, + synthesisMessage, + thinkingPhraseIndex, + error, + routingInfo, + className, +}: DecisionGraphProps) { + const analystProgress = GRAPH_ANALYST_KEYS.filter((key) => specialists[key]?.insight).length + const contextInsight = specialists.context?.insight + const channelLabel = decision ? channelConfig[decision.channel]?.label || decision.channel : null + // Only show decision-related visuals if we have a valid, complete decision + const hasValidDecision = decision && decision.deliver && decision.priority !== undefined && decision.channel + const decisionAction = hasValidDecision + ? decision.deliver === 'batch' + ? 'delay' + : decision.deliver + : null + const outcomeMessage = hasValidDecision + ? decision.deliver === 'now' + ? `Sent via ${channelLabel}` + : decision.deliver === 'delay' + ? 'Queued for an optimal window' + : decision.deliver === 'batch' + ? 'Added to the next batch digest' + : 'Suppressed to avoid fatigue' + : isRunning + ? 'Awaiting final consensus' + : 'Run a scenario to see the outcome' + + return ( +
+ {/* Error Display */} + {error && ( +
+ {error} +
+ )} + + {/* Routing Info */} + {routingInfo && ( +
+ 🎯 + {routingInfo.replace(/^[🌟⚡🎯]\s*/, '')} +
+ )} + + {/* Graph Container */} +
+
+
+
+
+
+
+

+ Decision Graph +

+

+ Four analysts converge, then route to send, delay, or skip. +

+
+
+ + {analystProgress}/4 analysts + + + {isRunning ? 'Live analysis' : decision ? 'Decision locked' : 'Idle'} + +
+
+ + {/* Desktop Graph */} +
+
+
+ + + + + + + + + + + + + + {GRAPH_ANALYST_KEYS.map((key) => ( + + ))} + {ACTION_NODES.map((action) => { + const isActive = decisionAction === action.key + const d = buildBezierPath(CONSENSUS_POINT, ACTION_LINE_POINTS[action.key]) + return ( + + {isActive && ( + + )} + + + ) + })} + {ACTION_NODES.map((action) => { + const isActive = decisionAction === action.key + const d = buildBezierPath(ACTION_LINE_POINTS[action.key], OUTCOME_POINT) + return ( + + {isActive && ( + + )} + + + ) + })} + + + {/* Analyst Nodes */} + {GRAPH_ANALYST_KEYS.map((key) => { + const spec = specialists[key] + if (!spec) return null + const colors = colorClasses[spec.color] + const hasInsight = Boolean(spec.insight) + const scoreValue = spec.insight?.score ?? 0 + const thinkingPhrase = THINKING_PHRASES[key]?.[thinkingPhraseIndex] || 'Analyzing...' + + return ( +
+ {hasInsight &&
} +
+
+ + {spec.emoji} + + + {spec.label} + +
+
+ {hasInsight ? ( + <> +
+ {spec.insight?.value || 'N/A'} +
+ {spec.insight?.score !== undefined && ( +
+
+
+ )} + + ) : ( +
+ {isRunning ? 'Analyzing...' : 'Waiting'} +
+ )} +
+
+
+ ) + })} + + {/* Consensus Node */} +
+
+
+
+ 🧠 + Consensus +
+
+ {synthesisMessage + ? synthesisMessage.replace(/^[✨🧩]\s*/, '').slice(0, 60) + (synthesisMessage.length > 60 ? '...' : '') + : isRunning + ? 'Synthesizing insights...' + : 'Waiting for analysts'} +
+ {decision && decision.priority !== undefined && channelLabel && ( +
+ + P{decision.priority} + + + {channelLabel} + +
+ )} +
+
+ + {/* Action Nodes */} + {ACTION_NODES.map((action) => { + const isActive = decisionAction === action.key + const styles = ACTION_STYLES[action.key] + return ( +
+
+
+
+ {action.icon} + {isActive && ( + + Active + + )} +
+
{action.label}
+
{action.subtitle}
+
+
+ ) + })} + + {/* Outcome Node */} +
+
+
+
+ 🎯 + Outcome +
+
{outcomeMessage}
+ {hasValidDecision && decision.reasoning && ( +
+ {decision.reasoning.length > 50 + ? `${decision.reasoning.slice(0, 50)}...` + : decision.reasoning} +
+ )} +
+
+
+ + {/* Mobile Graph */} +
+
+ {GRAPH_ANALYST_KEYS.map((key) => { + const spec = specialists[key] + if (!spec) return null + const colors = colorClasses[spec.color] + const hasInsight = Boolean(spec.insight) + const thinkingPhrase = THINKING_PHRASES[key]?.[thinkingPhraseIndex] || 'Analyzing...' + + return ( +
+
+ {spec.emoji} + + {spec.label} + +
+
+ {hasInsight ? spec.insight?.value || 'N/A' : isRunning ? thinkingPhrase : 'Waiting'} +
+ {spec.insight?.secondary && ( +
{spec.insight.secondary}
+ )} +
+ ) + })} +
+ +
+
+ 🧠 + Consensus +
+
+ {synthesisMessage + ? synthesisMessage.replace(/^[✨🧩]\s*/, '') + : isRunning + ? 'Synthesizing analyst inputs.' + : 'Waiting for analysts.'} +
+ {contextInsight?.value && ( +
Context: {contextInsight.value}
+ )} +
+ +
+ {ACTION_NODES.map((action) => { + const isActive = decisionAction === action.key + const styles = ACTION_STYLES[action.key] + return ( +
+
{action.icon}
+
{action.label}
+ {isActive && ( +
Selected
+ )} +
+ ) + })} +
+ +
+
+ 🎯 + Outcome +
+
{outcomeMessage}
+
+
+
+
+ + {/* Empty State */} + {phase === 'idle' && !decision && !error && ( +
+ Select a scenario and click Analyze to watch the agents + work +
+ )} +
+
+ ) +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/components/InputPanel.tsx b/examples/python_agent_nodes/notification_intelligence/ui/components/InputPanel.tsx new file mode 100644 index 000000000..aedcc755c --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/components/InputPanel.tsx @@ -0,0 +1,514 @@ +'use client' + +import React from 'react' + +function cn(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' ') +} + +interface Scenario { + id: string + name: string + description: string +} + +type ScenarioMode = 'predefined' | 'custom' + +const NOTIFICATION_TYPES = [ + { value: 'abandoned_cart', label: 'Abandoned Cart', icon: '🛒' }, + { value: 'flash_sale', label: 'Flash Sale', icon: '⚡' }, + { value: 'back_in_stock', label: 'Back in Stock', icon: '📦' }, + { value: 'price_drop', label: 'Price Drop', icon: '💰' }, + { value: 'shipping_update', label: 'Shipping Update', icon: '🚚' }, +] + +export interface CustomFormData { + notification_type: string + cart_value?: number + items?: string + abandoned_minutes?: number + discount_percent?: number + expires_hours?: number + category?: string + item_name?: string + item_price?: number + stock_quantity?: number + original_price?: number + new_price?: number + order_id?: string + delivery_status?: string + estimated_delivery?: string + user_tier?: string + previous_purchases?: number + notifications_today?: number + user_currently_browsing?: boolean + hour_in_user_timezone?: number + recent_notifications_ignored?: number + user_engagement_level?: string +} + +interface InputPanelProps { + scenarios: Scenario[] + selectedScenarioId: string + setSelectedScenarioId: (id: string) => void + scenarioMode: ScenarioMode + setScenarioMode: (mode: ScenarioMode) => void + customForm: CustomFormData + setCustomForm: React.Dispatch> + isRunning: boolean + onRunPredefined: () => void + onRunCustom: () => void + className?: string +} + +export function InputPanel({ + scenarios, + selectedScenarioId, + setSelectedScenarioId, + scenarioMode, + setScenarioMode, + customForm, + setCustomForm, + isRunning, + onRunPredefined, + onRunCustom, + className, +}: InputPanelProps) { + const selectedScenario = scenarios.find((s) => s.id === selectedScenarioId) + + return ( +
+ {/* Header */} +
+

+ Configuration +

+
+ + {/* Scrollable Content */} +
+ {/* Tabs */} +
+ + +
+ +
+ {scenarioMode === 'predefined' ? ( +
+
+ + +
+ {selectedScenario && ( +

{selectedScenario.description}

+ )} +
+ ) : ( +
+ {/* Notification Type */} +
+ +
+ {NOTIFICATION_TYPES.map((type) => ( + + ))} +
+
+ + {/* Dynamic Fields Based on Type */} +
+ {customForm.notification_type === 'abandoned_cart' && ( + <> +
+ + + setCustomForm((f) => ({ ...f, cart_value: parseFloat(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + setCustomForm((f) => ({ ...f, items: e.target.value }))} + disabled={isRunning} + placeholder="Shoes, Watch, Bag" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, abandoned_minutes: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+ + )} + + {customForm.notification_type === 'flash_sale' && ( + <> +
+ + + setCustomForm((f) => ({ ...f, discount_percent: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, expires_hours: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + setCustomForm((f) => ({ ...f, category: e.target.value }))} + disabled={isRunning} + placeholder="Electronics" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+ + )} + + {customForm.notification_type === 'back_in_stock' && ( + <> +
+ + setCustomForm((f) => ({ ...f, item_name: e.target.value }))} + disabled={isRunning} + placeholder="Sony Headphones" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, item_price: parseFloat(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, stock_quantity: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+ + )} + + {customForm.notification_type === 'price_drop' && ( + <> +
+ + setCustomForm((f) => ({ ...f, item_name: e.target.value }))} + disabled={isRunning} + placeholder="Dyson Vacuum" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, original_price: parseFloat(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, new_price: parseFloat(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+ + )} + + {customForm.notification_type === 'shipping_update' && ( + <> +
+ + setCustomForm((f) => ({ ...f, order_id: e.target.value }))} + disabled={isRunning} + placeholder="ORD-12345" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + +
+
+ + setCustomForm((f) => ({ ...f, estimated_delivery: e.target.value }))} + disabled={isRunning} + placeholder="Today by 6 PM" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+ + )} +
+ + {/* User Context */} +
+
User Context
+
+
+ + +
+
+ + + setCustomForm((f) => ({ ...f, previous_purchases: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + +
+
+ + +
+
+
+ + {/* Fatigue Signals */} +
+
+ Fatigue Signals (may cause skip/delay) +
+
+
+ + + setCustomForm((f) => ({ ...f, notifications_today: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ + ...f, + recent_notifications_ignored: parseInt(e.target.value) || 0, + })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+ + + setCustomForm((f) => ({ ...f, hour_in_user_timezone: parseInt(e.target.value) || 0 })) + } + disabled={isRunning} + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-xs text-zinc-100 focus:outline-none focus:border-indigo-500" + /> +
+
+
+
+ )} +
+
+ + {/* Footer with Button */} +
+ +
+
+ ) +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/hooks/useAgentStream.ts b/examples/python_agent_nodes/notification_intelligence/ui/hooks/useAgentStream.ts new file mode 100644 index 000000000..3ed35381f --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/hooks/useAgentStream.ts @@ -0,0 +1,125 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000' + +export interface AgentEvent { + type: string + execution_id?: string + agent_name?: string + event_type?: string + message?: string + tags?: string[] + result?: Record + error?: string + timestamp: string + // learning_update payload + response?: 'opened' | 'ignored' | 'dismissed' + new_state?: { + pattern_count?: number + analysis_mode?: string + specialists_used?: number + open_rate?: number + } + scenario?: { + id: string + name: string + notification_type: string + } + user?: { + id: string + analysis_mode: string + specialists_used: number + } +} + +export interface UseAgentStreamReturn { + connected: boolean + events: AgentEvent[] + currentExecution: string | null + clearEvents: () => void + clearTrace: () => void +} + +export function useAgentStream(): UseAgentStreamReturn { + const [connected, setConnected] = useState(false) + const [events, setEvents] = useState([]) + const [currentExecution, setCurrentExecution] = useState(null) + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return + + const ws = new WebSocket(`${WS_URL}/ws/events`) + + ws.onopen = () => { + setConnected(true) + console.log('WebSocket connected') + } + + ws.onclose = () => { + setConnected(false) + console.log('WebSocket disconnected, reconnecting...') + // Reconnect after 2 seconds + reconnectTimeoutRef.current = setTimeout(connect, 2000) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onmessage = (event) => { + try { + const data: AgentEvent = JSON.parse(event.data) + + // Track current execution + if (data.type === 'execution_started' && data.execution_id) { + setCurrentExecution(data.execution_id) + } else if (data.type === 'execution_completed' || data.type === 'execution_failed') { + // Don't clear execution - keep it for display + } + + setEvents((prev) => [...prev, data]) + } catch (e) { + console.error('Failed to parse WebSocket message:', e) + } + } + + wsRef.current = ws + }, []) + + const clearEvents = useCallback(() => { + setEvents([]) + setCurrentExecution(null) + }, []) + + const clearTrace = useCallback(() => { + setEvents([]) + }, []) + + useEffect(() => { + connect() + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + wsRef.current?.close() + } + }, [connect]) + + // Send periodic pings to keep connection alive + useEffect(() => { + const pingInterval = setInterval(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'ping' })) + } + }, 30000) + + return () => clearInterval(pingInterval) + }, []) + + return { connected, events, currentExecution, clearEvents, clearTrace } +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/next-env.d.ts b/examples/python_agent_nodes/notification_intelligence/ui/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/python_agent_nodes/notification_intelligence/ui/next.config.js b/examples/python_agent_nodes/notification_intelligence/ui/next.config.js new file mode 100644 index 000000000..5cd8cc341 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', +} + +module.exports = nextConfig diff --git a/examples/python_agent_nodes/notification_intelligence/ui/package-lock.json b/examples/python_agent_nodes/notification_intelligence/ui/package-lock.json new file mode 100644 index 000000000..e90282a3a --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/package-lock.json @@ -0,0 +1,2091 @@ +{ + "name": "notification-intelligence-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notification-intelligence-ui", + "version": "1.0.0", + "dependencies": { + "next": "^16.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.1", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/package.json b/examples/python_agent_nodes/notification_intelligence/ui/package.json new file mode 100644 index 000000000..46bc2254a --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "notification-intelligence-ui", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^16.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0" + } +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/postcss.config.js b/examples/python_agent_nodes/notification_intelligence/ui/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/public/.gitkeep b/examples/python_agent_nodes/notification_intelligence/ui/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/python_agent_nodes/notification_intelligence/ui/tailwind.config.js b/examples/python_agent_nodes/notification_intelligence/ui/tailwind.config.js new file mode 100644 index 000000000..bfcd5cbda --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './app/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './hooks/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/examples/python_agent_nodes/notification_intelligence/ui/tsconfig.json b/examples/python_agent_nodes/notification_intelligence/ui/tsconfig.json new file mode 100644 index 000000000..9e9bbf7b1 --- /dev/null +++ b/examples/python_agent_nodes/notification_intelligence/ui/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}