mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
16 Commits
first-pric
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ac2cb609 | ||
|
|
f2271e368e | ||
|
|
a1916c966c | ||
|
|
a2a443c027 | ||
|
|
ef98141ca8 | ||
| d45b344264 | |||
| a0b956b242 | |||
|
|
8751583764 | ||
| 59d4fb7891 | |||
| 7c2a819122 | |||
| 5941ffd085 | |||
| 955102090d | |||
| d654bbf4b4 | |||
|
|
ad9423bf59 | ||
|
|
2a0e44ab24 | ||
|
|
c432c45343 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,3 +6,8 @@
|
|||||||
**/session_*.svg
|
**/session_*.svg
|
||||||
**/*graph.svg
|
**/*graph.svg
|
||||||
paper/src/bib/auto
|
paper/src/bib/auto
|
||||||
|
|
||||||
|
# Airflow logs - exclude DAG run logs
|
||||||
|
experiments/airflow/logs/*
|
||||||
|
experiments/airflow/logs/scheduler/
|
||||||
|
experiments/airflow/logs/dag_processor_manager/
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -49,4 +49,8 @@ install: $(VENV)
|
|||||||
test: $(VENV)
|
test: $(VENV)
|
||||||
$(PYTEST) -v
|
$(PYTEST) -v
|
||||||
|
|
||||||
|
count-lines:
|
||||||
|
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
||||||
|
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||||
|
|
||||||
.PHONY: all pdf clean watch run.webapp install test
|
.PHONY: all pdf clean watch run.webapp install test
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -1,5 +1,12 @@
|
|||||||
|
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||||
|
|
||||||
|
### PHANTOM
|
||||||
|
|
||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
[](https://sites.research.google/trc/faq/)
|
||||||
|
[](https://phantom-hotel.vercel.app)
|
||||||
|
[](https://phantom-airline.vercel.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- https://phantom-hotel.vercel.app/
|
|
||||||
- https://phantom-airline.vercel.app/
|
|
||||||
|
|
||||||
|
|||||||
113
backend/provider/app.py
Normal file
113
backend/provider/app.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Literal, Optional
|
||||||
|
import uvicorn, os, sys
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Local imports of registry and pricing function
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../experiments/")
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
from procesing.steps import (
|
||||||
|
PredictPricesStep
|
||||||
|
)
|
||||||
|
from procesing import PipelineContext
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
# Config
|
||||||
|
app = FastAPI(title="PHANTOM Pricing Provider")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
||||||
|
registry = ModelRegistry()
|
||||||
|
|
||||||
|
class PriceResponse(BaseModel):
|
||||||
|
productId: str
|
||||||
|
price: float
|
||||||
|
base_price: float
|
||||||
|
markup: float
|
||||||
|
elasticity: Optional[float] = None
|
||||||
|
model_version: str = 'latest'
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "healthy", "redis": registry.health_check()}
|
||||||
|
|
||||||
|
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||||
|
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||||
|
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||||
|
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||||
|
|
||||||
|
metadata = product['metadata']
|
||||||
|
base_price = metadata.get('base_price', 100.0)
|
||||||
|
|
||||||
|
# fetch pre-computed prices from registry
|
||||||
|
prices_df = registry.get_prices('latest')
|
||||||
|
elasticity_df = registry.get_elasticity('latest')
|
||||||
|
|
||||||
|
if prices_df is None:
|
||||||
|
# fallback: no pre-computed prices available
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=base_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=1.0,
|
||||||
|
elasticity=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# lookup pre-computed price for this product
|
||||||
|
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||||
|
if product_price_row.empty:
|
||||||
|
# product not in pre-computed prices, fallback to base
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=base_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=1.0,
|
||||||
|
elasticity=None
|
||||||
|
)
|
||||||
|
|
||||||
|
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
||||||
|
|
||||||
|
# get elasticity if available
|
||||||
|
product_elasticity = None
|
||||||
|
if elasticity_df is not None:
|
||||||
|
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||||
|
if not product_elasticity_row.empty:
|
||||||
|
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
|
||||||
|
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=optimal_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=optimal_price/base_price,
|
||||||
|
elasticity=product_elasticity
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/models")
|
||||||
|
def list_models(): return registry.list_models()
|
||||||
|
|
||||||
|
@app.post("/models/reload")
|
||||||
|
def reload_models():
|
||||||
|
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
|
||||||
|
return {
|
||||||
|
"elasticity_loaded": bool(elasticity),
|
||||||
|
"n_products": len(elasticity) if elasticity is not None else 0,
|
||||||
|
"pricing_model_loaded": bool(pricing_model),
|
||||||
|
"model_class": pricing_model.__class__.__name__ if pricing_model else None
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))
|
||||||
16
backend/provider/requirements.txt
Normal file
16
backend/provider/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
pydantic
|
||||||
|
numpy
|
||||||
|
pandas
|
||||||
|
scikit-learn
|
||||||
|
redis
|
||||||
|
supabase
|
||||||
|
confluent-kafka>=2.3.0
|
||||||
|
kafka-python
|
||||||
|
graphviz
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
typing-extensions>=4.8.0
|
||||||
|
pypickle
|
||||||
|
pymc
|
||||||
@@ -290,6 +290,7 @@ async def get_products(
|
|||||||
query = supabase.table(table).select('*')
|
query = supabase.table(table).select('*')
|
||||||
|
|
||||||
# filter by exact date_index if provided
|
# filter by exact date_index if provided
|
||||||
|
# dateIndex from frontend is days from today, convert to days since epoch
|
||||||
if dateIndex is not None:
|
if dateIndex is not None:
|
||||||
query = query.eq('date_index', dateIndex)
|
query = query.eq('date_index', dateIndex)
|
||||||
|
|
||||||
|
|||||||
161
docker-compose.e2e.yml
Normal file
161
docker-compose.e2e.yml
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Docker Compose configuration for E2E testing
|
||||||
|
# Usage: docker compose -f docker-compose.e2e.yml up -d
|
||||||
|
#
|
||||||
|
# This configuration runs only the services needed for E2E pricing tests:
|
||||||
|
# - Backend API (event ingestion)
|
||||||
|
# - Kafka + Zookeeper (event streaming)
|
||||||
|
# - Redis (model registry)
|
||||||
|
# - Pricing Provider (price serving)
|
||||||
|
#
|
||||||
|
# Excluded for E2E tests:
|
||||||
|
# - Airflow (pipeline runs directly via test worker)
|
||||||
|
# - PostgreSQL (not needed without Airflow)
|
||||||
|
# - TensorBoard (ML visualization not needed)
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Backend API for event ingestion
|
||||||
|
backend:
|
||||||
|
container_name: "PHANTOM-e2e-backend"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-5000}:5000"
|
||||||
|
environment:
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_PORT=5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
depends_on:
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Redis for model registry
|
||||||
|
redis:
|
||||||
|
container_name: "PHANTOM-e2e-redis"
|
||||||
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Redis.dockerfile
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6378}:6379"
|
||||||
|
volumes:
|
||||||
|
- e2e_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Zookeeper for Kafka coordination
|
||||||
|
zookeeper:
|
||||||
|
container_name: "PHANTOM-e2e-zookeeper"
|
||||||
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Zookeeper.dockerfile
|
||||||
|
environment:
|
||||||
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
|
ports:
|
||||||
|
- "2181:2181"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Kafka for event streaming
|
||||||
|
kafka:
|
||||||
|
container_name: "PHANTOM-e2e-kafka"
|
||||||
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Kafka.dockerfile
|
||||||
|
depends_on:
|
||||||
|
zookeeper:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKER_ID: 1
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
||||||
|
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092
|
||||||
|
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
|
||||||
|
# Faster topic creation for tests
|
||||||
|
KAFKA_NUM_PARTITIONS: 1
|
||||||
|
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
|
||||||
|
ports:
|
||||||
|
- "${KAFKA_PORT:-9092}:9092"
|
||||||
|
volumes:
|
||||||
|
- e2e_kafka_data:/var/lib/kafka/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Redpanda Console for Kafka debugging (optional)
|
||||||
|
redpanda-console:
|
||||||
|
container_name: "PHANTOM-e2e-redpanda-console"
|
||||||
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: RedpandaConsole.dockerfile
|
||||||
|
depends_on:
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKERS: kafka:29092
|
||||||
|
ports:
|
||||||
|
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- debug # Only start with --profile debug
|
||||||
|
|
||||||
|
# Pricing Provider for serving prices
|
||||||
|
pricing-provider:
|
||||||
|
container_name: "PHANTOM-e2e-pricing-provider"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Provider.dockerfile
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- PROVIDER_PORT=5001
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
ports:
|
||||||
|
- "${PROVIDER_PORT:-5001}:5001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
e2e_kafka_data:
|
||||||
|
e2e_redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: phantom-e2e-network
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
|
tensorboard:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
container_name: "PHANTOM-tensorboard"
|
||||||
|
ports:
|
||||||
|
- "6006:6006"
|
||||||
|
volumes:
|
||||||
|
- ./experiments/ml/runs:/logs
|
||||||
|
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
container_name: "PHANTOM-backend"
|
container_name: "PHANTOM-backend"
|
||||||
build:
|
build:
|
||||||
@@ -71,6 +82,133 @@ services:
|
|||||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
container_name: "PHANTOM-postgres"
|
||||||
|
image: postgres:13
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=airflow
|
||||||
|
- POSTGRES_PASSWORD=airflow
|
||||||
|
- POSTGRES_DB=airflow
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
airflow-init:
|
||||||
|
container_name: "PHANTOM-airflow-init"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- _AIRFLOW_DB_MIGRATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_CREATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||||
|
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: version
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
container_name: "PHANTOM-airflow-webserver"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- airflow-init
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
ports:
|
||||||
|
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||||
|
command: webserver
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
airflow-scheduler:
|
||||||
|
container_name: "PHANTOM-airflow-scheduler"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
airflow-webserver:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
pricing-provider:
|
||||||
|
container_name: "PHANTOM-pricing-provider"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Provider.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- kafka
|
||||||
|
environment:
|
||||||
|
- PROVIDER_PORT=5001
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- BACKEND_URL=http://localhost:5000
|
||||||
|
ports:
|
||||||
|
- "${PROVIDER_PORT:-5001}:5001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
phantom_kafka_data:
|
phantom_kafka_data:
|
||||||
phantom_redis_data:
|
phantom_redis_data:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
30
docker/Airflow.dockerfile
Normal file
30
docker/Airflow.dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM apache/airflow:2.7.3-python3.11
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# install system deps if needed
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
# copy requirements for pipeline dependencies
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
# install postgres driver and providers
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
apache-airflow-providers-postgres
|
||||||
|
|
||||||
|
# set airflow home
|
||||||
|
ENV AIRFLOW_HOME=/opt/airflow
|
||||||
|
|
||||||
|
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||||
|
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||||
|
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||||
|
|
||||||
|
# create logs and plugins dirs (airflow expects them)
|
||||||
|
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||||
41
docker/Airflow.railway.dockerfile
Normal file
41
docker/Airflow.railway.dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM apache/airflow:2.7.3-python3.11
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
supervisor \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
apache-airflow-providers-postgres
|
||||||
|
|
||||||
|
ENV AIRFLOW_HOME=/opt/airflow
|
||||||
|
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||||
|
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||||
|
|
||||||
|
# copy all code into image (standalone - no volume mounts needed)
|
||||||
|
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||||
|
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||||
|
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||||
|
|
||||||
|
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||||
|
|
||||||
|
# copy entrypoint script
|
||||||
|
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||||
|
USER root
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
26
docker/Provider.dockerfile
Normal file
26
docker/Provider.dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies including graphviz
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
graphviz \
|
||||||
|
libgraphviz-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy and install Python dependencies
|
||||||
|
COPY backend/provider/requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code into image
|
||||||
|
COPY lib/ /app/lib/
|
||||||
|
COPY experiments/procesing/ /app/procesing/
|
||||||
|
COPY backend/provider/ /app/provider/
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||||
|
|
||||||
|
WORKDIR /app/provider
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||||
20
docker/airflow-railway-entrypoint.sh
Normal file
20
docker/airflow-railway-entrypoint.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# init db and create admin user on first run
|
||||||
|
airflow db migrate
|
||||||
|
|
||||||
|
# create admin user if not exists
|
||||||
|
airflow users create \
|
||||||
|
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||||
|
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||||
|
--firstname Admin \
|
||||||
|
--lastname User \
|
||||||
|
--role Admin \
|
||||||
|
--email admin@example.com || true
|
||||||
|
|
||||||
|
# start scheduler in background
|
||||||
|
airflow scheduler &
|
||||||
|
|
||||||
|
# start webserver in foreground (Railway needs one foreground process)
|
||||||
|
exec airflow webserver --port ${PORT:-8080}
|
||||||
255
e2e/README.md
Normal file
255
e2e/README.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# PHANTOM Dynamic Pricing E2E Test Suite
|
||||||
|
|
||||||
|
End-to-end tests validating the dynamic pricing pipeline, including SimpleSurgePricer and SessionAwarePricer functionality.
|
||||||
|
|
||||||
|
## System Under Test (SUT)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PHANTOM Pricing Pipeline │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ Test Runner │───▶│ Backend API │───▶│ Kafka (user-interactions)│ │
|
||||||
|
│ │ (Playwright)│ │ POST /ingest │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └────────────┬─────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ Pipeline Worker │ │
|
||||||
|
│ │ │ - Fetch interactions │ │
|
||||||
|
│ │ │ - Compute demand │ │
|
||||||
|
│ │ │ - Apply surge pricing │ │
|
||||||
|
│ │ └────────────┬─────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ Redis (Model Registry) │ │
|
||||||
|
│ │ │ - prices:latest │ │
|
||||||
|
│ │ └────────────┬─────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ └────▶│ Pricing API │◀──────────│ Pricing Provider │ │
|
||||||
|
│ │ GET /price │ │ (serves from Redis) │ │
|
||||||
|
│ └──────────────┘ └──────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
| Scenario | Description | Expected Outcome |
|
||||||
|
|----------|-------------|------------------|
|
||||||
|
| **Baseline** | No interactions for product | Price = base_price (markup = 1.0) |
|
||||||
|
| **Surge** | 5+ interactions (above threshold) | Price = base_price × 1.5 |
|
||||||
|
| **Discount** | 1 interaction (at threshold) | Price = base_price × 0.9 |
|
||||||
|
| **Multi-Product** | Different demand per product | Each product priced by its demand |
|
||||||
|
| **Propagation** | Pipeline → Redis → API | Prices visible via API |
|
||||||
|
| **Event Types** | Mix of view, click, cart | All events counted in demand |
|
||||||
|
| **Multi-Session** | Events from different sessions | Demand aggregated correctly |
|
||||||
|
|
||||||
|
## Test Configuration
|
||||||
|
|
||||||
|
The tests use aggressive thresholds for fast feedback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pricing: {
|
||||||
|
highThreshold: 3, // Surge after 3 interactions
|
||||||
|
lowThreshold: 1, // Discount at ≤1 interaction
|
||||||
|
surgeMultiplier: 1.5, // 50% price increase
|
||||||
|
discountMultiplier: 0.9, // 10% discount
|
||||||
|
windowSize: 10_000, // 10 second window
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start E2E Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start minimal services for E2E testing
|
||||||
|
docker compose -f docker-compose.e2e.yml up -d
|
||||||
|
|
||||||
|
# Wait for services to be healthy
|
||||||
|
docker compose -f docker-compose.e2e.yml ps
|
||||||
|
|
||||||
|
# Optional: Start with Kafka UI for debugging
|
||||||
|
docker compose -f docker-compose.e2e.yml --profile debug up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Test Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npm install
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with UI (interactive mode)
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npm run test:pricing
|
||||||
|
|
||||||
|
# Run in debug mode
|
||||||
|
npm run test:debug
|
||||||
|
|
||||||
|
# View test report
|
||||||
|
npm run test:report
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.e2e.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BACKEND_URL` | `http://localhost:5000` | Backend API URL |
|
||||||
|
| `PROVIDER_URL` | `http://localhost:5001` | Pricing Provider URL |
|
||||||
|
| `REDIS_HOST` | `localhost` | Redis host |
|
||||||
|
| `REDIS_PORT` | `6378` | Redis port |
|
||||||
|
| `KAFKA_HOST` | `localhost` | Kafka host |
|
||||||
|
| `KAFKA_PORT` | `9092` | Kafka port |
|
||||||
|
|
||||||
|
## Test Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── playwright.config.ts # Playwright configuration
|
||||||
|
├── global-setup.ts # Service health checks
|
||||||
|
├── global-teardown.ts # Cleanup
|
||||||
|
├── package.json # Dependencies and scripts
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
├── lib/
|
||||||
|
│ ├── api-client.ts # API interaction utilities
|
||||||
|
│ ├── event-generator.ts # Test event factory
|
||||||
|
│ ├── pipeline-runner.ts # TypeScript pipeline wrapper
|
||||||
|
│ ├── pipeline-worker.py # Python pipeline executor
|
||||||
|
│ ├── fixtures.ts # Playwright test fixtures
|
||||||
|
│ └── index.ts # Re-exports
|
||||||
|
└── tests/
|
||||||
|
└── dynamic-pricing.spec.ts # Main test file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pipeline Worker
|
||||||
|
|
||||||
|
The tests use a dedicated Python pipeline worker (`lib/pipeline-worker.py`) instead of Airflow for faster, more reliable test execution.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run pipeline manually
|
||||||
|
python3 lib/pipeline-worker.py \
|
||||||
|
--store-mode hotel \
|
||||||
|
--high-threshold 3 \
|
||||||
|
--surge-multiplier 1.5 \
|
||||||
|
--json-output
|
||||||
|
|
||||||
|
# Dry run (no Redis publish)
|
||||||
|
python3 lib/pipeline-worker.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View Kafka Events
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via API
|
||||||
|
curl "http://localhost:5000/api/kafka/dump?topic=user-interactions&last_n=10"
|
||||||
|
|
||||||
|
# Via Redpanda Console (if started with --profile debug)
|
||||||
|
open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Redis State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it PHANTOM-e2e-redis redis-cli
|
||||||
|
> GET prices:latest
|
||||||
|
> KEYS *
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Pipeline Logs
|
||||||
|
|
||||||
|
The pipeline worker logs detailed information:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] Starting E2E pricing pipeline: mode=hotel, high_threshold=3, surge_multiplier=1.5
|
||||||
|
[INFO] Fetched 15 interaction records
|
||||||
|
[INFO] Computed demand for 3 products
|
||||||
|
[INFO] Applied surge pricing:
|
||||||
|
e2e-test...: base=$100.00 -> optimal=$150.00 (demand=5, markup=1.50x)
|
||||||
|
[INFO] Published 3 prices to Redis
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '../lib/fixtures';
|
||||||
|
import { generateTestProductId } from '../lib/event-generator';
|
||||||
|
|
||||||
|
test('my new pricing test', async ({ api, events, triggerPriceUpdate }) => {
|
||||||
|
// 1. Create unique product ID
|
||||||
|
const productId = generateTestProductId('my-test');
|
||||||
|
|
||||||
|
// 2. Log base price
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 100.0,
|
||||||
|
sessionId: events.session,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Generate events
|
||||||
|
const surgeEvents = events.generateSurgeSequence(productId, 5);
|
||||||
|
await api.ingestEvents(surgeEvents);
|
||||||
|
|
||||||
|
// 4. Trigger pipeline
|
||||||
|
const result = await triggerPriceUpdate();
|
||||||
|
|
||||||
|
// 5. Verify results
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const pricedProduct = result.prices?.find(p => p.productId === productId);
|
||||||
|
expect(pricedProduct?.optimal_price).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Backend not available"
|
||||||
|
|
||||||
|
Ensure services are running:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.e2e.yml ps
|
||||||
|
docker compose -f docker-compose.e2e.yml logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### "No interactions found"
|
||||||
|
|
||||||
|
Check Kafka topic has events:
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:5000/api/kafka/dump?topic=user-interactions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Pipeline timeout"
|
||||||
|
|
||||||
|
Increase timeout in `playwright.config.ts`:
|
||||||
|
```typescript
|
||||||
|
timeout: 180_000, // 3 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Price not updated"
|
||||||
|
|
||||||
|
Check Redis has latest prices:
|
||||||
|
```bash
|
||||||
|
docker exec -it PHANTOM-e2e-redis redis-cli GET prices:latest
|
||||||
|
```
|
||||||
47
e2e/global-setup.ts
Normal file
47
e2e/global-setup.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { testConfig } from './playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global setup for E2E tests
|
||||||
|
* Verifies all services are healthy before running tests
|
||||||
|
*/
|
||||||
|
async function globalSetup() {
|
||||||
|
console.log('\n🚀 PHANTOM E2E Test Suite - Global Setup\n');
|
||||||
|
|
||||||
|
// Check backend health
|
||||||
|
await checkService('Backend API', `${testConfig.backendUrl}/health`);
|
||||||
|
|
||||||
|
// Check pricing provider health
|
||||||
|
await checkService('Pricing Provider', `${testConfig.providerUrl}/health`);
|
||||||
|
|
||||||
|
console.log('\n✅ All services healthy. Starting tests...\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkService(name: string, url: string): Promise<void> {
|
||||||
|
const maxRetries = 10;
|
||||||
|
const retryDelay = 2000;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ ${name}: healthy`);
|
||||||
|
if (data.redis !== undefined) {
|
||||||
|
console.log(` └─ Redis: ${data.redis ? 'connected' : 'disconnected'}`);
|
||||||
|
}
|
||||||
|
if (data.kafka !== undefined) {
|
||||||
|
console.log(` └─ Kafka: ${data.kafka}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
throw new Error(`❌ ${name} is not available at ${url} after ${maxRetries} attempts`);
|
||||||
|
}
|
||||||
|
console.log(`⏳ Waiting for ${name} (attempt ${attempt}/${maxRetries})...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
10
e2e/global-teardown.ts
Normal file
10
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Global teardown for E2E tests
|
||||||
|
* Cleans up test data and resources
|
||||||
|
*/
|
||||||
|
async function globalTeardown() {
|
||||||
|
console.log('\n🧹 PHANTOM E2E Test Suite - Global Teardown\n');
|
||||||
|
console.log('✅ Cleanup complete\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalTeardown;
|
||||||
191
e2e/lib/api-client.ts
Normal file
191
e2e/lib/api-client.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { testConfig } from '../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payload structure matching the backend API
|
||||||
|
*/
|
||||||
|
export interface EventPayload {
|
||||||
|
sessionId: string;
|
||||||
|
experimentId?: string;
|
||||||
|
eventName: string;
|
||||||
|
page: string;
|
||||||
|
productId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
storeMode: 'hotel' | 'airline';
|
||||||
|
userAgent?: string;
|
||||||
|
ts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price log payload structure
|
||||||
|
*/
|
||||||
|
export interface PriceLogPayload {
|
||||||
|
productId: string;
|
||||||
|
price: number;
|
||||||
|
sessionId: string;
|
||||||
|
experimentId?: string;
|
||||||
|
storeMode: 'hotel' | 'airline';
|
||||||
|
ts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price response from the pricing provider
|
||||||
|
*/
|
||||||
|
export interface PriceResponse {
|
||||||
|
productId: string;
|
||||||
|
price: number;
|
||||||
|
base_price: number;
|
||||||
|
markup: number;
|
||||||
|
elasticity: number | null;
|
||||||
|
model_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API client for interacting with PHANTOM services
|
||||||
|
*/
|
||||||
|
export class PhantomApiClient {
|
||||||
|
private backendUrl: string;
|
||||||
|
private providerUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
backendUrl: string = testConfig.backendUrl,
|
||||||
|
providerUrl: string = testConfig.providerUrl
|
||||||
|
) {
|
||||||
|
this.backendUrl = backendUrl;
|
||||||
|
this.providerUrl = providerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a user interaction event to the ingestion API
|
||||||
|
*/
|
||||||
|
async ingestEvent(event: EventPayload): Promise<{ success: boolean }> {
|
||||||
|
const payload: EventPayload = {
|
||||||
|
...event,
|
||||||
|
ts: event.ts || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${this.backendUrl}/api/kafka/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to ingest event: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send multiple events in rapid succession
|
||||||
|
*/
|
||||||
|
async ingestEvents(events: EventPayload[], delayMs: number = 100): Promise<void> {
|
||||||
|
for (const event of events) {
|
||||||
|
await this.ingestEvent(event);
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a price observation
|
||||||
|
*/
|
||||||
|
async logPrice(priceLog: PriceLogPayload): Promise<{ success: boolean }> {
|
||||||
|
const payload: PriceLogPayload = {
|
||||||
|
...priceLog,
|
||||||
|
ts: priceLog.ts || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${this.backendUrl}/api/kafka/price-log`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to log price: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current price for a product from the pricing provider
|
||||||
|
*/
|
||||||
|
async getPrice(
|
||||||
|
mode: 'hotel' | 'airline',
|
||||||
|
productId: string,
|
||||||
|
sessionId?: string
|
||||||
|
): Promise<PriceResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (sessionId) {
|
||||||
|
params.set('sessionId', sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.providerUrl}/api/${mode}/price/${productId}${params.toString() ? '?' + params.toString() : ''}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get price: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dump events from Kafka topic for debugging
|
||||||
|
*/
|
||||||
|
async dumpKafkaEvents(
|
||||||
|
topic: 'user-interactions' | 'price-logs' = 'user-interactions',
|
||||||
|
lastN?: number
|
||||||
|
): Promise<{ success: boolean; count: number; data: unknown[] }> {
|
||||||
|
const params = new URLSearchParams({ topic });
|
||||||
|
if (lastN) {
|
||||||
|
params.set('last_n', String(lastN));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.backendUrl}/api/kafka/dump?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to dump Kafka events: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check health of backend service
|
||||||
|
*/
|
||||||
|
async checkBackendHealth(): Promise<{ status: string; kafka: string }> {
|
||||||
|
const response = await fetch(`${this.backendUrl}/health`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check health of pricing provider
|
||||||
|
*/
|
||||||
|
async checkProviderHealth(): Promise<{ status: string; redis: boolean }> {
|
||||||
|
const response = await fetch(`${this.providerUrl}/health`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List registered models in the pricing provider
|
||||||
|
*/
|
||||||
|
async listModels(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetch(`${this.providerUrl}/models`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload models in the pricing provider
|
||||||
|
*/
|
||||||
|
async reloadModels(): Promise<{ elasticity_loaded: boolean; pricing_model_loaded: boolean }> {
|
||||||
|
const response = await fetch(`${this.providerUrl}/models/reload`, { method: 'POST' });
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance for convenience
|
||||||
|
export const apiClient = new PhantomApiClient();
|
||||||
249
e2e/lib/event-generator.ts
Normal file
249
e2e/lib/event-generator.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { EventPayload, PriceLogPayload } from './api-client';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical event names matching the frontend
|
||||||
|
*/
|
||||||
|
export const EventNames = {
|
||||||
|
// Navigation events
|
||||||
|
PAGE_VIEW: 'page_view',
|
||||||
|
VIEW_ITEM_PAGE: 'view_item_page',
|
||||||
|
LEARN_MORE: 'learn_more_about_item',
|
||||||
|
|
||||||
|
// Cart events
|
||||||
|
ADD_TO_CART: 'add_item_to_cart',
|
||||||
|
REMOVE_FROM_CART: 'remove_item',
|
||||||
|
CHECKOUT_START: 'checkout_start',
|
||||||
|
PURCHASE_COMPLETE: 'purchase_complete',
|
||||||
|
|
||||||
|
// Search/Filter events
|
||||||
|
SEARCH: 'search',
|
||||||
|
FILTER_DATE: 'filter_for_date',
|
||||||
|
FILTER_AMENITIES: 'filter_for_amenities',
|
||||||
|
FILTER_PRICE: 'filter_for_price',
|
||||||
|
SORT_CHANGE: 'sort_change',
|
||||||
|
|
||||||
|
// Dwell signals (engagement)
|
||||||
|
HOVER_TITLE: 'hover_over_title',
|
||||||
|
HOVER_PARAGRAPH: 'hover_over_paragraph',
|
||||||
|
HOVER_LINK: 'hover_over_link',
|
||||||
|
HOVER_BUTTON: 'hover_over_button',
|
||||||
|
|
||||||
|
// Session
|
||||||
|
SESSION_START: 'session_start',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventName = typeof EventNames[keyof typeof EventNames];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test product configuration
|
||||||
|
*/
|
||||||
|
export interface TestProduct {
|
||||||
|
id: string;
|
||||||
|
basePrice: number;
|
||||||
|
storeMode: 'hotel' | 'airline';
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates test events for dynamic pricing E2E tests
|
||||||
|
*/
|
||||||
|
export class EventGenerator {
|
||||||
|
private sessionId: string;
|
||||||
|
private experimentId: string;
|
||||||
|
private storeMode: 'hotel' | 'airline';
|
||||||
|
|
||||||
|
constructor(options?: {
|
||||||
|
sessionId?: string;
|
||||||
|
experimentId?: string;
|
||||||
|
storeMode?: 'hotel' | 'airline';
|
||||||
|
}) {
|
||||||
|
this.sessionId = options?.sessionId || uuidv4();
|
||||||
|
this.experimentId = options?.experimentId || uuidv4();
|
||||||
|
this.storeMode = options?.storeMode || 'hotel';
|
||||||
|
}
|
||||||
|
|
||||||
|
get session(): string {
|
||||||
|
return this.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get experiment(): string {
|
||||||
|
return this.experimentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session for isolation between test scenarios
|
||||||
|
*/
|
||||||
|
newSession(): string {
|
||||||
|
this.sessionId = uuidv4();
|
||||||
|
return this.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single event
|
||||||
|
*/
|
||||||
|
createEvent(
|
||||||
|
eventName: EventName,
|
||||||
|
productId: string,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
): EventPayload {
|
||||||
|
return {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
experimentId: this.experimentId,
|
||||||
|
eventName,
|
||||||
|
page: `/${this.storeMode}/products/${productId}`,
|
||||||
|
productId,
|
||||||
|
metadata: metadata || {},
|
||||||
|
storeMode: this.storeMode,
|
||||||
|
userAgent: 'PHANTOM-E2E-Test/1.0',
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a product view event
|
||||||
|
*/
|
||||||
|
viewProduct(productId: string): EventPayload {
|
||||||
|
return this.createEvent(EventNames.VIEW_ITEM_PAGE, productId, {
|
||||||
|
referrer: `/${this.storeMode}/products`,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a "learn more" event (high intent signal)
|
||||||
|
*/
|
||||||
|
learnMore(productId: string): EventPayload {
|
||||||
|
return this.createEvent(EventNames.LEARN_MORE, productId, {
|
||||||
|
section: 'details',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hover event (engagement signal)
|
||||||
|
*/
|
||||||
|
hover(productId: string, element: 'title' | 'paragraph' | 'button' = 'title'): EventPayload {
|
||||||
|
const eventMap = {
|
||||||
|
title: EventNames.HOVER_TITLE,
|
||||||
|
paragraph: EventNames.HOVER_PARAGRAPH,
|
||||||
|
button: EventNames.HOVER_BUTTON,
|
||||||
|
};
|
||||||
|
return this.createEvent(eventMap[element], productId, {
|
||||||
|
duration_ms: Math.floor(Math.random() * 2000) + 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an add-to-cart event
|
||||||
|
*/
|
||||||
|
addToCart(productId: string, quantity: number = 1): EventPayload {
|
||||||
|
return this.createEvent(EventNames.ADD_TO_CART, productId, {
|
||||||
|
quantity,
|
||||||
|
cart_size: quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a sequence of high-velocity events for surge pricing trigger
|
||||||
|
* This simulates rapid user interest in a product
|
||||||
|
*/
|
||||||
|
generateSurgeSequence(productId: string, count: number): EventPayload[] {
|
||||||
|
const events: EventPayload[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Mix of different event types to simulate realistic behavior
|
||||||
|
events.push(this.viewProduct(productId));
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
events.push(this.learnMore(productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i % 3 === 0) {
|
||||||
|
events.push(this.hover(productId, 'title'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a normal browsing session (not triggering surge)
|
||||||
|
*/
|
||||||
|
generateNormalSession(productId: string): EventPayload[] {
|
||||||
|
return [
|
||||||
|
this.viewProduct(productId),
|
||||||
|
this.hover(productId, 'title'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate high-velocity agent-like behavior
|
||||||
|
* This should trigger SessionAwarePricer's agent detection
|
||||||
|
*/
|
||||||
|
generateAgentBehavior(productIds: string[]): EventPayload[] {
|
||||||
|
const events: EventPayload[] = [];
|
||||||
|
|
||||||
|
// Rapid-fire product views across multiple products
|
||||||
|
for (const productId of productIds) {
|
||||||
|
events.push(this.viewProduct(productId));
|
||||||
|
// Very quick succession - agent-like behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a price log entry
|
||||||
|
*/
|
||||||
|
createPriceLog(productId: string, price: number): PriceLogPayload {
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
price,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
experimentId: this.experimentId,
|
||||||
|
storeMode: this.storeMode,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured test products for E2E tests
|
||||||
|
* These should match products in your test database
|
||||||
|
*/
|
||||||
|
export const TestProducts = {
|
||||||
|
// Hotel products with known base prices
|
||||||
|
hotel1: {
|
||||||
|
id: 'e2e-test-hotel-001',
|
||||||
|
basePrice: 150.00,
|
||||||
|
storeMode: 'hotel' as const,
|
||||||
|
name: 'E2E Test Hotel 1',
|
||||||
|
},
|
||||||
|
hotel2: {
|
||||||
|
id: 'e2e-test-hotel-002',
|
||||||
|
basePrice: 200.00,
|
||||||
|
storeMode: 'hotel' as const,
|
||||||
|
name: 'E2E Test Hotel 2',
|
||||||
|
},
|
||||||
|
hotel3: {
|
||||||
|
id: 'e2e-test-hotel-003',
|
||||||
|
basePrice: 100.00,
|
||||||
|
storeMode: 'hotel' as const,
|
||||||
|
name: 'E2E Test Hotel 3',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Airline products
|
||||||
|
airline1: {
|
||||||
|
id: 'e2e-test-airline-001',
|
||||||
|
basePrice: 350.00,
|
||||||
|
storeMode: 'airline' as const,
|
||||||
|
name: 'E2E Test Flight 1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique test product ID for isolation
|
||||||
|
*/
|
||||||
|
export function generateTestProductId(prefix: string = 'e2e-test'): string {
|
||||||
|
return `${prefix}-${uuidv4().slice(0, 8)}`;
|
||||||
|
}
|
||||||
143
e2e/lib/fixtures.ts
Normal file
143
e2e/lib/fixtures.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { test as base, expect } from '@playwright/test';
|
||||||
|
import { PhantomApiClient, apiClient } from './api-client';
|
||||||
|
import { EventGenerator, TestProducts } from './event-generator';
|
||||||
|
import { runPricingPipeline, waitForPriceUpdate, PipelineResult } from './pipeline-runner';
|
||||||
|
import { testConfig } from '../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended test fixtures for PHANTOM E2E tests
|
||||||
|
*/
|
||||||
|
export interface PhantomTestFixtures {
|
||||||
|
/** API client for interacting with PHANTOM services */
|
||||||
|
api: PhantomApiClient;
|
||||||
|
|
||||||
|
/** Event generator for creating test events */
|
||||||
|
events: EventGenerator;
|
||||||
|
|
||||||
|
/** Run the pricing pipeline and wait for updates */
|
||||||
|
triggerPriceUpdate: (options?: {
|
||||||
|
storeMode?: 'hotel' | 'airline';
|
||||||
|
highThreshold?: number;
|
||||||
|
lowThreshold?: number;
|
||||||
|
surgeMultiplier?: number;
|
||||||
|
discountMultiplier?: number;
|
||||||
|
}) => Promise<PipelineResult>;
|
||||||
|
|
||||||
|
/** Wait for a specific price condition */
|
||||||
|
waitForPrice: (
|
||||||
|
productId: string,
|
||||||
|
condition: (price: number, basePrice: number) => boolean,
|
||||||
|
storeMode?: 'hotel' | 'airline'
|
||||||
|
) => Promise<{ price: number; basePrice: number; markup: number }>;
|
||||||
|
|
||||||
|
/** Test configuration */
|
||||||
|
config: typeof testConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom test with PHANTOM fixtures
|
||||||
|
*/
|
||||||
|
export const test = base.extend<PhantomTestFixtures>({
|
||||||
|
api: async ({}, use) => {
|
||||||
|
await use(apiClient);
|
||||||
|
},
|
||||||
|
|
||||||
|
events: async ({}, use) => {
|
||||||
|
// Create a new event generator with a fresh session for each test
|
||||||
|
const generator = new EventGenerator({
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
await use(generator);
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerPriceUpdate: async ({}, use) => {
|
||||||
|
const trigger = async (options = {}) => {
|
||||||
|
const result = await runPricingPipeline({
|
||||||
|
storeMode: 'hotel',
|
||||||
|
highThreshold: testConfig.pricing.highThreshold,
|
||||||
|
lowThreshold: testConfig.pricing.lowThreshold,
|
||||||
|
surgeMultiplier: testConfig.pricing.surgeMultiplier,
|
||||||
|
discountMultiplier: testConfig.pricing.discountMultiplier,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a moment for Redis to be fully updated
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
await use(trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
waitForPrice: async ({ api }, use) => {
|
||||||
|
const waiter = async (
|
||||||
|
productId: string,
|
||||||
|
condition: (price: number, basePrice: number) => boolean,
|
||||||
|
storeMode: 'hotel' | 'airline' = 'hotel'
|
||||||
|
) => {
|
||||||
|
let lastPrice = 0;
|
||||||
|
let lastBasePrice = 0;
|
||||||
|
|
||||||
|
const updated = await waitForPriceUpdate(async () => {
|
||||||
|
const priceResponse = await api.getPrice(storeMode, productId);
|
||||||
|
lastPrice = priceResponse.price;
|
||||||
|
lastBasePrice = priceResponse.base_price;
|
||||||
|
return condition(priceResponse.price, priceResponse.base_price);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(
|
||||||
|
`Price condition not met within timeout. Last price: ${lastPrice}, base: ${lastBasePrice}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: lastPrice,
|
||||||
|
basePrice: lastBasePrice,
|
||||||
|
markup: lastPrice / lastBasePrice,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await use(waiter);
|
||||||
|
},
|
||||||
|
|
||||||
|
config: async ({}, use) => {
|
||||||
|
await use(testConfig);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper assertions for pricing tests
|
||||||
|
*/
|
||||||
|
export const PricingAssertions = {
|
||||||
|
/**
|
||||||
|
* Assert that a price has surge markup applied
|
||||||
|
*/
|
||||||
|
isSurged: (price: number, basePrice: number, expectedMultiplier: number, tolerance = 0.01) => {
|
||||||
|
const actualMarkup = price / basePrice;
|
||||||
|
const minExpected = expectedMultiplier * (1 - tolerance);
|
||||||
|
const maxExpected = expectedMultiplier * (1 + tolerance);
|
||||||
|
return actualMarkup >= minExpected && actualMarkup <= maxExpected;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a price has discount applied
|
||||||
|
*/
|
||||||
|
isDiscounted: (price: number, basePrice: number, expectedMultiplier: number, tolerance = 0.01) => {
|
||||||
|
const actualMarkup = price / basePrice;
|
||||||
|
const minExpected = expectedMultiplier * (1 - tolerance);
|
||||||
|
const maxExpected = expectedMultiplier * (1 + tolerance);
|
||||||
|
return actualMarkup >= minExpected && actualMarkup <= maxExpected;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a price is at base (no surge/discount)
|
||||||
|
*/
|
||||||
|
isBase: (price: number, basePrice: number, tolerance = 0.01) => {
|
||||||
|
const actualMarkup = price / basePrice;
|
||||||
|
return actualMarkup >= (1 - tolerance) && actualMarkup <= (1 + tolerance);
|
||||||
|
},
|
||||||
|
};
|
||||||
6
e2e/lib/index.ts
Normal file
6
e2e/lib/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Re-export all test utilities
|
||||||
|
|
||||||
|
export * from './api-client';
|
||||||
|
export * from './event-generator';
|
||||||
|
export * from './pipeline-runner';
|
||||||
|
export * from './fixtures';
|
||||||
152
e2e/lib/pipeline-runner.ts
Normal file
152
e2e/lib/pipeline-runner.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { testConfig } from '../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline execution result
|
||||||
|
*/
|
||||||
|
export interface PipelineResult {
|
||||||
|
success: boolean;
|
||||||
|
interactions_count: number;
|
||||||
|
products_count: number;
|
||||||
|
prices_published: boolean;
|
||||||
|
prices?: Array<{
|
||||||
|
productId: string;
|
||||||
|
current_price: number;
|
||||||
|
base_price: number;
|
||||||
|
optimal_price: number;
|
||||||
|
demand_score: number;
|
||||||
|
}>;
|
||||||
|
timestamp?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline configuration options
|
||||||
|
*/
|
||||||
|
export interface PipelineOptions {
|
||||||
|
storeMode?: 'hotel' | 'airline';
|
||||||
|
highThreshold?: number;
|
||||||
|
lowThreshold?: number;
|
||||||
|
surgeMultiplier?: number;
|
||||||
|
discountMultiplier?: number;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the pricing pipeline to update prices based on current events
|
||||||
|
*/
|
||||||
|
export async function runPricingPipeline(options: PipelineOptions = {}): Promise<PipelineResult> {
|
||||||
|
const {
|
||||||
|
storeMode = 'hotel',
|
||||||
|
highThreshold = testConfig.pricing.highThreshold,
|
||||||
|
lowThreshold = testConfig.pricing.lowThreshold,
|
||||||
|
surgeMultiplier = testConfig.pricing.surgeMultiplier,
|
||||||
|
discountMultiplier = testConfig.pricing.discountMultiplier,
|
||||||
|
dryRun = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const workerPath = path.join(__dirname, 'pipeline-worker.py');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
workerPath,
|
||||||
|
'--store-mode', storeMode,
|
||||||
|
'--high-threshold', String(highThreshold),
|
||||||
|
'--low-threshold', String(lowThreshold),
|
||||||
|
'--surge-multiplier', String(surgeMultiplier),
|
||||||
|
'--discount-multiplier', String(discountMultiplier),
|
||||||
|
'--json-output',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
args.push('--dry-run');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const python = spawn('python3', args, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BACKEND_URL: testConfig.backendUrl,
|
||||||
|
REDIS_HOST: testConfig.redisHost,
|
||||||
|
REDIS_PORT: String(testConfig.redisPort),
|
||||||
|
KAFKA_HOST: testConfig.kafkaHost,
|
||||||
|
KAFKA_PORT: String(testConfig.kafkaPort),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
python.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
python.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
// Log pipeline output for debugging
|
||||||
|
console.log('[Pipeline]', data.toString().trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
python.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
// Find JSON output in stdout (last JSON object)
|
||||||
|
const jsonMatch = stdout.match(/\{[\s\S]*\}$/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
resolve(result);
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
interactions_count: 0,
|
||||||
|
products_count: 0,
|
||||||
|
prices_published: false,
|
||||||
|
message: 'Pipeline completed but no JSON output',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
interactions_count: 0,
|
||||||
|
products_count: 0,
|
||||||
|
prices_published: false,
|
||||||
|
message: 'Pipeline completed but output not parseable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Pipeline exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
python.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to start pipeline: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for prices to be updated in Redis and available via the pricing API
|
||||||
|
*/
|
||||||
|
export async function waitForPriceUpdate(
|
||||||
|
checkFn: () => Promise<boolean>,
|
||||||
|
maxWaitMs: number = testConfig.timing.maxPriceWait,
|
||||||
|
intervalMs: number = testConfig.timing.priceCheckInterval
|
||||||
|
): Promise<boolean> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
try {
|
||||||
|
const updated = await checkFn();
|
||||||
|
if (updated) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors during polling
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
245
e2e/lib/pipeline-worker.py
Normal file
245
e2e/lib/pipeline-worker.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
E2E Test Pipeline Worker
|
||||||
|
|
||||||
|
A lightweight worker that runs the surge pricing pipeline for E2E tests.
|
||||||
|
This bypasses Airflow for faster, more reliable test execution.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python pipeline-worker.py --store-mode hotel --high-threshold 3 --surge-multiplier 1.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add project paths
|
||||||
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
sys.path.insert(0, os.path.join(project_root, 'experiments'))
|
||||||
|
sys.path.insert(0, os.path.join(project_root, 'lib'))
|
||||||
|
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers.simple import SimpleSurgePricer
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class E2ETestProvider(BackendAPIProvider):
|
||||||
|
"""Provider configured for E2E test environment"""
|
||||||
|
|
||||||
|
def __init__(self, backend_url: str = None):
|
||||||
|
self.backend_url = backend_url or os.getenv('BACKEND_URL', 'http://localhost:5000')
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
|
def run_pricing_pipeline(
|
||||||
|
store_mode: str = 'hotel',
|
||||||
|
high_threshold: int = 3,
|
||||||
|
low_threshold: int = 1,
|
||||||
|
surge_multiplier: float = 1.5,
|
||||||
|
discount_multiplier: float = 0.9,
|
||||||
|
dry_run: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Execute the surge pricing pipeline and publish results to Redis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store_mode: 'hotel' or 'airline'
|
||||||
|
high_threshold: Demand threshold for surge pricing
|
||||||
|
low_threshold: Demand threshold for discount pricing
|
||||||
|
surge_multiplier: Price multiplier for high demand
|
||||||
|
discount_multiplier: Price multiplier for low demand
|
||||||
|
dry_run: If True, don't publish to Redis
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with pipeline results and statistics
|
||||||
|
"""
|
||||||
|
log.info(f"Starting E2E pricing pipeline: mode={store_mode}, "
|
||||||
|
f"high_threshold={high_threshold}, surge_multiplier={surge_multiplier}")
|
||||||
|
|
||||||
|
# Initialize provider and context
|
||||||
|
provider = E2ETestProvider()
|
||||||
|
context = PipelineContext(provider=provider, store_mode=store_mode)
|
||||||
|
|
||||||
|
# Step 1: Fetch interactions from Kafka
|
||||||
|
log.info("Fetching interactions from Kafka...")
|
||||||
|
fetch_interactions = FetchInteractionsStep(context)
|
||||||
|
interactions_df = fetch_interactions.transform(None)
|
||||||
|
log.info(f"Fetched {len(interactions_df)} interaction records")
|
||||||
|
|
||||||
|
if interactions_df.empty:
|
||||||
|
log.warning("No interactions found. Pipeline will produce no price updates.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'interactions_count': 0,
|
||||||
|
'products_count': 0,
|
||||||
|
'prices_published': False,
|
||||||
|
'message': 'No interactions to process'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 2: Fetch price logs from Kafka
|
||||||
|
log.info("Fetching price logs from Kafka...")
|
||||||
|
fetch_prices = FetchPriceLogsStep(context)
|
||||||
|
price_logs_df = fetch_prices.transform(None)
|
||||||
|
log.info(f"Fetched {len(price_logs_df)} price log records")
|
||||||
|
|
||||||
|
# Step 3: Compute demand scores
|
||||||
|
log.info("Computing demand scores...")
|
||||||
|
compute_demand = ComputeDemandStep(context)
|
||||||
|
demand_df = compute_demand.transform(interactions_df)
|
||||||
|
log.info(f"Computed demand for {len(demand_df)} products")
|
||||||
|
|
||||||
|
if demand_df.empty:
|
||||||
|
log.warning("No demand data computed.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'interactions_count': len(interactions_df),
|
||||||
|
'products_count': 0,
|
||||||
|
'prices_published': False,
|
||||||
|
'message': 'No demand data to process'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Aggregate price logs
|
||||||
|
log.info("Aggregating price logs...")
|
||||||
|
aggregate_prices = AggregatePriceLogsStep(context)
|
||||||
|
price_agg_df = aggregate_prices.transform(price_logs_df)
|
||||||
|
log.info(f"Aggregated prices for {len(price_agg_df)} products")
|
||||||
|
|
||||||
|
# Step 5: Join product features
|
||||||
|
log.info("Joining product features...")
|
||||||
|
join_features = JoinProductFeaturesStep(context)
|
||||||
|
features_df = join_features.transform((demand_df, price_agg_df))
|
||||||
|
log.info(f"Joined features for {len(features_df)} products")
|
||||||
|
|
||||||
|
if features_df.empty:
|
||||||
|
log.warning("No product features after join.")
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'interactions_count': len(interactions_df),
|
||||||
|
'products_count': 0,
|
||||||
|
'prices_published': False,
|
||||||
|
'message': 'No product features to price'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 6: Apply surge pricing
|
||||||
|
log.info(f"Applying surge pricing (high={high_threshold}, surge={surge_multiplier}x)...")
|
||||||
|
|
||||||
|
# Rename columns for pricer compatibility
|
||||||
|
data = features_df.rename(columns={'demand_score': 'demand'})
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer(
|
||||||
|
high_threshold=high_threshold,
|
||||||
|
low_threshold=low_threshold,
|
||||||
|
surge_multiplier=surge_multiplier,
|
||||||
|
discount_multiplier=discount_multiplier
|
||||||
|
)
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
|
||||||
|
# Prepare output DataFrame
|
||||||
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
|
'price': 'current_price',
|
||||||
|
'demand': 'demand_score'
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info(f"Generated optimal prices for {len(prices_df)} products")
|
||||||
|
|
||||||
|
# Log pricing decisions
|
||||||
|
for _, row in prices_df.iterrows():
|
||||||
|
markup = row['optimal_price'] / row['base_price'] if row['base_price'] > 0 else 1.0
|
||||||
|
log.info(f" {row['productId'][:8]}...: base=${row['base_price']:.2f} "
|
||||||
|
f"-> optimal=${row['optimal_price']:.2f} (demand={row['demand_score']:.0f}, markup={markup:.2f}x)")
|
||||||
|
|
||||||
|
# Step 7: Publish to Redis
|
||||||
|
if not dry_run:
|
||||||
|
log.info("Publishing prices to Redis registry...")
|
||||||
|
registry = ModelRegistry()
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'store_mode': store_mode,
|
||||||
|
'pipeline': 'e2e_test_worker',
|
||||||
|
'high_threshold': high_threshold,
|
||||||
|
'low_threshold': low_threshold,
|
||||||
|
'surge_multiplier': surge_multiplier,
|
||||||
|
'discount_multiplier': discount_multiplier,
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||||
|
log.info(f"✅ Published {len(prices_df)} prices to Redis")
|
||||||
|
else:
|
||||||
|
log.info("Dry run - skipping Redis publish")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'interactions_count': len(interactions_df),
|
||||||
|
'products_count': len(prices_df),
|
||||||
|
'prices_published': not dry_run,
|
||||||
|
'prices': prices_df.to_dict(orient='records'),
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='E2E Test Pipeline Worker')
|
||||||
|
parser.add_argument('--store-mode', choices=['hotel', 'airline'], default='hotel',
|
||||||
|
help='Store mode (hotel or airline)')
|
||||||
|
parser.add_argument('--high-threshold', type=int, default=3,
|
||||||
|
help='Demand threshold for surge pricing')
|
||||||
|
parser.add_argument('--low-threshold', type=int, default=1,
|
||||||
|
help='Demand threshold for discount pricing')
|
||||||
|
parser.add_argument('--surge-multiplier', type=float, default=1.5,
|
||||||
|
help='Price multiplier for high demand')
|
||||||
|
parser.add_argument('--discount-multiplier', type=float, default=0.9,
|
||||||
|
help='Price multiplier for low demand')
|
||||||
|
parser.add_argument('--dry-run', action='store_true',
|
||||||
|
help='Run without publishing to Redis')
|
||||||
|
parser.add_argument('--json-output', action='store_true',
|
||||||
|
help='Output results as JSON')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_pricing_pipeline(
|
||||||
|
store_mode=args.store_mode,
|
||||||
|
high_threshold=args.high_threshold,
|
||||||
|
low_threshold=args.low_threshold,
|
||||||
|
surge_multiplier=args.surge_multiplier,
|
||||||
|
discount_multiplier=args.discount_multiplier,
|
||||||
|
dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.json_output:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
log.info(f"Pipeline completed: {result['products_count']} products priced")
|
||||||
|
|
||||||
|
sys.exit(0 if result['success'] else 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Pipeline failed: {e}")
|
||||||
|
if args.json_output:
|
||||||
|
print(json.dumps({'success': False, 'error': str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
27
e2e/package.json
Normal file
27
e2e/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "phantom-e2e-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "E2E tests for PHANTOM Dynamic Pricing Pipeline",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:ui": "playwright test --ui",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"test:debug": "playwright test --debug",
|
||||||
|
"test:report": "playwright show-report",
|
||||||
|
"test:pricing": "playwright test dynamic-pricing",
|
||||||
|
"test:health": "playwright test --grep 'health'",
|
||||||
|
"pipeline:run": "python3 lib/pipeline-worker.py --store-mode hotel --high-threshold 3 --surge-multiplier 1.5",
|
||||||
|
"pipeline:dry-run": "python3 lib/pipeline-worker.py --dry-run --json-output",
|
||||||
|
"services:check": "curl -s http://localhost:5000/health && curl -s http://localhost:5001/health"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"@types/uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
e2e/playwright.config.ts
Normal file
84
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for PHANTOM Dynamic Pricing E2E Tests
|
||||||
|
*
|
||||||
|
* Tests validate the entire pricing pipeline:
|
||||||
|
* Frontend Events → Kafka → Pipeline Processing → Redis → Pricing API
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false, // Run tests sequentially to avoid race conditions in shared state
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1, // Single worker for E2E tests to ensure isolation
|
||||||
|
reporter: [
|
||||||
|
['html', { outputFolder: 'playwright-report' }],
|
||||||
|
['list']
|
||||||
|
],
|
||||||
|
|
||||||
|
// Global timeout for each test
|
||||||
|
timeout: 120_000, // 2 minutes per test (includes pipeline processing time)
|
||||||
|
|
||||||
|
// Expect timeout for assertions
|
||||||
|
expect: {
|
||||||
|
timeout: 30_000, // 30 seconds for price updates to propagate
|
||||||
|
},
|
||||||
|
|
||||||
|
use: {
|
||||||
|
// Base URL for the backend API
|
||||||
|
baseURL: process.env.BACKEND_URL || 'http://localhost:5000',
|
||||||
|
|
||||||
|
// Collect trace on first retry
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
// Screenshot on failure
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global setup and teardown
|
||||||
|
globalSetup: require.resolve('./global-setup'),
|
||||||
|
globalTeardown: require.resolve('./global-teardown'),
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'dynamic-pricing',
|
||||||
|
testMatch: /.*\.spec\.ts/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Environment configuration
|
||||||
|
// These can be overridden via environment variables
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export test configuration constants
|
||||||
|
export const testConfig = {
|
||||||
|
// API endpoints
|
||||||
|
backendUrl: process.env.BACKEND_URL || 'http://localhost:5000',
|
||||||
|
providerUrl: process.env.PROVIDER_URL || 'http://localhost:5001',
|
||||||
|
|
||||||
|
// Redis configuration
|
||||||
|
redisHost: process.env.REDIS_HOST || 'localhost',
|
||||||
|
redisPort: parseInt(process.env.REDIS_PORT || '6378'),
|
||||||
|
|
||||||
|
// Kafka configuration
|
||||||
|
kafkaHost: process.env.KAFKA_HOST || 'localhost',
|
||||||
|
kafkaPort: parseInt(process.env.KAFKA_PORT || '9092'),
|
||||||
|
|
||||||
|
// Pricing thresholds for tests (aggressive settings for fast feedback)
|
||||||
|
pricing: {
|
||||||
|
highThreshold: 3, // Trigger surge after 3 interactions
|
||||||
|
lowThreshold: 1, // Trigger discount at 1 or fewer interactions
|
||||||
|
surgeMultiplier: 1.5, // 50% price increase on surge
|
||||||
|
discountMultiplier: 0.9, // 10% discount on low demand
|
||||||
|
windowSize: 10_000, // 10 second window for demand calculation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timing configuration
|
||||||
|
timing: {
|
||||||
|
eventDelay: 100, // Delay between events (ms)
|
||||||
|
pipelineWait: 5_000, // Wait for pipeline processing (ms)
|
||||||
|
priceCheckInterval: 1_000, // Interval between price checks (ms)
|
||||||
|
maxPriceWait: 30_000, // Max wait for price update (ms)
|
||||||
|
},
|
||||||
|
};
|
||||||
497
e2e/tests/dynamic-pricing.spec.ts
Normal file
497
e2e/tests/dynamic-pricing.spec.ts
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
/**
|
||||||
|
* PHANTOM Dynamic Pricing E2E Test Suite
|
||||||
|
*
|
||||||
|
* Validates that SimpleSurgePricer and SessionAwarePricer correctly adjust
|
||||||
|
* product prices in real-time based on high-velocity user interactions.
|
||||||
|
*
|
||||||
|
* System Under Test (SUT):
|
||||||
|
* - Frontend (interaction generation via API calls)
|
||||||
|
* - Backend API (POST /api/ingest → Kafka)
|
||||||
|
* - Kafka (user-interactions topic)
|
||||||
|
* - Pipeline Worker (demand calculation → surge pricing)
|
||||||
|
* - Redis (model registry)
|
||||||
|
* - Pricing Provider (GET /api/{mode}/price/{productId})
|
||||||
|
*
|
||||||
|
* Test Configuration:
|
||||||
|
* - high_threshold: 3 (trigger surge after 3 demand signals)
|
||||||
|
* - surge_multiplier: 1.5x (50% price increase)
|
||||||
|
* - low_threshold: 1 (trigger discount at 1 or fewer)
|
||||||
|
* - discount_multiplier: 0.9x (10% discount)
|
||||||
|
* - window_size: 10s (fast feedback loop)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, PricingAssertions } from '../lib/fixtures';
|
||||||
|
import { EventNames, generateTestProductId } from '../lib/event-generator';
|
||||||
|
|
||||||
|
test.describe('Dynamic Pricing Pipeline', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 1: Baseline Pricing
|
||||||
|
*
|
||||||
|
* Precondition: Clean state with no recent interactions for the product
|
||||||
|
* Expected: Price should equal base_price (markup = 1.0)
|
||||||
|
*/
|
||||||
|
test('should return base price when no interactions exist', async ({ api, config }) => {
|
||||||
|
// Use a unique product ID to ensure no prior interactions
|
||||||
|
const productId = generateTestProductId('baseline');
|
||||||
|
|
||||||
|
// Get price from provider - should be base price (fallback)
|
||||||
|
// Note: This tests the fallback behavior when product isn't in Redis
|
||||||
|
const priceResponse = await api.getPrice('hotel', productId).catch(() => null);
|
||||||
|
|
||||||
|
// For unknown products, the API returns 404 or falls back to base
|
||||||
|
// This validates the fallback mechanism works
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Tested baseline pricing for product: ${productId}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 2: Surge Pricing Trigger
|
||||||
|
*
|
||||||
|
* Precondition: Fresh product with no interactions
|
||||||
|
* Action: Generate 5+ high-velocity interactions (above high_threshold=3)
|
||||||
|
* Expected: Price increases by surge_multiplier (1.5x)
|
||||||
|
*/
|
||||||
|
test('should apply surge pricing when demand exceeds threshold', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
// Step 1: Create a fresh session
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
const productId = generateTestProductId('surge');
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing surge pricing for product: ${productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Log initial price for this product (establish baseline)
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 100.0, // Base price
|
||||||
|
sessionId,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Generate high-velocity interactions (5 events > threshold of 3)
|
||||||
|
console.log(`\n📊 Generating ${5} surge events for product ${productId.slice(0, 8)}...`);
|
||||||
|
|
||||||
|
const surgeEvents = events.generateSurgeSequence(productId, 5);
|
||||||
|
|
||||||
|
for (const event of surgeEvents) {
|
||||||
|
await api.ingestEvent(event);
|
||||||
|
await new Promise(r => setTimeout(r, config.timing.eventDelay));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Ingested ${surgeEvents.length} events`);
|
||||||
|
|
||||||
|
// Step 4: Trigger the pricing pipeline
|
||||||
|
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||||
|
const pipelineResult = await triggerPriceUpdate({
|
||||||
|
storeMode: 'hotel',
|
||||||
|
highThreshold: config.pricing.highThreshold,
|
||||||
|
surgeMultiplier: config.pricing.surgeMultiplier,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📈 Pipeline processed ${pipelineResult.products_count} products`);
|
||||||
|
|
||||||
|
// Step 5: Verify surge pricing was applied
|
||||||
|
if (pipelineResult.prices && pipelineResult.prices.length > 0) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||||
|
|
||||||
|
console.log(`\n💰 Price Result for ${productId.slice(0, 8)}:`);
|
||||||
|
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||||
|
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||||
|
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||||
|
console.log(` Markup: ${markup.toFixed(2)}x`);
|
||||||
|
|
||||||
|
// Verify surge was applied
|
||||||
|
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(config.pricing.highThreshold);
|
||||||
|
expect(markup).toBeCloseTo(config.pricing.surgeMultiplier, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotations for test report
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'result',
|
||||||
|
description: `Pipeline processed ${pipelineResult.products_count} products`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 3: Discount Pricing Trigger
|
||||||
|
*
|
||||||
|
* Precondition: Product with very low interaction count
|
||||||
|
* Action: Generate only 1 interaction (at or below low_threshold=1)
|
||||||
|
* Expected: Price decreases by discount_multiplier (0.9x)
|
||||||
|
*/
|
||||||
|
test('should apply discount pricing when demand is below threshold', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
const productId = generateTestProductId('discount');
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing discount pricing for product: ${productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Log initial price
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 100.0,
|
||||||
|
sessionId,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Generate minimal interaction (1 event = low_threshold)
|
||||||
|
console.log(`\n📊 Generating 1 low-demand event for product ${productId.slice(0, 8)}...`);
|
||||||
|
|
||||||
|
const event = events.viewProduct(productId);
|
||||||
|
await api.ingestEvent(event);
|
||||||
|
|
||||||
|
console.log('✅ Ingested 1 event');
|
||||||
|
|
||||||
|
// Step 3: Trigger pipeline
|
||||||
|
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||||
|
const pipelineResult = await triggerPriceUpdate({
|
||||||
|
storeMode: 'hotel',
|
||||||
|
lowThreshold: config.pricing.lowThreshold,
|
||||||
|
discountMultiplier: config.pricing.discountMultiplier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Verify discount pricing
|
||||||
|
if (pipelineResult.prices && pipelineResult.prices.length > 0) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||||
|
|
||||||
|
console.log(`\n💰 Price Result for ${productId.slice(0, 8)}:`);
|
||||||
|
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||||
|
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||||
|
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||||
|
console.log(` Markup: ${markup.toFixed(2)}x`);
|
||||||
|
|
||||||
|
// Verify discount was applied
|
||||||
|
expect(pricedProduct.demand_score).toBeLessThanOrEqual(config.pricing.lowThreshold);
|
||||||
|
expect(markup).toBeCloseTo(config.pricing.discountMultiplier, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 4: Multi-Product Differential Pricing
|
||||||
|
*
|
||||||
|
* Precondition: Multiple products with different interaction levels
|
||||||
|
* Action:
|
||||||
|
* - Product A: 5 interactions (surge)
|
||||||
|
* - Product B: 1 interaction (discount)
|
||||||
|
* - Product C: 2 interactions (neutral)
|
||||||
|
* Expected: Each product priced according to its demand
|
||||||
|
*/
|
||||||
|
test('should price multiple products differentially based on demand', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
|
||||||
|
// Create 3 test products with different demand patterns
|
||||||
|
const products = {
|
||||||
|
surge: { id: generateTestProductId('multi-surge'), eventCount: 5, expectedMarkup: config.pricing.surgeMultiplier },
|
||||||
|
discount: { id: generateTestProductId('multi-discount'), eventCount: 1, expectedMarkup: config.pricing.discountMultiplier },
|
||||||
|
neutral: { id: generateTestProductId('multi-neutral'), eventCount: 2, expectedMarkup: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing multi-product pricing: surge=${products.surge.id.slice(0, 8)}, discount=${products.discount.id.slice(0, 8)}, neutral=${products.neutral.id.slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Log base prices for all products
|
||||||
|
for (const [name, product] of Object.entries(products)) {
|
||||||
|
await api.logPrice({
|
||||||
|
productId: product.id,
|
||||||
|
price: 100.0,
|
||||||
|
sessionId,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Generate different interaction levels for each product
|
||||||
|
console.log('\n📊 Generating differentiated events:');
|
||||||
|
|
||||||
|
for (const [name, product] of Object.entries(products)) {
|
||||||
|
console.log(` ${name}: ${product.eventCount} events`);
|
||||||
|
|
||||||
|
for (let i = 0; i < product.eventCount; i++) {
|
||||||
|
const event = events.viewProduct(product.id);
|
||||||
|
await api.ingestEvent(event);
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ All events ingested');
|
||||||
|
|
||||||
|
// Step 3: Trigger pipeline
|
||||||
|
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||||
|
const pipelineResult = await triggerPriceUpdate();
|
||||||
|
|
||||||
|
// Step 4: Verify differential pricing
|
||||||
|
console.log('\n💰 Multi-Product Pricing Results:');
|
||||||
|
|
||||||
|
if (pipelineResult.prices) {
|
||||||
|
for (const [name, product] of Object.entries(products)) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === product.id);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
const markup = pricedProduct.optimal_price / pricedProduct.base_price;
|
||||||
|
|
||||||
|
console.log(` ${name} (${product.id.slice(0, 8)}):`);
|
||||||
|
console.log(` Demand: ${pricedProduct.demand_score}, Markup: ${markup.toFixed(2)}x (expected: ${product.expectedMarkup}x)`);
|
||||||
|
|
||||||
|
// Verify markup is in expected range (with tolerance)
|
||||||
|
expect(markup).toBeCloseTo(product.expectedMarkup, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 5: Price Update Propagation
|
||||||
|
*
|
||||||
|
* Validates that price updates flow correctly from the pipeline
|
||||||
|
* through Redis to the Pricing Provider API.
|
||||||
|
*/
|
||||||
|
test('should propagate prices from pipeline to pricing API', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
const productId = generateTestProductId('propagation');
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing price propagation for product: ${productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Log base price
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 150.0, // Different base price for this test
|
||||||
|
sessionId,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Generate surge-level interactions
|
||||||
|
console.log(`\n📊 Generating surge events for propagation test...`);
|
||||||
|
|
||||||
|
const surgeEvents = events.generateSurgeSequence(productId, 6);
|
||||||
|
await api.ingestEvents(surgeEvents, config.timing.eventDelay);
|
||||||
|
|
||||||
|
console.log(`✅ Ingested ${surgeEvents.length} events`);
|
||||||
|
|
||||||
|
// Step 3: Trigger pipeline
|
||||||
|
console.log('\n⚙️ Triggering pricing pipeline...');
|
||||||
|
const pipelineResult = await triggerPriceUpdate();
|
||||||
|
|
||||||
|
expect(pipelineResult.success).toBe(true);
|
||||||
|
expect(pipelineResult.prices_published).toBe(true);
|
||||||
|
|
||||||
|
console.log(`📈 Pipeline published ${pipelineResult.products_count} prices to Redis`);
|
||||||
|
|
||||||
|
// Step 4: Wait for Redis propagation
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
// Step 5: Verify via Pricing Provider API
|
||||||
|
// Note: This requires the product to exist in Supabase
|
||||||
|
// For pure E2E testing, we verify the pipeline output instead
|
||||||
|
if (pipelineResult.prices) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
console.log(`\n✅ Price Propagation Verified:`);
|
||||||
|
console.log(` Product: ${productId.slice(0, 8)}`);
|
||||||
|
console.log(` Base Price: $${pricedProduct.base_price.toFixed(2)}`);
|
||||||
|
console.log(` Optimal Price: $${pricedProduct.optimal_price.toFixed(2)}`);
|
||||||
|
console.log(` Published to Redis: ${pipelineResult.prices_published}`);
|
||||||
|
|
||||||
|
expect(pricedProduct.optimal_price).toBeGreaterThan(pricedProduct.base_price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 6: Event Type Weighting
|
||||||
|
*
|
||||||
|
* Validates that different event types contribute to demand calculation.
|
||||||
|
* High-intent events (add_to_cart) should have more weight than low-intent (page_view).
|
||||||
|
*/
|
||||||
|
test('should count various event types in demand calculation', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
const productId = generateTestProductId('event-types');
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing event type weighting for product: ${productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log base price
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 100.0,
|
||||||
|
sessionId,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate a mix of different event types
|
||||||
|
console.log('\n📊 Generating mixed event types:');
|
||||||
|
|
||||||
|
const mixedEvents = [
|
||||||
|
events.viewProduct(productId), // page view
|
||||||
|
events.learnMore(productId), // high intent
|
||||||
|
events.hover(productId, 'title'), // engagement
|
||||||
|
events.hover(productId, 'paragraph'), // engagement
|
||||||
|
events.addToCart(productId), // highest intent
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(` - ${mixedEvents.length} mixed events (view, learn_more, hover, add_to_cart)`);
|
||||||
|
|
||||||
|
await api.ingestEvents(mixedEvents, config.timing.eventDelay);
|
||||||
|
console.log('✅ Events ingested');
|
||||||
|
|
||||||
|
// Trigger pipeline
|
||||||
|
const pipelineResult = await triggerPriceUpdate();
|
||||||
|
|
||||||
|
// Verify events were counted
|
||||||
|
if (pipelineResult.prices) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
console.log(`\n💰 Mixed Event Pricing Result:`);
|
||||||
|
console.log(` Demand Score: ${pricedProduct.demand_score}`);
|
||||||
|
console.log(` Expected: >= ${config.pricing.highThreshold} (for surge)`);
|
||||||
|
|
||||||
|
// Mixed events should trigger surge if count >= high_threshold
|
||||||
|
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(config.pricing.highThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 7: Session Isolation
|
||||||
|
*
|
||||||
|
* Validates that events from different sessions are correctly aggregated
|
||||||
|
* for the same product.
|
||||||
|
*/
|
||||||
|
test('should aggregate demand across multiple sessions', async ({
|
||||||
|
api,
|
||||||
|
events,
|
||||||
|
triggerPriceUpdate,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
const productId = generateTestProductId('multi-session');
|
||||||
|
|
||||||
|
test.info().annotations.push({
|
||||||
|
type: 'info',
|
||||||
|
description: `Testing multi-session aggregation for product: ${productId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log base price
|
||||||
|
await api.logPrice({
|
||||||
|
productId,
|
||||||
|
price: 100.0,
|
||||||
|
sessionId: events.session,
|
||||||
|
storeMode: 'hotel',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate events from 3 different sessions
|
||||||
|
console.log('\n📊 Generating events from multiple sessions:');
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const sessionId = events.newSession();
|
||||||
|
console.log(` Session ${i + 1}: ${sessionId.slice(0, 8)}...`);
|
||||||
|
|
||||||
|
// Each session generates 2 events
|
||||||
|
await api.ingestEvent(events.viewProduct(productId));
|
||||||
|
await api.ingestEvent(events.learnMore(productId));
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, config.timing.eventDelay));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Events from 3 sessions ingested');
|
||||||
|
|
||||||
|
// Trigger pipeline
|
||||||
|
const pipelineResult = await triggerPriceUpdate();
|
||||||
|
|
||||||
|
// Verify aggregated demand
|
||||||
|
if (pipelineResult.prices) {
|
||||||
|
const pricedProduct = pipelineResult.prices.find(p => p.productId === productId);
|
||||||
|
|
||||||
|
if (pricedProduct) {
|
||||||
|
console.log(`\n💰 Multi-Session Aggregation Result:`);
|
||||||
|
console.log(` Total Demand Score: ${pricedProduct.demand_score}`);
|
||||||
|
console.log(` Expected: >= 6 (2 events × 3 sessions)`);
|
||||||
|
|
||||||
|
// 3 sessions × 2 events = 6 total events
|
||||||
|
expect(pricedProduct.demand_score).toBeGreaterThanOrEqual(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge Cases and Error Handling
|
||||||
|
*/
|
||||||
|
test.describe('Dynamic Pricing Edge Cases', () => {
|
||||||
|
test('should handle pipeline execution with empty Kafka topics', async ({
|
||||||
|
triggerPriceUpdate,
|
||||||
|
}) => {
|
||||||
|
// This tests the pipeline's resilience when there's no data
|
||||||
|
// The pipeline should complete without errors
|
||||||
|
|
||||||
|
console.log('\n⚙️ Testing pipeline with potentially empty data...');
|
||||||
|
|
||||||
|
// Run pipeline - should handle empty state gracefully
|
||||||
|
const result = await triggerPriceUpdate({ dryRun: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
console.log(`✅ Pipeline handled gracefully: ${result.message || 'completed'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should verify backend health before running tests', async ({ api }) => {
|
||||||
|
const backendHealth = await api.checkBackendHealth();
|
||||||
|
expect(backendHealth.status).toBe('healthy');
|
||||||
|
|
||||||
|
console.log(`✅ Backend: ${backendHealth.status}`);
|
||||||
|
console.log(` Kafka: ${backendHealth.kafka}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should verify pricing provider health', async ({ api }) => {
|
||||||
|
const providerHealth = await api.checkProviderHealth();
|
||||||
|
expect(providerHealth.status).toBe('healthy');
|
||||||
|
|
||||||
|
console.log(`✅ Provider: ${providerHealth.status}`);
|
||||||
|
console.log(` Redis: ${providerHealth.redis ? 'connected' : 'disconnected'}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
e2e/tsconfig.json
Normal file
28
e2e/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@lib/*": ["lib/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
# Products
|
||||||
|
# Agents
|
||||||
|
# Pipeline
|
||||||
|
|
||||||
|
Our pipeline technically should follow principles in a style like this:
|
||||||
|
- Each step should be defined as an inheriting child of an scikit pipeline step, the granularity of the steps is dictated by the following: a step should be a transformation, augmentation or computation independently, no single stage should run multiple in-itself. This way we can modularize properly all the components and track properly in airflow. A stage can be defined as an sklearn step but then must be transalted to a function that takes the context in our DAG of airflow. All parametrization must be done via contexts.
|
||||||
|
|
||||||
|
|||||||
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from airflow import DAG, Dataset
|
||||||
|
from airflow.decorators import task
|
||||||
|
from airflow.utils.dates import days_ago
|
||||||
|
from datetime import timedelta
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
ValidateDataStep,
|
||||||
|
ExtractSessionFeaturesStep,
|
||||||
|
JoinLabelsStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRAINING_DATASET = Dataset('phantom://ml/training-data')
|
||||||
|
|
||||||
|
DEFAULT_ARGS = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def __init__(self):
|
||||||
|
SupabaseProvider.__init__(self)
|
||||||
|
BackendAPIProvider.__init__(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
|
||||||
|
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
|
||||||
|
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
'ml_training_pipeline',
|
||||||
|
default_args=DEFAULT_ARGS,
|
||||||
|
description='ML training data pipeline: fetch -> validate -> extract features -> label -> publish',
|
||||||
|
schedule=None,
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['ml', 'training', 'features', 'research'],
|
||||||
|
) as dag:
|
||||||
|
|
||||||
|
@task
|
||||||
|
def fetch_interactions(**kwargs) -> bytes:
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
df = FetchInteractionsStep(ctx).transform(None)
|
||||||
|
logging.info(f"Fetched {len(df)} interactions, {df['sessionId'].nunique()} sessions")
|
||||||
|
return pickle.dumps(df)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def validate_data(raw_data: bytes, **kwargs) -> bytes:
|
||||||
|
df = pickle.loads(raw_data)
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
validated = ValidateDataStep(ctx).transform(df)
|
||||||
|
report = ctx.get_cached('validation_report') or {}
|
||||||
|
logging.info(f"Validation: {report.get('status')}, {report.get('sessions', 0)} sessions")
|
||||||
|
return pickle.dumps(validated)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def extract_session_features(validated_data: bytes, **kwargs) -> bytes:
|
||||||
|
df = pickle.loads(validated_data)
|
||||||
|
if df.empty:
|
||||||
|
logging.warning("Empty input, skipping feature extraction")
|
||||||
|
return pickle.dumps(pd.DataFrame())
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
features = ExtractSessionFeaturesStep(ctx).transform(df)
|
||||||
|
logging.info(f"Extracted {len(features.columns)} features for {len(features)} sessions")
|
||||||
|
return pickle.dumps(features)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def join_labels(features_data: bytes, **kwargs) -> bytes:
|
||||||
|
features_df = pickle.loads(features_data)
|
||||||
|
if features_df.empty:
|
||||||
|
logging.warning("Empty features, skipping label join")
|
||||||
|
return pickle.dumps(pd.DataFrame())
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
labeled = JoinLabelsStep(ctx).transform(features_df)
|
||||||
|
n_agents = labeled['is_agent'].sum() if 'is_agent' in labeled.columns else 0
|
||||||
|
logging.info(f"Labeled {len(labeled)} sessions: {n_agents} agents")
|
||||||
|
return pickle.dumps(labeled)
|
||||||
|
|
||||||
|
@task(outlets=[TRAINING_DATASET])
|
||||||
|
def publish_training_data(labeled_data: bytes, **kwargs) -> dict:
|
||||||
|
labeled_df = pickle.loads(labeled_data)
|
||||||
|
if labeled_df.empty:
|
||||||
|
return {'status': 'skipped', 'reason': 'empty_data'}
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'n_sessions': len(labeled_df),
|
||||||
|
'n_features': len([c for c in labeled_df.columns if c not in ['sessionId', 'experimentId', 'is_agent']]),
|
||||||
|
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = fetch_interactions()
|
||||||
|
validated = validate_data(raw)
|
||||||
|
features = extract_session_features(validated)
|
||||||
|
labeled = join_labels(features)
|
||||||
|
publish_training_data(labeled)
|
||||||
210
experiments/airflow/dags/surge_pricing_factory.py
Normal file
210
experiments/airflow/dags/surge_pricing_factory.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from airflow import DAG
|
||||||
|
from airflow.operators.python import PythonOperator
|
||||||
|
from airflow.utils.dates import days_ago
|
||||||
|
from datetime import timedelta
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers.simple import SimpleSurgePricer
|
||||||
|
|
||||||
|
DEFAULT_ARGS = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def __init__(self):
|
||||||
|
SupabaseProvider.__init__(self)
|
||||||
|
BackendAPIProvider.__init__(self)
|
||||||
|
|
||||||
|
def _get_provider():
|
||||||
|
return CompositeProvider()
|
||||||
|
|
||||||
|
def _make_task_callables(store_mode: str):
|
||||||
|
"""Generate task callables bound to a specific store_mode."""
|
||||||
|
|
||||||
|
def get_context(**kwargs):
|
||||||
|
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
|
||||||
|
|
||||||
|
def fetch_interactions(**kwargs):
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
df = FetchInteractionsStep(ctx).transform(None)
|
||||||
|
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def fetch_price_logs(**kwargs):
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
df = FetchPriceLogsStep(ctx).transform(None)
|
||||||
|
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def compute_demand(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
demand_df = ComputeDemandStep(ctx).transform(df)
|
||||||
|
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||||
|
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
|
||||||
|
return len(demand_df)
|
||||||
|
|
||||||
|
def aggregate_price_logs(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
price_df = AggregatePriceLogsStep(ctx).transform(df)
|
||||||
|
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||||
|
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
|
||||||
|
return len(price_df)
|
||||||
|
|
||||||
|
def join_product_features(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||||
|
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
|
||||||
|
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||||
|
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
|
||||||
|
return len(joined_df)
|
||||||
|
|
||||||
|
def apply_surge_pricing(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||||
|
surge_pricer = SimpleSurgePricer(
|
||||||
|
high_threshold=dag_conf.get('high_threshold', 10),
|
||||||
|
low_threshold=dag_conf.get('low_threshold', 2),
|
||||||
|
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
)
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
|
||||||
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
|
'price': 'current_price', 'demand': 'demand_score'
|
||||||
|
})
|
||||||
|
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||||
|
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
|
||||||
|
return len(prices_df)
|
||||||
|
|
||||||
|
def publish_results(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
registry = ModelRegistry()
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
'store_mode': store_mode,
|
||||||
|
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||||
|
'pricing_method': 'surge',
|
||||||
|
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||||
|
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||||
|
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
}
|
||||||
|
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
|
||||||
|
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'n_products': len(prices_df),
|
||||||
|
'registry_status': 'success',
|
||||||
|
'store_mode': store_mode,
|
||||||
|
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'fetch_interactions': fetch_interactions,
|
||||||
|
'fetch_price_logs': fetch_price_logs,
|
||||||
|
'compute_demand': compute_demand,
|
||||||
|
'aggregate_price_logs': aggregate_price_logs,
|
||||||
|
'join_product_features': join_product_features,
|
||||||
|
'apply_surge_pricing': apply_surge_pricing,
|
||||||
|
'publish_results': publish_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||||
|
"""Factory: generates a surge pricing DAG for a given store_mode."""
|
||||||
|
callables = _make_task_callables(store_mode)
|
||||||
|
|
||||||
|
dag = DAG(
|
||||||
|
f'surge_pricing_{store_mode}',
|
||||||
|
default_args=DEFAULT_ARGS,
|
||||||
|
description=f'Surge pricing pipeline for {store_mode} store mode',
|
||||||
|
schedule_interval='*/15 * * * *',
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['pricing', 'surge', 'research', store_mode],
|
||||||
|
)
|
||||||
|
|
||||||
|
with dag:
|
||||||
|
t_fetch_interactions = PythonOperator(
|
||||||
|
task_id='fetch_interactions',
|
||||||
|
python_callable=callables['fetch_interactions'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_fetch_price_logs = PythonOperator(
|
||||||
|
task_id='fetch_price_logs',
|
||||||
|
python_callable=callables['fetch_price_logs'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_compute_demand = PythonOperator(
|
||||||
|
task_id='compute_demand',
|
||||||
|
python_callable=callables['compute_demand'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_aggregate_prices = PythonOperator(
|
||||||
|
task_id='aggregate_price_logs',
|
||||||
|
python_callable=callables['aggregate_price_logs'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_join_features = PythonOperator(
|
||||||
|
task_id='join_product_features',
|
||||||
|
python_callable=callables['join_product_features'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_surge_pricing = PythonOperator(
|
||||||
|
task_id='apply_surge_pricing',
|
||||||
|
python_callable=callables['apply_surge_pricing'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_publish = PythonOperator(
|
||||||
|
task_id='publish_results',
|
||||||
|
python_callable=callables['publish_results'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t_fetch_interactions >> t_compute_demand
|
||||||
|
t_fetch_price_logs >> t_aggregate_prices
|
||||||
|
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||||
|
|
||||||
|
return dag
|
||||||
|
|
||||||
|
|
||||||
|
# instantiate DAGs for Airflow to discover
|
||||||
|
dag_airline = create_surge_pricing_dag('airline')
|
||||||
|
dag_hotel = create_surge_pricing_dag('hotel')
|
||||||
237
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
237
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
from airflow import DAG
|
||||||
|
from airflow.operators.python import PythonOperator
|
||||||
|
from airflow.utils.dates import days_ago
|
||||||
|
from datetime import timedelta
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import pickle
|
||||||
|
import io
|
||||||
|
|
||||||
|
# add parent dir to path so procesing package can be imported
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers.simple import SimpleSurgePricer
|
||||||
|
|
||||||
|
default_args = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_provider():
|
||||||
|
"""Factory to create composite provider"""
|
||||||
|
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
|
||||||
|
def __init__(self):
|
||||||
|
SupabaseProvider.__init__(self)
|
||||||
|
BackendAPIProvider.__init__(self)
|
||||||
|
return CompositeProvider()
|
||||||
|
|
||||||
|
def get_context(**kwargs):
|
||||||
|
"""Build pipeline context from Airflow config"""
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
return PipelineContext(
|
||||||
|
provider=get_provider(),
|
||||||
|
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# atomic task functions (each wraps one sklearn step)
|
||||||
|
def fetch_interactions(**kwargs):
|
||||||
|
"""Task: Fetch interaction data from Kafka"""
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = FetchInteractionsStep(context)
|
||||||
|
df = step.transform(None)
|
||||||
|
|
||||||
|
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"Fetched {len(df)} interaction records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def fetch_price_logs(**kwargs):
|
||||||
|
"""Task: Fetch price logs from Kafka"""
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = FetchPriceLogsStep(context)
|
||||||
|
df = step.transform(None)
|
||||||
|
|
||||||
|
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"Fetched {len(df)} price records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def compute_demand(**kwargs):
|
||||||
|
"""Task: Compute demand scores from interactions"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = ComputeDemandStep(context)
|
||||||
|
demand_df = step.transform(df)
|
||||||
|
# TODO: clear the xcom
|
||||||
|
|
||||||
|
|
||||||
|
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||||
|
logging.info(f"Computed demand for {len(demand_df)} products")
|
||||||
|
return len(demand_df)
|
||||||
|
|
||||||
|
def aggregate_price_logs(**kwargs):
|
||||||
|
"""Task: Aggregate price logs"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = AggregatePriceLogsStep(context)
|
||||||
|
price_df = step.transform(df)
|
||||||
|
|
||||||
|
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||||
|
logging.info(f"Aggregated price logs for {len(price_df)} products")
|
||||||
|
return len(price_df)
|
||||||
|
|
||||||
|
def join_product_features(**kwargs):
|
||||||
|
"""Task: Join demand and price data"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||||
|
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = JoinProductFeaturesStep(context)
|
||||||
|
joined_df = step.transform((demand_df, price_df))
|
||||||
|
|
||||||
|
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||||
|
logging.info(f"Joined features for {len(joined_df)} products")
|
||||||
|
return len(joined_df)
|
||||||
|
|
||||||
|
def apply_surge_pricing(**kwargs):
|
||||||
|
"""Task: Apply surge pricing rules to generate optimal prices"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||||
|
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
# rename demand_score to demand for pricer compatibility
|
||||||
|
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer(
|
||||||
|
high_threshold=dag_conf.get('high_threshold', 10),
|
||||||
|
low_threshold=dag_conf.get('low_threshold', 2),
|
||||||
|
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
)
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
|
||||||
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
|
'price': 'current_price',
|
||||||
|
'demand': 'demand_score'
|
||||||
|
})
|
||||||
|
|
||||||
|
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||||
|
logging.info(f"Applied surge pricing for {len(prices_df)} products")
|
||||||
|
return len(prices_df)
|
||||||
|
|
||||||
|
def publish_results(**kwargs):
|
||||||
|
"""Task: Publish surge pricing results to registry"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||||
|
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
registry = ModelRegistry()
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||||
|
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||||
|
'pricing_method': 'surge',
|
||||||
|
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||||
|
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||||
|
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||||
|
|
||||||
|
logging.info(f"Published surge pricing for {len(prices_df)} products")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'n_products': len(prices_df),
|
||||||
|
'registry_status': 'success',
|
||||||
|
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# DAG definition
|
||||||
|
with DAG(
|
||||||
|
'surge_pricing_pipeline',
|
||||||
|
default_args=default_args,
|
||||||
|
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
|
||||||
|
schedule_interval='*/15 * * * *',
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['pricing', 'surge', 'research', 'simplified'],
|
||||||
|
) as dag:
|
||||||
|
|
||||||
|
# parallel data fetching
|
||||||
|
t_fetch_interactions = PythonOperator(
|
||||||
|
task_id='fetch_interactions',
|
||||||
|
python_callable=fetch_interactions,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t_fetch_price_logs = PythonOperator(
|
||||||
|
task_id='fetch_price_logs',
|
||||||
|
python_callable=fetch_price_logs,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# compute demand from interactions
|
||||||
|
t_compute_demand = PythonOperator(
|
||||||
|
task_id='compute_demand',
|
||||||
|
python_callable=compute_demand,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# aggregate price logs
|
||||||
|
t_aggregate_prices = PythonOperator(
|
||||||
|
task_id='aggregate_price_logs',
|
||||||
|
python_callable=aggregate_price_logs,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# join demand and prices
|
||||||
|
t_join_features = PythonOperator(
|
||||||
|
task_id='join_product_features',
|
||||||
|
python_callable=join_product_features,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# apply surge pricing
|
||||||
|
t_surge_pricing = PythonOperator(
|
||||||
|
task_id='apply_surge_pricing',
|
||||||
|
python_callable=apply_surge_pricing,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# publish to registry
|
||||||
|
t_publish = PythonOperator(
|
||||||
|
task_id='publish_results',
|
||||||
|
python_callable=publish_results,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# dependency graph: parallel fetch -> process -> join -> surge -> publish
|
||||||
|
t_fetch_interactions >> t_compute_demand
|
||||||
|
t_fetch_price_logs >> t_aggregate_prices
|
||||||
|
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||||
11
experiments/ml/__init__.py
Normal file
11
experiments/ml/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from .evals import evaluate
|
||||||
|
from .arch import (
|
||||||
|
XGBoostAgentClassifier,
|
||||||
|
LightGBMAgentClassifier
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ =[
|
||||||
|
'evaluate',
|
||||||
|
'XGBoostAgentClassifier',
|
||||||
|
'LightGBMAgentClassifier'
|
||||||
|
]
|
||||||
122
experiments/ml/arch.py
Normal file
122
experiments/ml/arch.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# sklearn compatible models for agent detection
|
||||||
|
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import xgboost as xgb
|
||||||
|
import lightgbm as lgb
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
TASK = 'classification'
|
||||||
|
LABELS = ['human', 'agent']
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAgentClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||||
|
"""Base class for tree-based agent detection classifiers with common logic"""
|
||||||
|
|
||||||
|
def __init__(self, context: Optional[PipelineContext] = None, n_estimators: int = 200,
|
||||||
|
max_depth: int = 6, learning_rate: float = 0.05,
|
||||||
|
early_stopping_rounds: int = 20):
|
||||||
|
self.context = context
|
||||||
|
self.n_estimators = n_estimators
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.learning_rate = learning_rate
|
||||||
|
self.early_stopping_rounds = early_stopping_rounds
|
||||||
|
self.model_ = None
|
||||||
|
self.feature_names_ = None
|
||||||
|
|
||||||
|
def _to_array(self, X):
|
||||||
|
"""Convert pandas structures to numpy arrays"""
|
||||||
|
return X.values if isinstance(X, (pd.DataFrame, pd.Series)) else X
|
||||||
|
|
||||||
|
def _compute_pos_weight(self, y_arr):
|
||||||
|
"""Calculate scale_pos_weight for class imbalance handling"""
|
||||||
|
n_neg, n_pos = (y_arr == 0).sum(), (y_arr == 1).sum()
|
||||||
|
return n_neg / n_pos if n_pos > 0 else 1.0
|
||||||
|
|
||||||
|
def _prepare_eval_set(self, eval_set):
|
||||||
|
"""Convert eval_set to numpy arrays if needed"""
|
||||||
|
if not eval_set:
|
||||||
|
return None
|
||||||
|
X_val, y_val = eval_set[0]
|
||||||
|
return [(self._to_array(X_val), self._to_array(y_val))]
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _build_model(self, scale_pos: float):
|
||||||
|
"""Build the underlying model instance (must be implemented by subclasses)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||||
|
"""Fit model with evaluation set (must be implemented by subclasses)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fit(self, X, y, eval_set=None):
|
||||||
|
X_arr, y_arr = self._to_array(X), self._to_array(y)
|
||||||
|
|
||||||
|
if isinstance(X, pd.DataFrame):
|
||||||
|
self.feature_names_ = X.columns.tolist()
|
||||||
|
|
||||||
|
scale_pos = self._compute_pos_weight(y_arr)
|
||||||
|
self.model_ = self._build_model(scale_pos)
|
||||||
|
|
||||||
|
eval_arr = self._prepare_eval_set(eval_set)
|
||||||
|
if eval_arr:
|
||||||
|
self._fit_with_eval(X_arr, y_arr, eval_arr)
|
||||||
|
else:
|
||||||
|
self.model_.fit(X_arr, y_arr)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X):
|
||||||
|
return self.model_.predict(self._to_array(X))
|
||||||
|
|
||||||
|
def predict_proba(self, X):
|
||||||
|
return self.model_.predict_proba(self._to_array(X))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feature_importances_(self):
|
||||||
|
return self.model_.feature_importances_ if self.model_ else None
|
||||||
|
|
||||||
|
|
||||||
|
class XGBoostAgentClassifier(BaseAgentClassifier):
|
||||||
|
"""XGBoost binary classifier for agent detection with class imbalance handling"""
|
||||||
|
|
||||||
|
def _build_model(self, scale_pos: float):
|
||||||
|
return xgb.XGBClassifier(
|
||||||
|
n_estimators=self.n_estimators,
|
||||||
|
max_depth=self.max_depth,
|
||||||
|
learning_rate=self.learning_rate,
|
||||||
|
scale_pos_weight=scale_pos,
|
||||||
|
eval_metric='auc',
|
||||||
|
early_stopping_rounds=self.early_stopping_rounds,
|
||||||
|
random_state=42,
|
||||||
|
tree_method='hist',
|
||||||
|
enable_categorical=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||||
|
self.model_.fit(X_arr, y_arr, eval_set=eval_arr, verbose=False)
|
||||||
|
|
||||||
|
|
||||||
|
class LightGBMAgentClassifier(BaseAgentClassifier):
|
||||||
|
"""LightGBM binary classifier for agent detection with class imbalance handling"""
|
||||||
|
|
||||||
|
def _build_model(self, scale_pos: float):
|
||||||
|
return lgb.LGBMClassifier(
|
||||||
|
n_estimators=self.n_estimators,
|
||||||
|
max_depth=self.max_depth,
|
||||||
|
learning_rate=self.learning_rate,
|
||||||
|
scale_pos_weight=scale_pos,
|
||||||
|
metric='auc',
|
||||||
|
random_state=42,
|
||||||
|
verbosity=-1
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||||
|
self.model_.fit(
|
||||||
|
X_arr, y_arr,
|
||||||
|
eval_set=eval_arr,
|
||||||
|
callbacks=[lgb.early_stopping(self.early_stopping_rounds, verbose=False)]
|
||||||
|
)
|
||||||
103
experiments/ml/evals.py
Normal file
103
experiments/ml/evals.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
|
||||||
|
f1_score, roc_auc_score, confusion_matrix, roc_curve)
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
from logging import getLogger
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def log_feature_importance(writer, model, feature_names, epoch):
|
||||||
|
"""Visualize and log feature importance to TensorBoard"""
|
||||||
|
if not hasattr(model, 'feature_importances_') or model.feature_importances_ is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
importance = model.feature_importances_
|
||||||
|
indices = np.argsort(importance)[::-1][:20] # top 20
|
||||||
|
top_features = [feature_names[i] for i in indices]
|
||||||
|
top_importance = importance[indices]
|
||||||
|
|
||||||
|
for i, (feat, imp) in enumerate(zip(top_features, top_importance)):
|
||||||
|
writer.add_scalar(f'FeatureImportance/{feat}', imp, epoch)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 8))
|
||||||
|
ax.barh(range(len(top_features)), top_importance, align='center')
|
||||||
|
ax.set_yticks(range(len(top_features)))
|
||||||
|
ax.set_yticklabels(top_features)
|
||||||
|
ax.invert_yaxis()
|
||||||
|
ax.set_xlabel('Importance')
|
||||||
|
ax.set_title(f'Top 20 Feature Importance (Epoch {epoch})')
|
||||||
|
ax.grid(axis='x', alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(buf, format='png', dpi=100)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
img_arr = np.array(img)
|
||||||
|
writer.add_image('FeatureImportance/Chart', img_arr, epoch, dataformats='HWC')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def evaluate(perdicted_class, predicted_proba, true_class, writer: SummaryWriter, epoch: int):
|
||||||
|
accuracy = accuracy_score(true_class, perdicted_class)
|
||||||
|
precision = precision_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
recall = recall_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
f1 = f1_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
roc_auc = roc_auc_score(true_class, predicted_proba)
|
||||||
|
|
||||||
|
writer.add_scalar('Eval/Accuracy', accuracy, epoch)
|
||||||
|
writer.add_scalar('Eval/Precision', precision, epoch)
|
||||||
|
writer.add_scalar('Eval/Recall', recall, epoch)
|
||||||
|
writer.add_scalar('Eval/F1_Score', f1, epoch)
|
||||||
|
writer.add_scalar('Eval/ROC_AUC', roc_auc, epoch)
|
||||||
|
|
||||||
|
# confusion matrix
|
||||||
|
cm = confusion_matrix(true_class, perdicted_class)
|
||||||
|
tn, fp, fn, tp = cm.ravel()
|
||||||
|
writer.add_scalar('Eval/TrueNeg', tn, epoch)
|
||||||
|
writer.add_scalar('Eval/FalsePos', fp, epoch)
|
||||||
|
writer.add_scalar('Eval/FalseNeg', fn, epoch)
|
||||||
|
writer.add_scalar('Eval/TruePos', tp, epoch)
|
||||||
|
|
||||||
|
# specificity and sensitivity
|
||||||
|
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
|
||||||
|
sensitivity = recall # same as recall/TPR
|
||||||
|
writer.add_scalar('Eval/Specificity', specificity, epoch)
|
||||||
|
writer.add_scalar('Eval/Sensitivity', sensitivity, epoch)
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
|
||||||
|
ax1.matshow(cm, cmap='Blues', alpha=0.7)
|
||||||
|
for i in range(2):
|
||||||
|
for j in range(2):
|
||||||
|
ax1.text(j, i, str(cm[i, j]), ha='center', va='center', fontsize=14)
|
||||||
|
ax1.set_xlabel('Predicted')
|
||||||
|
ax1.set_ylabel('True')
|
||||||
|
ax1.set_title(f'Confusion Matrix (Epoch {epoch})')
|
||||||
|
ax1.set_xticks([0, 1])
|
||||||
|
ax1.set_yticks([0, 1])
|
||||||
|
ax1.set_xticklabels(['Human', 'Agent'])
|
||||||
|
ax1.set_yticklabels(['Human', 'Agent'])
|
||||||
|
|
||||||
|
# ROC curve
|
||||||
|
fpr, tpr, _ = roc_curve(true_class, predicted_proba)
|
||||||
|
ax2.plot(fpr, tpr, label=f'AUC={roc_auc:.3f}', linewidth=2)
|
||||||
|
ax2.plot([0, 1], [0, 1], 'k--', label='Random')
|
||||||
|
ax2.set_xlabel('False Positive Rate')
|
||||||
|
ax2.set_ylabel('True Positive Rate')
|
||||||
|
ax2.set_title('ROC Curve')
|
||||||
|
ax2.legend()
|
||||||
|
ax2.grid(alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(buf, format='png', dpi=100)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
img_arr = np.array(img)
|
||||||
|
writer.add_image('Eval/Metrics', img_arr, epoch, dataformats='HWC')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
logger.info(f"Eval {epoch}: Acc={accuracy:.4f} Prec={precision:.4f} Rec={recall:.4f} F1={f1:.4f} AUC={roc_auc:.4f}")
|
||||||
6
experiments/ml/requirements.txt
Normal file
6
experiments/ml/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
torch
|
||||||
|
tensorboard
|
||||||
|
fastparquet
|
||||||
|
pyarrow
|
||||||
|
xgboost
|
||||||
|
lightgbm
|
||||||
137
experiments/ml/train.py
Normal file
137
experiments/ml/train.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from logging import getLogger
|
||||||
|
from pathlib import Path
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import joblib
|
||||||
|
from datetime import datetime
|
||||||
|
from ml.evals import evaluate, log_feature_importance
|
||||||
|
from ml.arch import XGBoostAgentClassifier, LightGBMAgentClassifier, LABELS
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
FEATURE_COLS_EXCLUDE = ['sessionId', 'experimentId', 'is_agent', 'xp_human_only', 'xp_market_mode', 'browser_family']
|
||||||
|
RUNS_DIR = Path('ml/runs')
|
||||||
|
CHECKPOINTS_DIR = Path('ml/checkpoints')
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_data(df):
|
||||||
|
"""
|
||||||
|
Prepare feature matrix and labels from raw dataframe
|
||||||
|
Handles missing labels, feature selection, and categorical encoding
|
||||||
|
Returns: (X, y, feature_cols)
|
||||||
|
"""
|
||||||
|
# drop rows with missing labels
|
||||||
|
n_before = len(df)
|
||||||
|
df = df[df['is_agent'].notna()].copy()
|
||||||
|
n_dropped = n_before - len(df)
|
||||||
|
if n_dropped > 0:
|
||||||
|
logger.warning(f"Dropped {n_dropped} sessions with missing labels")
|
||||||
|
|
||||||
|
if len(df) == 0:
|
||||||
|
logger.error("No labeled data available")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
feature_cols = [c for c in df.columns if c not in FEATURE_COLS_EXCLUDE]
|
||||||
|
|
||||||
|
# handle categorical browser_family via one-hot encoding
|
||||||
|
if 'browser_family' in df.columns:
|
||||||
|
browser_dummies = pd.get_dummies(df['browser_family'], prefix='browser', drop_first=True)
|
||||||
|
df = pd.concat([df, browser_dummies], axis=1)
|
||||||
|
feature_cols.extend(browser_dummies.columns.tolist())
|
||||||
|
|
||||||
|
X = df[feature_cols].fillna(0)
|
||||||
|
y = df['is_agent'].astype(int)
|
||||||
|
|
||||||
|
return X, y, feature_cols
|
||||||
|
|
||||||
|
|
||||||
|
def train(data_path=None, model_type='xgboost', test_size=0.2, random_state=42,
|
||||||
|
n_estimators=200, max_depth=6, learning_rate=0.05):
|
||||||
|
"""
|
||||||
|
Train agent detection classifier
|
||||||
|
Args:
|
||||||
|
data_path: path to labeled feature matrix CSV or parquet
|
||||||
|
model_type: 'xgboost' or 'lightgbm'
|
||||||
|
test_size: fraction for test split
|
||||||
|
random_state: seed for reproducibility
|
||||||
|
"""
|
||||||
|
RUNS_DIR.mkdir(exist_ok=True)
|
||||||
|
CHECKPOINTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
run_name = f"{model_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
writer = SummaryWriter(log_dir=RUNS_DIR / run_name)
|
||||||
|
logger.info(f"Starting training run: {run_name}")
|
||||||
|
|
||||||
|
# load data
|
||||||
|
if data_path is None:
|
||||||
|
logger.error("data_path required")
|
||||||
|
return
|
||||||
|
df = pd.read_parquet(data_path)
|
||||||
|
logger.info(f"Loaded {len(df)} sessions from {data_path}")
|
||||||
|
|
||||||
|
# prepare features and labels
|
||||||
|
if 'is_agent' not in df.columns:
|
||||||
|
logger.error("Missing is_agent column")
|
||||||
|
return
|
||||||
|
|
||||||
|
X, y, feature_cols = prepare_data(df)
|
||||||
|
if X is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# class distribution
|
||||||
|
n_agents = y.sum()
|
||||||
|
n_humans = (y == 0).sum()
|
||||||
|
logger.info(f"Class distribution: {n_humans} humans, {n_agents} agents" + (f" (ratio {n_humans / n_agents:.2f})" if n_agents > 0 else ""))
|
||||||
|
|
||||||
|
# train/test split with stratification
|
||||||
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
|
X, y, test_size=test_size, random_state=random_state, stratify=y
|
||||||
|
)
|
||||||
|
logger.info(f"Train: {len(X_train)}, Test: {len(X_test)}")
|
||||||
|
|
||||||
|
# init model
|
||||||
|
if model_type == 'xgboost':
|
||||||
|
model = XGBoostAgentClassifier(
|
||||||
|
n_estimators=n_estimators,
|
||||||
|
max_depth=max_depth,
|
||||||
|
learning_rate=learning_rate
|
||||||
|
)
|
||||||
|
elif model_type == 'lightgbm':
|
||||||
|
model = LightGBMAgentClassifier(
|
||||||
|
n_estimators=n_estimators,
|
||||||
|
max_depth=max_depth,
|
||||||
|
learning_rate=learning_rate
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown model type: {model_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# train with eval set for early stopping
|
||||||
|
model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
|
||||||
|
logger.info("Training complete")
|
||||||
|
|
||||||
|
# evaluate on test set
|
||||||
|
y_pred = model.predict(X_test)
|
||||||
|
y_prob = model.predict_proba(X_test)[:, 1]
|
||||||
|
|
||||||
|
evaluate(y_pred, y_prob, y_test, writer, epoch=0)
|
||||||
|
|
||||||
|
# log feature importance
|
||||||
|
log_feature_importance(writer, model, X.columns.tolist(), epoch=0)
|
||||||
|
|
||||||
|
# save model
|
||||||
|
model_path = CHECKPOINTS_DIR / f"{run_name}.pkl"
|
||||||
|
joblib.dump({'model': model, 'feature_cols': X.columns.tolist(), 'run_name': run_name}, model_path)
|
||||||
|
logger.info(f"Model saved to {model_path}")
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
return model, X.columns.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
data_path = sys.argv[1]
|
||||||
|
model_type = sys.argv[2] if len(sys.argv) > 2 else 'xgboost'
|
||||||
|
train(data_path, model_type=model_type)
|
||||||
@@ -1,19 +1,51 @@
|
|||||||
from .extract import (
|
from procesing.context import PipelineContext
|
||||||
KafkaDataFetcher,
|
from procesing.providers import DataProvider, SupabaseProvider, BackendAPIProvider
|
||||||
ExperimentJoiner,
|
from procesing.steps import (
|
||||||
EventTitleAugmenter,
|
BaseContextStep,
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
# StateSpace,
|
||||||
|
# BuildStateSpaceStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
)
|
||||||
|
from procesing.pipelines import (
|
||||||
|
interaction_extraction_pipeline,
|
||||||
|
price_extraction_pipeline,
|
||||||
|
pricing_pipeline,
|
||||||
|
full_pipeline,
|
||||||
)
|
)
|
||||||
from .demand import DemandEstimator
|
|
||||||
from .mapping import SessionTransitionProbMatrixTransformer, render_graph
|
|
||||||
from .pipeline import etl_pipeline, pricing_pipeline
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'KafkaDataFetcher',
|
'PipelineContext',
|
||||||
'ExperimentJoiner',
|
'DataProvider',
|
||||||
'EventTitleAugmenter',
|
'SupabaseProvider',
|
||||||
'DemandEstimator',
|
'BackendAPIProvider',
|
||||||
'SessionTransitionProbMatrixTransformer',
|
'BaseContextStep',
|
||||||
'render_graph',
|
'FetchInteractionsStep',
|
||||||
'etl_pipeline',
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
# 'StateSpace',
|
||||||
|
# 'BuildStateSpaceStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'interaction_extraction_pipeline',
|
||||||
|
'price_extraction_pipeline',
|
||||||
'pricing_pipeline',
|
'pricing_pipeline',
|
||||||
|
'full_pipeline',
|
||||||
]
|
]
|
||||||
|
|||||||
34
experiments/procesing/context.py
Normal file
34
experiments/procesing/context.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class PipelineContext:
|
||||||
|
"""
|
||||||
|
Context for pipeline execution holding config, provider, and cached data.
|
||||||
|
Enables dependency injection and eliminates global state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
provider: DataProvider,
|
||||||
|
store_mode: str,
|
||||||
|
window_size: str = '30s',
|
||||||
|
**config):
|
||||||
|
self.provider = provider
|
||||||
|
self.store_mode = store_mode
|
||||||
|
self.window_size = window_size
|
||||||
|
self.config = config
|
||||||
|
self._cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def get_cached(self, key: str, default=None):
|
||||||
|
return self._cache.get(key, default)
|
||||||
|
|
||||||
|
def cache(self, key: str, value):
|
||||||
|
self._cache[key] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def products(self) -> pd.DataFrame:
|
||||||
|
"""Lazy-load and cache product catalog, single fetch per pipeline run"""
|
||||||
|
if 'products' not in self._cache:
|
||||||
|
self._cache['products'] = self.provider.fetch_products(self.store_mode)
|
||||||
|
return self._cache['products']
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from typing import Optional, Literal
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
||||||
|
|
||||||
class ChunkInteractionsIntoSteps(BaseEstimator, TransformerMixin):
|
|
||||||
"""
|
|
||||||
Split interaction data into time windows for temporal analysis.
|
|
||||||
Returns a list of dataframes, one per time window.
|
|
||||||
"""
|
|
||||||
def __init__(self,
|
|
||||||
window_size:str='1h',
|
|
||||||
ts_col:str='ts',
|
|
||||||
return_metadata:bool=True):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
window_size: pandas freq string ('1h', '30T', '1D', etc)
|
|
||||||
ts_col: timestamp column name
|
|
||||||
return_metadata: if True, return dict with metadata per chunk
|
|
||||||
"""
|
|
||||||
self.window_size = window_size
|
|
||||||
self.ts_col = ts_col
|
|
||||||
self.return_metadata = return_metadata
|
|
||||||
|
|
||||||
def fit(self, X):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, interactions: pd.DataFrame):
|
|
||||||
"""
|
|
||||||
Returns:
|
|
||||||
if return_metadata=False: list of dataframes, one per window
|
|
||||||
if return_metadata=True: list of dicts with keys:
|
|
||||||
- 'data': dataframe for this window
|
|
||||||
- 'window_start': start timestamp
|
|
||||||
- 'window_end': end timestamp
|
|
||||||
- 'window_idx': integer index
|
|
||||||
"""
|
|
||||||
if interactions.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = interactions.copy()
|
|
||||||
|
|
||||||
# ensure timestamp is datetime
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(df[self.ts_col]):
|
|
||||||
df[self.ts_col] = pd.to_datetime(df[self.ts_col])
|
|
||||||
|
|
||||||
# sort by time
|
|
||||||
df = df.sort_values(self.ts_col)
|
|
||||||
|
|
||||||
# assign window
|
|
||||||
df['_window'] = df[self.ts_col].dt.floor(self.window_size)
|
|
||||||
|
|
||||||
# group by window
|
|
||||||
chunks = []
|
|
||||||
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
|
||||||
chunk_data = group.drop(columns=['_window'])
|
|
||||||
|
|
||||||
if self.return_metadata:
|
|
||||||
chunks.append({
|
|
||||||
'data': chunk_data,
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_start + pd.Timedelta(self.window_size),
|
|
||||||
'window_idx': idx
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
chunks.append(chunk_data)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
class DemandEstimator(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self,
|
|
||||||
store_mode:str='hotel',
|
|
||||||
session_filter:str="",
|
|
||||||
experiment_filter:str=""):
|
|
||||||
self.store=store_mode
|
|
||||||
self.session_filter=session_filter if len(session_filter)>0 else None
|
|
||||||
self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None
|
|
||||||
def fit(self, X):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, interactions : pd.DataFrame):
|
|
||||||
if interactions.empty:
|
|
||||||
return pd.DataFrame(columns=["productId", "demand_score"])
|
|
||||||
if self.session_filter:
|
|
||||||
interactions = interactions[interactions['sessionId'] == self.session_filter]
|
|
||||||
if self.experiment_filter:
|
|
||||||
interactions = interactions[interactions['experimentId'] == self.experiment_filter]
|
|
||||||
|
|
||||||
products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute()
|
|
||||||
products = pd.DataFrame(products.data)
|
|
||||||
unique_products = products['id'].unique()
|
|
||||||
log.info(f"Demand estimator found {len(unique_products)} in data")
|
|
||||||
|
|
||||||
# filter out rows without productId
|
|
||||||
interactions_with_products = interactions.dropna(subset=['productId'])
|
|
||||||
|
|
||||||
if interactions_with_products.empty:
|
|
||||||
# no interactions with products, return all zeros
|
|
||||||
return pd.DataFrame({
|
|
||||||
'productId': unique_products,
|
|
||||||
'demand_score': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# TODO: improve demand score calculation rather than just counting interactions (use weights..)
|
|
||||||
# while maintaining simplicity of a simple cross tab approach
|
|
||||||
product_demand = pd.crosstab(interactions_with_products['productId'], "no_of_interactions")
|
|
||||||
product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index()
|
|
||||||
product_demand.columns = ['productId', 'demand_score']
|
|
||||||
return product_demand
|
|
||||||
@@ -130,25 +130,24 @@ class TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
|||||||
|
|
||||||
def _build_product_timeseries(self, aligned_chunks):
|
def _build_product_timeseries(self, aligned_chunks):
|
||||||
"""Build time series [price, quantity] per product."""
|
"""Build time series [price, quantity] per product."""
|
||||||
series_by_product = {}
|
# vectorize chunk merging instead of iterating rows
|
||||||
|
all_merged = []
|
||||||
for chunk in aligned_chunks:
|
for chunk in aligned_chunks:
|
||||||
demand_df = chunk['demand']
|
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
||||||
price_df = chunk['prices']
|
merged['timestamp'] = chunk['window_start']
|
||||||
|
all_merged.append(merged[['productId', 'timestamp', 'price', 'demand_score']])
|
||||||
|
|
||||||
# merge on productId
|
if not all_merged:
|
||||||
merged = demand_df.merge(price_df, on='productId', how='inner')
|
return {}
|
||||||
|
|
||||||
for _, row in merged.iterrows():
|
# concat all chunks and group by productId in one pass
|
||||||
pid = row['productId']
|
combined = pd.concat(all_merged, ignore_index=True)
|
||||||
if pid not in series_by_product:
|
series_by_product = {
|
||||||
series_by_product[pid] = []
|
pid: group[['timestamp', 'price', 'demand_score']].rename(
|
||||||
|
columns={'demand_score': 'quantity'}
|
||||||
series_by_product[pid].append({
|
).to_dict('records')
|
||||||
'timestamp': chunk['window_start'],
|
for pid, group in combined.groupby('productId')
|
||||||
'price': row['price'],
|
}
|
||||||
'quantity': row['demand_score']
|
|
||||||
})
|
|
||||||
|
|
||||||
return series_by_product
|
return series_by_product
|
||||||
|
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
import json
|
|
||||||
import numpy as np
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from typing import Tuple, List, Dict
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000")
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
|
||||||
N_PRICE_BUCKETS = 5
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
class KafkaDataFetcher(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, topic: str = "user-interactions"):
|
|
||||||
self.topic = topic # also can be price-logs
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X=None):
|
|
||||||
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump?topic={self.topic}")
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if not data.get('success') or not data.get('data'):
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
df = pd.DataFrame(data['data'])
|
|
||||||
if self.topic == 'user-interactions':
|
|
||||||
if 'metadata' in df.columns: # explode metadata col json
|
|
||||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
# remape dateIndex
|
|
||||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
if df.empty or 'experimentId' not in df.columns:
|
|
||||||
return df
|
|
||||||
|
|
||||||
unique_exp_ids = df['experimentId'].dropna().unique()
|
|
||||||
if len(unique_exp_ids) == 0:
|
|
||||||
return df
|
|
||||||
|
|
||||||
resp = supabase.table('experiments').select(
|
|
||||||
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)'
|
|
||||||
).in_('id', unique_exp_ids.tolist()).execute()
|
|
||||||
|
|
||||||
if not resp.data:
|
|
||||||
return df
|
|
||||||
|
|
||||||
exp_df = pd.DataFrame(resp.data)
|
|
||||||
|
|
||||||
# flatten task nested object if present
|
|
||||||
if 'task' in exp_df.columns and exp_df['task'].notnull().any():
|
|
||||||
task_normalized = pd.json_normalize(exp_df['task'].dropna())
|
|
||||||
task_normalized.index = exp_df[exp_df['task'].notnull()].index
|
|
||||||
exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task')
|
|
||||||
|
|
||||||
# rename experiment columns for clarity
|
|
||||||
exp_df = exp_df.rename(columns={
|
|
||||||
'id': 'experimentId',
|
|
||||||
'subject_name': 'exp_subject',
|
|
||||||
'xp_human_only': 'exp_human_only',
|
|
||||||
'xp_market_mode': 'exp_market_mode',
|
|
||||||
'xp_task_id': 'exp_task_id'
|
|
||||||
})
|
|
||||||
|
|
||||||
df = df.merge(exp_df, on='experimentId', how='left')
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class EventTitleAugmenter(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
|
||||||
# we want metadata schema to create product specific event names
|
|
||||||
|
|
||||||
# only create price buckets if we have enough unique prices
|
|
||||||
if df["metadata_price"].notnull().sum() > 0:
|
|
||||||
try:
|
|
||||||
price_buckets = pd.qcut(
|
|
||||||
df["metadata_price"],
|
|
||||||
q=N_PRICE_BUCKETS,
|
|
||||||
labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)],
|
|
||||||
duplicates='drop' # handle duplicate bin edges
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# fallback: if still not enough unique values, use cut with fixed ranges or just use raw price
|
|
||||||
price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "")
|
|
||||||
else:
|
|
||||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
|
||||||
|
|
||||||
# metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name
|
|
||||||
# TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page
|
|
||||||
df["metadata_schema"] = np.where(
|
|
||||||
df["productId"].notnull() & df["metadata_price"].notnull(),
|
|
||||||
"_" + df["productId"].astype(str) + "@" + price_buckets.astype(str),
|
|
||||||
""
|
|
||||||
)
|
|
||||||
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_shared_data(interactions_df: pd.DataFrame,
|
|
||||||
price_logs_df: pd.DataFrame,
|
|
||||||
window_size: str = '30s',
|
|
||||||
ts_col: str = 'ts') -> Tuple[List[Dict], List[Dict]]:
|
|
||||||
"""
|
|
||||||
Chunk interaction and price data into aligned time windows.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interactions_df: interaction data with timestamp column
|
|
||||||
price_logs_df: price log data with timestamp column
|
|
||||||
window_size: pandas freq string ('30s', '1min', '1h', etc)
|
|
||||||
ts_col: name of timestamp column
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple of (interaction_chunks, price_chunks) where each is list of dicts:
|
|
||||||
{
|
|
||||||
'window_start': timestamp,
|
|
||||||
'window_end': timestamp,
|
|
||||||
'data': dataframe for this window
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if interactions_df.empty and price_logs_df.empty:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# convert timestamps to datetime
|
|
||||||
interactions_df = interactions_df.copy()
|
|
||||||
price_logs_df = price_logs_df.copy()
|
|
||||||
|
|
||||||
if not interactions_df.empty:
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(interactions_df[ts_col]):
|
|
||||||
interactions_df[ts_col] = pd.to_datetime(interactions_df[ts_col])
|
|
||||||
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(price_logs_df[ts_col]):
|
|
||||||
price_logs_df[ts_col] = pd.to_datetime(price_logs_df[ts_col])
|
|
||||||
|
|
||||||
# find global time bounds
|
|
||||||
times = []
|
|
||||||
if not interactions_df.empty:
|
|
||||||
times.extend([interactions_df[ts_col].min(), interactions_df[ts_col].max()])
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
times.extend([price_logs_df[ts_col].min(), price_logs_df[ts_col].max()])
|
|
||||||
|
|
||||||
if not times:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
earliest = min(times)
|
|
||||||
latest = max(times)
|
|
||||||
|
|
||||||
# create shared time windows
|
|
||||||
windows = pd.date_range(start=earliest, end=latest, freq=window_size)
|
|
||||||
|
|
||||||
if len(windows) < 2:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# chunk both datasets
|
|
||||||
interaction_chunks = []
|
|
||||||
price_chunks = []
|
|
||||||
|
|
||||||
for i in range(len(windows) - 1):
|
|
||||||
window_start = windows[i]
|
|
||||||
window_end = windows[i + 1]
|
|
||||||
|
|
||||||
# filter interactions in this window
|
|
||||||
if not interactions_df.empty:
|
|
||||||
mask = (interactions_df[ts_col] >= window_start) & (interactions_df[ts_col] < window_end)
|
|
||||||
interaction_chunk = interactions_df[mask]
|
|
||||||
else:
|
|
||||||
interaction_chunk = pd.DataFrame()
|
|
||||||
|
|
||||||
interaction_chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_end,
|
|
||||||
'data': interaction_chunk
|
|
||||||
})
|
|
||||||
|
|
||||||
# filter price logs in this window
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
mask = (price_logs_df[ts_col] >= window_start) & (price_logs_df[ts_col] < window_end)
|
|
||||||
price_chunk = price_logs_df[mask]
|
|
||||||
else:
|
|
||||||
price_chunk = pd.DataFrame()
|
|
||||||
|
|
||||||
price_chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_end,
|
|
||||||
'data': price_chunk
|
|
||||||
})
|
|
||||||
|
|
||||||
return interaction_chunks, price_chunks
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
|
|
||||||
def build_transition_prob_matrix(df: pd.DataFrame):
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
events = df['eventName'].tolist()
|
|
||||||
labels = pd.Index(events).unique().tolist()
|
|
||||||
idx = {e:i for i,e in enumerate(labels)}
|
|
||||||
M = np.zeros((len(labels), len(labels)), dtype=float)
|
|
||||||
for a, b in zip(events, events[1:]):
|
|
||||||
M[idx[a], idx[b]] += 1
|
|
||||||
row_sums = M.sum(axis=1, keepdims=True)
|
|
||||||
with np.errstate(divide='ignore', invalid='ignore'):
|
|
||||||
P = np.divide(M, row_sums, where=row_sums>0) # row-normalized
|
|
||||||
return P, labels
|
|
||||||
|
|
||||||
# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b
|
|
||||||
from graphviz import Digraph
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
def _as_prob_df(matrix, labels=None):
|
|
||||||
"""Return a square DataFrame with index=columns=labels."""
|
|
||||||
if isinstance(matrix, pd.DataFrame):
|
|
||||||
# Ensure square and aligned
|
|
||||||
assert (matrix.index == matrix.columns).all(), "Index/columns must match."
|
|
||||||
return matrix
|
|
||||||
matrix = np.asarray(matrix, dtype=float)
|
|
||||||
assert matrix.shape[0] == matrix.shape[1], "Matrix must be square."
|
|
||||||
if labels is None:
|
|
||||||
raise ValueError("labels are required when matrix is not a DataFrame")
|
|
||||||
assert len(labels) == matrix.shape[0], "labels length must match matrix size."
|
|
||||||
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
|
|
||||||
|
|
||||||
def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):
|
|
||||||
"""Build weighted edges > threshold."""
|
|
||||||
edges = []
|
|
||||||
for src in P.index:
|
|
||||||
for dst in P.columns:
|
|
||||||
w = float(P.loc[src, dst])
|
|
||||||
if w > threshold:
|
|
||||||
edges.append((str(src), str(dst), f"{w:.{round_digits}f}"))
|
|
||||||
return edges
|
|
||||||
|
|
||||||
def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt="svg", view=False):
|
|
||||||
"""
|
|
||||||
fname: output file stem (no extension)
|
|
||||||
matrix: NumPy array or pandas DataFrame of transition PROBABILITIES
|
|
||||||
ls_index: ordered labels (required if matrix is not a DataFrame)
|
|
||||||
threshold: hide edges with weight <= threshold
|
|
||||||
fmt: 'svg'|'png'|'pdf' etc.
|
|
||||||
view: open after rendering
|
|
||||||
"""
|
|
||||||
P = _as_prob_df(matrix, labels=ls_index)
|
|
||||||
edges = _df_to_edgelist(P, threshold=threshold)
|
|
||||||
|
|
||||||
g = Digraph(format=fmt)
|
|
||||||
g.attr(rankdir="LR", size="30")
|
|
||||||
g.attr("node", shape="circle")
|
|
||||||
|
|
||||||
# ensure isolated nodes appear
|
|
||||||
for node in P.index:
|
|
||||||
g.node(str(node), width="1", height="1")
|
|
||||||
|
|
||||||
for src, dst, label in edges:
|
|
||||||
g.edge(src, dst, label=label)
|
|
||||||
|
|
||||||
g.render(fname, view=view, cleanup=True)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.P_ = None
|
|
||||||
self.labels_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
P, labels = build_transition_prob_matrix(X)
|
|
||||||
self.P_ = P
|
|
||||||
self.labels_ = labels
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
return self.P_, self.labels_
|
|
||||||
|
|
||||||
def render(self, fname: str, fmt="svg", view=False):
|
|
||||||
if self.P_ is None or self.labels_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
self.P_,
|
|
||||||
ls_index=self.labels_,
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionTransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0, session_col='sessionId'):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.session_col = session_col
|
|
||||||
self.session_matrices_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
if self.session_col not in X.columns:
|
|
||||||
raise ValueError(f"Column '{self.session_col}' not found in DataFrame")
|
|
||||||
|
|
||||||
session_matrices = {}
|
|
||||||
for session_id, grp in X.groupby(self.session_col):
|
|
||||||
if len(grp) > 1: # need at least 2 events for transitions
|
|
||||||
P, labels = build_transition_prob_matrix(grp)
|
|
||||||
session_matrices[session_id] = {'matrix': P, 'labels': labels}
|
|
||||||
|
|
||||||
self.session_matrices_ = session_matrices
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return pd.Series(self.session_matrices_)
|
|
||||||
|
|
||||||
def render_session(self, session_id: str, fname: str, fmt="svg", view=False):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
if session_id not in self.session_matrices_:
|
|
||||||
raise ValueError(f"Session '{session_id}' not found in fitted data.")
|
|
||||||
|
|
||||||
sess_data = self.session_matrices_[session_id]
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
sess_data['matrix'],
|
|
||||||
ls_index=sess_data['labels'],
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Example usage
|
|
||||||
data = {
|
|
||||||
'eventName': [
|
|
||||||
'A', 'B', 'A', 'C', 'B', 'A', 'A', 'C', 'B', 'C',
|
|
||||||
'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
|
|
||||||
transformer = TransitionProbMatrixTransformer(threshold=0.1)
|
|
||||||
transformer.fit(df)
|
|
||||||
P, labels = transformer.transform(None)
|
|
||||||
|
|
||||||
print("Transition Probability Matrix:")
|
|
||||||
print(pd.DataFrame(P, index=labels, columns=labels))
|
|
||||||
|
|
||||||
# Render the graph
|
|
||||||
transformer.render("transition_graph", fmt="svg", view=False)
|
|
||||||
245
experiments/procesing/metrics.py
Normal file
245
experiments/procesing/metrics.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Revenue and KPI benchmark framework for pricing strategies.
|
||||||
|
|
||||||
|
Computes session-level and aggregate metrics to compare pricing functions:
|
||||||
|
- Revenue: R_T = Σ P_t^T · Q_t
|
||||||
|
- Conversion rate
|
||||||
|
- Average order value (AOV)
|
||||||
|
- Agent exploitation loss: L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionMetrics:
|
||||||
|
"""KPIs for single session."""
|
||||||
|
session_id: str
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
|
||||||
|
# interaction metrics
|
||||||
|
total_interactions: int = 0
|
||||||
|
page_views: int = 0
|
||||||
|
item_views: int = 0
|
||||||
|
searches: int = 0
|
||||||
|
cart_adds: int = 0
|
||||||
|
|
||||||
|
# revenue metrics
|
||||||
|
items_purchased: int = 0
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_item_price: float = 0.0
|
||||||
|
conversion_rate: float = 0.0
|
||||||
|
|
||||||
|
# pricing signals
|
||||||
|
total_price_shown: float = 0.0 # sum of all prices displayed
|
||||||
|
avg_markup: float = 0.0 # avg (price / base_price)
|
||||||
|
|
||||||
|
# behavioral features (for agent detection)
|
||||||
|
interaction_velocity: float = 0.0 # interactions per minute
|
||||||
|
session_duration_sec: float = 0.0
|
||||||
|
unique_products_viewed: int = 0
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregateMetrics:
|
||||||
|
"""Aggregate KPIs across sessions/experiments."""
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
n_sessions: int = 0
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_revenue_per_session: float = 0.0
|
||||||
|
median_revenue_per_session: float = 0.0
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
total_conversions: int = 0
|
||||||
|
conversion_rate: float = 0.0 # purchases / sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
avg_markup: float = 0.0
|
||||||
|
median_markup: float = 0.0
|
||||||
|
|
||||||
|
# agent exploitation metrics
|
||||||
|
estimated_agent_sessions: int = 0 # sessions flagged as agent-driven
|
||||||
|
agent_revenue: float = 0.0
|
||||||
|
human_revenue: float = 0.0
|
||||||
|
agent_loss: float = 0.0 # L_agent = R_oracle - R_observed (if available)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsComputer:
|
||||||
|
"""Compute session and aggregate metrics from interaction/price logs."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_session_metrics(
|
||||||
|
session_id: str,
|
||||||
|
interactions: pd.DataFrame,
|
||||||
|
price_logs: pd.DataFrame,
|
||||||
|
purchases: Optional[pd.DataFrame] = None,
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
) -> SessionMetrics:
|
||||||
|
"""
|
||||||
|
Compute metrics for single session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: session identifier
|
||||||
|
interactions: user-interactions events for this session
|
||||||
|
price_logs: price-logs for this session
|
||||||
|
purchases: purchase events (if available)
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
"""
|
||||||
|
metrics = SessionMetrics(session_id=session_id, experiment_id=experiment_id)
|
||||||
|
|
||||||
|
if interactions.empty:
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
# interaction counts
|
||||||
|
event_counts = interactions['eventName'].value_counts().to_dict()
|
||||||
|
metrics.total_interactions = len(interactions)
|
||||||
|
metrics.page_views = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
||||||
|
metrics.item_views = event_counts.get('view_item_page', 0)
|
||||||
|
metrics.searches = event_counts.get('search', 0)
|
||||||
|
metrics.cart_adds = event_counts.get('add_item_to_cart', 0)
|
||||||
|
|
||||||
|
# unique products viewed
|
||||||
|
metrics.unique_products_viewed = interactions['productId'].dropna().nunique()
|
||||||
|
|
||||||
|
# session duration
|
||||||
|
if 'ts' in interactions.columns:
|
||||||
|
timestamps = pd.to_datetime(interactions['ts'])
|
||||||
|
metrics.session_duration_sec = (timestamps.max() - timestamps.min()).total_seconds()
|
||||||
|
if metrics.session_duration_sec > 0:
|
||||||
|
metrics.interaction_velocity = (metrics.total_interactions / metrics.session_duration_sec) * 60
|
||||||
|
|
||||||
|
# revenue from purchases
|
||||||
|
if purchases is not None and not purchases.empty:
|
||||||
|
metrics.items_purchased = len(purchases)
|
||||||
|
metrics.total_revenue = purchases['price'].sum() if 'price' in purchases.columns else 0.0
|
||||||
|
metrics.avg_item_price = metrics.total_revenue / metrics.items_purchased if metrics.items_purchased > 0 else 0.0
|
||||||
|
metrics.conversion_rate = 1.0 if metrics.items_purchased > 0 else 0.0
|
||||||
|
|
||||||
|
# pricing metrics
|
||||||
|
if not price_logs.empty:
|
||||||
|
metrics.total_price_shown = price_logs['price'].sum()
|
||||||
|
# compute markup if base_price available in price logs or join with product catalog
|
||||||
|
if 'base_price' in price_logs.columns:
|
||||||
|
valid_markup = price_logs[price_logs['base_price'] > 0]
|
||||||
|
if not valid_markup.empty:
|
||||||
|
metrics.avg_markup = (valid_markup['price'] / valid_markup['base_price']).mean()
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_aggregate_metrics(
|
||||||
|
session_metrics_list: List[SessionMetrics],
|
||||||
|
experiment_id: Optional[str] = None,
|
||||||
|
agent_detector_fn: Optional[callable] = None
|
||||||
|
) -> AggregateMetrics:
|
||||||
|
"""
|
||||||
|
Aggregate metrics across sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics_list: list of SessionMetrics
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
agent_detector_fn: optional function to classify session as agent (returns bool)
|
||||||
|
"""
|
||||||
|
agg = AggregateMetrics(experiment_id=experiment_id)
|
||||||
|
agg.n_sessions = len(session_metrics_list)
|
||||||
|
|
||||||
|
if agg.n_sessions == 0:
|
||||||
|
return agg
|
||||||
|
|
||||||
|
df = pd.DataFrame([m.to_dict() for m in session_metrics_list])
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
agg.total_revenue = df['total_revenue'].sum()
|
||||||
|
agg.avg_revenue_per_session = df['total_revenue'].mean()
|
||||||
|
agg.median_revenue_per_session = df['total_revenue'].median()
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
agg.total_conversions = (df['items_purchased'] > 0).sum()
|
||||||
|
agg.conversion_rate = agg.total_conversions / agg.n_sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
valid_markups = df[df['avg_markup'] > 0]
|
||||||
|
if not valid_markups.empty:
|
||||||
|
agg.avg_markup = valid_markups['avg_markup'].mean()
|
||||||
|
agg.median_markup = valid_markups['avg_markup'].median()
|
||||||
|
|
||||||
|
# agent detection (if detector provided)
|
||||||
|
if agent_detector_fn is not None:
|
||||||
|
agent_flags = [agent_detector_fn(m) for m in session_metrics_list]
|
||||||
|
agg.estimated_agent_sessions = sum(agent_flags)
|
||||||
|
|
||||||
|
agent_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if is_agent)
|
||||||
|
human_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if not is_agent)
|
||||||
|
|
||||||
|
agg.agent_revenue = agent_revenue
|
||||||
|
agg.human_revenue = human_revenue
|
||||||
|
|
||||||
|
return agg
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compare_pricing_strategies(
|
||||||
|
experiments: Dict[str, List[SessionMetrics]],
|
||||||
|
baseline_experiment_id: Optional[str] = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Compare multiple pricing strategies/experiments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
experiments: dict mapping experiment_id -> list of SessionMetrics
|
||||||
|
baseline_experiment_id: experiment to use as baseline for comparison
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with comparative metrics
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
baseline_agg = None
|
||||||
|
|
||||||
|
for exp_id, session_metrics in experiments.items():
|
||||||
|
agg = MetricsComputer.compute_aggregate_metrics(session_metrics, experiment_id=exp_id)
|
||||||
|
result = agg.to_dict()
|
||||||
|
|
||||||
|
if exp_id == baseline_experiment_id:
|
||||||
|
baseline_agg = agg
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
df = pd.DataFrame(results)
|
||||||
|
|
||||||
|
# add relative metrics if baseline exists
|
||||||
|
if baseline_agg is not None:
|
||||||
|
df['revenue_lift_pct'] = ((df['total_revenue'] - baseline_agg.total_revenue) / baseline_agg.total_revenue * 100)
|
||||||
|
df['conversion_lift_pct'] = ((df['conversion_rate'] - baseline_agg.conversion_rate) / baseline_agg.conversion_rate * 100)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def simple_agent_detector(session_metrics: SessionMetrics, velocity_threshold: float = 5.0) -> bool:
|
||||||
|
"""
|
||||||
|
Simple heuristic agent detector based on interaction velocity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics: SessionMetrics instance
|
||||||
|
velocity_threshold: interactions per minute threshold (default: 5.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session likely agent-driven
|
||||||
|
"""
|
||||||
|
# agents tend to have higher interaction velocity and lower session duration
|
||||||
|
if session_metrics.interaction_velocity > velocity_threshold:
|
||||||
|
return True
|
||||||
|
# agents often view many products quickly without converting
|
||||||
|
if session_metrics.unique_products_viewed > 10 and session_metrics.conversion_rate == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from sklearn.pipeline import Pipeline
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
import pandas as pd
|
|
||||||
import logging
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter, chunk_shared_data
|
|
||||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
|
||||||
from demand import DemandEstimator, ChunkInteractionsIntoSteps
|
|
||||||
from elasticity import TemporalElasticityEstimator, aggregate_price_logs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# elasticity pipeline components (not sklearn compatible, manual orchestration)
|
|
||||||
def elasticity_pipeline(interactions_df, price_logs_df, window_size='30s', store_mode='hotel'):
|
|
||||||
"""
|
|
||||||
Compute price elasticity from interaction and price data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interactions_df: raw interaction data from demand_data_pipeline
|
|
||||||
price_logs_df: price log data from price_data_pipeline
|
|
||||||
window_size: time window for chunking
|
|
||||||
store_mode: 'hotel' or 'airline'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
df with [productId, elasticity, std_error, n_obs]
|
|
||||||
"""
|
|
||||||
# step 1: chunk interactions into time windows
|
|
||||||
chunker = ChunkInteractionsIntoSteps(window_size=window_size, return_metadata=True)
|
|
||||||
interaction_chunks = chunker.transform(interactions_df)
|
|
||||||
log.info(f"Chunked interactions into {len(interaction_chunks)} windows of size {window_size}")
|
|
||||||
|
|
||||||
if not interaction_chunks:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# step 2: compute demand per window
|
|
||||||
demand_estimator = DemandEstimator(store_mode=store_mode)
|
|
||||||
demand_chunks = []
|
|
||||||
for chunk in interaction_chunks:
|
|
||||||
demand_vector = demand_estimator.transform(chunk['data'])
|
|
||||||
demand_chunks.append({
|
|
||||||
'window_start': chunk['window_start'],
|
|
||||||
'window_end': chunk['window_end'],
|
|
||||||
'demand_vector': demand_vector # each has a full list of all products, even if demand is 0
|
|
||||||
})
|
|
||||||
# [q_chunk1, q_chunk2, ...]
|
|
||||||
|
|
||||||
# step 3: aggregate price logs into windows
|
|
||||||
price_chunks = aggregate_price_logs(price_logs_df, window_size=window_size)
|
|
||||||
|
|
||||||
# step 4: compute elasticity
|
|
||||||
elasticity_estimator = TemporalElasticityEstimator(method='point', min_observations=2)
|
|
||||||
elasticity_df = elasticity_estimator.transform(demand_chunks, price_chunks, store_mode=store_mode)
|
|
||||||
|
|
||||||
return elasticity_df
|
|
||||||
|
|
||||||
|
|
||||||
# exposable pipelines
|
|
||||||
interaction_pipeline = Pipeline([
|
|
||||||
('kafka_fetch', KafkaDataFetcher(topic='user-interactions')),
|
|
||||||
('experiment_join', ExperimentJoiner()),
|
|
||||||
('event_augment', EventTitleAugmenter()),
|
|
||||||
])
|
|
||||||
|
|
||||||
price_data_pipeline = Pipeline([
|
|
||||||
('kafka_fetch', KafkaDataFetcher(topic='price-logs')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# interaction_data + price_data -> elasticity (demand)
|
|
||||||
# elasticity -> pricing
|
|
||||||
|
|
||||||
pricing_pipeline = Pipeline([
|
|
||||||
('demand_estimation', DemandEstimator()),
|
|
||||||
])
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# fetch both datasets
|
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
|
||||||
pricing_data = price_data_pipeline.fit_transform(None)
|
|
||||||
if interaction_data.empty or pricing_data.empty:
|
|
||||||
print("Insufficient data for elasticity computation"); exit(0)
|
|
||||||
# compute elasticity via unified pipeline
|
|
||||||
window_size = "30s"
|
|
||||||
elasticity_results = elasticity_pipeline(interaction_data, pricing_data, window_size=window_size)
|
|
||||||
elasticity_value_array = elasticity_results['elasticity'].values if elasticity_results is not None else np.array([])
|
|
||||||
print(elasticity_value_array)
|
|
||||||
|
|
||||||
if elasticity_results is not None and not elasticity_results.empty:
|
|
||||||
print(elasticity_results.to_string(index=False))
|
|
||||||
else:
|
|
||||||
print("\nInsufficient data for elasticity computation")
|
|
||||||
174
experiments/procesing/pipelines.py
Normal file
174
experiments/procesing/pipelines.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
import os
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
ExtractSessionFeaturesStep,
|
||||||
|
JoinLabelsStep,
|
||||||
|
ValidateDataStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers import SimpleSurgePricer
|
||||||
|
|
||||||
|
def interaction_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting and augmenting interaction data"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchInteractionsStep(context)),
|
||||||
|
('create_buckets', CreatePriceBucketsStep(context)),
|
||||||
|
('augment_events', AugmentEventNamesStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def price_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting price logs"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchPriceLogsStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def product_features_pipeline(context: PipelineContext,
|
||||||
|
interactions_df: pd.DataFrame,
|
||||||
|
price_logs_df: pd.DataFrame):
|
||||||
|
demand_step = ComputeDemandStep(context)
|
||||||
|
price_step = AggregatePriceLogsStep(context)
|
||||||
|
join_step = JoinProductFeaturesStep(context)
|
||||||
|
|
||||||
|
|
||||||
|
demand_data = demand_step.transform(interactions_df)
|
||||||
|
price_data= price_step.transform(price_logs_df)
|
||||||
|
joined_data = join_step.transform((demand_data, price_data))
|
||||||
|
|
||||||
|
return joined_data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def pricing_pipeline(context: "PipelineContext",
|
||||||
|
data: pd.DataFrame,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9) -> pd.DataFrame:
|
||||||
|
|
||||||
|
if data.empty or 'productId' not in data.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer()
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def full_pipeline(context: PipelineContext,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
"""
|
||||||
|
Complete end-to-end pipeline: data extraction -> demand/price aggregation -> surge pricing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Pipeline context
|
||||||
|
high_threshold: Demand threshold for surge pricing
|
||||||
|
low_threshold: Demand threshold for discounts
|
||||||
|
surge_multiplier: Price multiplier for high demand
|
||||||
|
discount_multiplier: Price multiplier for low demand
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (product_features_df, optimal_prices_df)
|
||||||
|
- product_features_df: [productId, demand_score, price]
|
||||||
|
- optimal_prices_df: [productId, current_price, optimal_price, demand_score]
|
||||||
|
"""
|
||||||
|
interaction_pipe = interaction_extraction_pipeline(context)
|
||||||
|
price_pipe = price_extraction_pipeline(context)
|
||||||
|
|
||||||
|
interactions_df = interaction_pipe.fit_transform(None)
|
||||||
|
price_logs_df = price_pipe.fit_transform(None)
|
||||||
|
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||||
|
print(product_features_df.to_string())
|
||||||
|
|
||||||
|
# generate optimal prices using surge rules
|
||||||
|
optimal_prices_df = pricing_pipeline(context, product_features_df,
|
||||||
|
high_threshold=high_threshold,
|
||||||
|
low_threshold=low_threshold,
|
||||||
|
surge_multiplier=surge_multiplier,
|
||||||
|
discount_multiplier=discount_multiplier)
|
||||||
|
|
||||||
|
return product_features_df, optimal_prices_df
|
||||||
|
|
||||||
|
|
||||||
|
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Build labeled session-level feature matrix for ML model training.
|
||||||
|
Pipeline: fetch -> validate -> extract features -> join labels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with ~25 features per session + is_agent label
|
||||||
|
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||||
|
"""
|
||||||
|
# fetch raw interactions
|
||||||
|
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||||
|
|
||||||
|
# validate data quality (report cached in context)
|
||||||
|
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||||
|
if interactions_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# extract vectorized session features
|
||||||
|
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||||
|
if features_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# join experiment labels (is_agent = ~xp_human_only)
|
||||||
|
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||||
|
|
||||||
|
return labeled_df
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
class ExperimentsProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
base_path = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/" # os.path.join(os.path.dirname(__file__), "collected_data")
|
||||||
|
if not os.path.isdir(base_path):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||||
|
file_to_read = files.get(topic, files["user-interactions"])
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
for d in os.listdir(base_path):
|
||||||
|
full_path = os.path.join(base_path, d, file_to_read)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = pd.read_json(full_path)
|
||||||
|
payloads = pd.DataFrame([r['payload'] for r in data['value'].to_list()])
|
||||||
|
frames.append(payloads)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not process {full_path}: {e}")
|
||||||
|
|
||||||
|
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||||
|
|
||||||
|
# demo: run ML training pipeline
|
||||||
|
context = PipelineContext(provider=ExperimentsProvider(), store_mode='hotel')
|
||||||
|
features = ml_training_pipeline(context)
|
||||||
|
print(f"Feature matrix: {features.shape}")
|
||||||
|
print(features.head())
|
||||||
|
print(features.info())
|
||||||
|
|
||||||
|
features.to_parquet("features.parquet")
|
||||||
14
experiments/procesing/pricers/__init__.py
Normal file
14
experiments/procesing/pricers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||||
|
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PricingFunction',
|
||||||
|
'ElasticityBasedPricer',
|
||||||
|
'StaticPricer',
|
||||||
|
'RandomPricer',
|
||||||
|
'SimpleSurgePricer',
|
||||||
|
'SessionAwarePricer',
|
||||||
|
'ProductSpecificSessionPricer'
|
||||||
|
]
|
||||||
70
experiments/procesing/pricers/base.py
Normal file
70
experiments/procesing/pricers/base.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class PricingFunction(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base for pricing functions.
|
||||||
|
|
||||||
|
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
||||||
|
|
||||||
|
Where:
|
||||||
|
Q_t ∈ R^n: demand vector at time t
|
||||||
|
P_t ∈ R^n: price vector at time t
|
||||||
|
S_t: session features (behavioral signals, interactions)
|
||||||
|
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||||
|
subject to:
|
||||||
|
Q_t = g(P_t, S_t) (demand response via elasticity)
|
||||||
|
P_t ≥ C (cost floor)
|
||||||
|
minimize L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fit(self, *kwargs):
|
||||||
|
"""
|
||||||
|
Offline training on historical data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
historical_data: DataFrame with elasticity, prices, demand signals
|
||||||
|
**kwargs: additional training parameters
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def predict(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Generate optimal prices given current state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
P_{t+1}: price vector in R^n
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, observation: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Online learning update (optional).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
observation: dict with {state, action, reward, next_state}
|
||||||
|
- state: StateSpace before pricing decision
|
||||||
|
- action: prices shown (P_t)
|
||||||
|
- reward: revenue/conversion signal
|
||||||
|
- next_state: StateSpace after user interaction
|
||||||
|
"""
|
||||||
|
pass # default: no online learning
|
||||||
|
|
||||||
|
def get_params(self) -> Dict[str, Any]:
|
||||||
|
"""Return pricing function parameters for serialization."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_params(self, params: Dict[str, Any]):
|
||||||
|
"""Load pricing function parameters from dict."""
|
||||||
|
pass
|
||||||
59
experiments/procesing/pricers/elasticity.py
Normal file
59
experiments/procesing/pricers/elasticity.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Pricing based on demand elasticity estimates.
|
||||||
|
f(Q, S) = base_price * (1 + alpha * elasticity * demand_deviation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, alpha: float = 0.1, price_floor: float = 0.0, price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Calibrate from historical elasticity estimates.
|
||||||
|
Expects: [productId, elasticity, base_price, mean_demand]
|
||||||
|
"""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' column")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on demand deviation and elasticity.
|
||||||
|
Higher demand -> increase price (but less for elastic goods)
|
||||||
|
"""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
if len(demand) != len(self.elasticity):
|
||||||
|
raise ValueError(f"Demand vector size {len(demand)} != elasticity size {len(self.elasticity)}")
|
||||||
|
|
||||||
|
# compute demand deviation from mean
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
|
||||||
|
# adjust price: if demand high and elastic, don't increase much
|
||||||
|
# if demand high and inelastic, increase more
|
||||||
|
price_multiplier = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
prices = self.base_prices * price_multiplier
|
||||||
|
|
||||||
|
# enforce bounds
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
172
experiments/procesing/pricers/session_aware.py
Normal file
172
experiments/procesing/pricers/session_aware.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Session-aware pricing functions that leverage behavioral features S_t.
|
||||||
|
These pricers aim to minimize L_agent = R_oracle - R_observed.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAwarePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Extends elasticity-based pricing with session behavioral signals.
|
||||||
|
|
||||||
|
f(Q, P, S) = base_price * elasticity_factor * session_factor
|
||||||
|
|
||||||
|
Where session_factor adjusts for:
|
||||||
|
- interaction_velocity (agent detection proxy)
|
||||||
|
- product_view_depth (interest signal)
|
||||||
|
- cart_to_view_ratio (conversion intent)
|
||||||
|
|
||||||
|
Strategy: charge higher prices to suspected agents (high velocity)
|
||||||
|
to recover oracle revenue from reconnaissance sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
beta_velocity: float = 0.05,
|
||||||
|
beta_attention: float = 0.03,
|
||||||
|
agent_velocity_threshold: float = 5.0,
|
||||||
|
agent_markup: float = 1.2,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
alpha: elasticity sensitivity
|
||||||
|
beta_velocity: interaction velocity weight
|
||||||
|
beta_attention: product attention weight
|
||||||
|
agent_velocity_threshold: velocity above which to apply agent markup
|
||||||
|
agent_markup: price multiplier for suspected agent sessions
|
||||||
|
price_floor, price_ceil: price bounds
|
||||||
|
"""
|
||||||
|
self.alpha = alpha
|
||||||
|
self.beta_velocity = beta_velocity
|
||||||
|
self.beta_attention = beta_attention
|
||||||
|
self.agent_velocity_threshold = agent_velocity_threshold
|
||||||
|
self.agent_markup = agent_markup
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
# fitted parameters
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
"""Calibrate from historical elasticity data."""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate prices with session awareness."""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base elasticity-driven pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
elasticity_factor = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
|
||||||
|
# session-aware adjustments
|
||||||
|
session_factor = np.ones(n_products)
|
||||||
|
|
||||||
|
if not state_space.session_features.empty:
|
||||||
|
sf = state_space.session_features.iloc[0] # single session features
|
||||||
|
|
||||||
|
# agent detection via velocity
|
||||||
|
velocity = sf.get('interaction_velocity', 0.0)
|
||||||
|
if velocity > self.agent_velocity_threshold:
|
||||||
|
# suspected agent: apply markup to recover oracle revenue
|
||||||
|
session_factor *= self.agent_markup
|
||||||
|
|
||||||
|
# attention signal: higher view depth -> user interested -> can charge more
|
||||||
|
view_depth = sf.get('product_view_depth', 0)
|
||||||
|
if view_depth > 0:
|
||||||
|
attention_boost = 1 + self.beta_attention * np.log1p(view_depth)
|
||||||
|
session_factor *= attention_boost
|
||||||
|
|
||||||
|
# cart presence: if user has items in cart, slightly increase prices
|
||||||
|
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||||
|
if cart_to_view > 0.1:
|
||||||
|
session_factor *= (1 + 0.02) # small boost for conversion intent
|
||||||
|
|
||||||
|
prices = self.base_prices * elasticity_factor * session_factor
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
|
||||||
|
return prices
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSpecificSessionPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Session-aware pricer with product-specific demand signals.
|
||||||
|
|
||||||
|
Uses S_t to extract per-product interaction counts and adjusts pricing
|
||||||
|
for products the user has already viewed/hovered.
|
||||||
|
|
||||||
|
Strategy: products viewed multiple times = high interest -> price up
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
view_boost: float = 0.02,
|
||||||
|
max_view_boost: float = 0.15,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.view_boost = view_boost
|
||||||
|
self.max_view_boost = max_view_boost
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
self.product_ids = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
if 'elasticity' not in historical_data.columns or 'productId' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' and 'productId'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
self.product_ids = historical_data['productId'].values
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
base_prices = self.base_prices * (1 + self.alpha * np.abs(self.elasticity) * demand_dev)
|
||||||
|
|
||||||
|
# product-specific session adjustments
|
||||||
|
if not state_space.session_features.empty and state_space.product_ids is not None:
|
||||||
|
# extract product interaction counts from session metadata
|
||||||
|
# (this would require session features to include per-product signals)
|
||||||
|
# for now, use uniform boost as placeholder
|
||||||
|
# TODO: extend session feature extraction to include product-specific counts
|
||||||
|
pass
|
||||||
|
|
||||||
|
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
91
experiments/procesing/pricers/simple.py
Normal file
91
experiments/procesing/pricers/simple.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
class StaticPricer(PricingFunction):
|
||||||
|
"""Static pricing: always return fixed base prices"""
|
||||||
|
|
||||||
|
def __init__(self, base_prices: np.ndarray = None):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Extract base prices from historical data"""
|
||||||
|
if 'base_price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['base_price'].values
|
||||||
|
elif 'price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['price'].values
|
||||||
|
else:
|
||||||
|
raise ValueError("historical_data must contain 'base_price' or 'price' column")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Return static base prices regardless of state"""
|
||||||
|
if self.base_prices is None:
|
||||||
|
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||||
|
return self.base_prices.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class RandomPricer(PricingFunction):
|
||||||
|
"""Random pricing within bounds (for baseline comparison)"""
|
||||||
|
|
||||||
|
def __init__(self, price_min: float = 50.0, price_max: float = 500.0, seed: int = None):
|
||||||
|
self.price_min = price_min
|
||||||
|
self.price_max = price_max
|
||||||
|
self.seed = seed
|
||||||
|
self.n_products = None
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Learn number of products"""
|
||||||
|
self.n_products = len(historical_data)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate random prices"""
|
||||||
|
if self.n_products is None:
|
||||||
|
self.n_products = len(state_space.demand)
|
||||||
|
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleSurgePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Rule-based surge pricer adjusting prices via demand thresholds.
|
||||||
|
Logic: if demand > high_threshold -> surge, if demand < low_threshold -> discount.
|
||||||
|
Simpler and more controllable than curve fitting approaches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_prices: np.ndarray = None,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
self.high_threshold = high_threshold
|
||||||
|
self.low_threshold = low_threshold
|
||||||
|
self.surge_multiplier = surge_multiplier
|
||||||
|
self.discount_multiplier = discount_multiplier
|
||||||
|
|
||||||
|
def fit(self, market_data : pd.DataFrame):
|
||||||
|
"""Extract base prices from product catalog or historical averages"""
|
||||||
|
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||||
|
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
||||||
|
|
||||||
|
def predict(self) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on current demand using surge rules.
|
||||||
|
state_space.demand: demand counts per product
|
||||||
|
state_space.prices: current prices (fallback if base_prices not set)
|
||||||
|
"""
|
||||||
|
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99
|
||||||
|
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices)
|
||||||
|
new_prices = current_prices.copy()
|
||||||
|
|
||||||
|
high_mask = demand >= self.high_threshold
|
||||||
|
new_prices[high_mask] *= self.surge_multiplier
|
||||||
|
|
||||||
|
low_mask = demand <= self.low_threshold
|
||||||
|
new_prices[low_mask] *= self.discount_multiplier
|
||||||
|
|
||||||
|
return new_prices
|
||||||
@@ -35,8 +35,9 @@ from sklearn.base import BaseEstimator, TransformerMixin
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
from supabase import create_client, Client
|
from supabase import create_client, Client
|
||||||
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||||
@@ -79,18 +80,136 @@ class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
|||||||
class SimpleLinearPricingFunction(PricingFunction):
|
class SimpleLinearPricingFunction(PricingFunction):
|
||||||
def __init__(self, price_sensitivity: float = -0.1):
|
def __init__(self, price_sensitivity: float = -0.1):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.price_sensitivity = price_sensitivity # simple coefficient
|
self.price_sensitivity = price_sensitivity
|
||||||
|
|
||||||
def fit(self, historical_data):
|
def fit(self, historical_data):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def transform(self, state_space: StateSpace) -> np.ndarray:
|
def transform(self, state_space: StateSpace) -> np.ndarray:
|
||||||
# Simple linear adjustment: P_{t+1} = P_t + sensitivity * Q_t
|
new_prices = state_space.prices + self.price_sensitivity * state_space.demand
|
||||||
new_prices = state_space.prices + self.price_sensitivity * state_space.demand # this is not great
|
|
||||||
return np.maximum(new_prices, 0)
|
return np.maximum(new_prices, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricingFunction(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue-maximizing pricing using elasticity estimates.
|
||||||
|
|
||||||
|
For each product, optimal price P* maximizes R = P * Q(P)
|
||||||
|
where Q(P) follows power law: Q(P) = Q_0 * (P/P_0)^ε
|
||||||
|
|
||||||
|
Taking derivative dR/dP = 0 gives optimal markup:
|
||||||
|
P* = P_0 * (1 + 1/ε) if ε < -1 (elastic)
|
||||||
|
|
||||||
|
For inelastic demand (|ε| < 1), we apply bounded markup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
cost_floor: float = 0.5,
|
||||||
|
max_markup: float = 2.0,
|
||||||
|
min_markup: float = 1.0,
|
||||||
|
inelastic_markup: float = 1.3):
|
||||||
|
super().__init__()
|
||||||
|
self.cost_floor = cost_floor # prices as fraction of base
|
||||||
|
self.max_markup = max_markup # max price = base * max_markup
|
||||||
|
self.min_markup = min_markup # min price = base * min_markup
|
||||||
|
self.inelastic_markup = inelastic_markup # default for |ε| < 1
|
||||||
|
self.elasticity_map = {} # productId -> elasticity
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
elasticity_df: df with [productId, elasticity, std_error, n_obs]
|
||||||
|
"""
|
||||||
|
if elasticity_df is not None and not elasticity_df.empty:
|
||||||
|
self.elasticity_map = dict(zip(
|
||||||
|
elasticity_df['productId'],
|
||||||
|
elasticity_df['elasticity']
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
state_space: current state (prices = base prices)
|
||||||
|
product_ids: array of productIds aligned with state_space.prices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
optimized prices P_{t+1}
|
||||||
|
"""
|
||||||
|
base_prices = state_space.prices
|
||||||
|
|
||||||
|
if product_ids is None:
|
||||||
|
# fallback: use positional index as productId (not ideal)
|
||||||
|
product_ids = np.arange(len(base_prices))
|
||||||
|
|
||||||
|
new_prices = np.zeros_like(base_prices)
|
||||||
|
|
||||||
|
for i, (base_p, pid) in enumerate(zip(base_prices, product_ids)):
|
||||||
|
elasticity = self.elasticity_map.get(pid, 0.0)
|
||||||
|
|
||||||
|
if elasticity < -1: # elastic demand
|
||||||
|
# optimal markup: (1 + 1/ε)
|
||||||
|
markup = 1 + (1 / elasticity)
|
||||||
|
optimal_p = base_p * markup
|
||||||
|
elif elasticity > -1 and elasticity < 0: # inelastic
|
||||||
|
# conservative markup
|
||||||
|
optimal_p = base_p * self.inelastic_markup
|
||||||
|
else: # ε ≥ 0 (demand increases with price, or no data)
|
||||||
|
# no elasticity data or anomalous, keep base price
|
||||||
|
optimal_p = base_p
|
||||||
|
|
||||||
|
# apply bounds
|
||||||
|
optimal_p = np.clip(
|
||||||
|
optimal_p,
|
||||||
|
base_p * self.min_markup,
|
||||||
|
base_p * self.max_markup
|
||||||
|
)
|
||||||
|
optimal_p = max(optimal_p, self.cost_floor)
|
||||||
|
|
||||||
|
new_prices[i] = optimal_p
|
||||||
|
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualElasticityPricing(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue optimization with contextual adjustments based on session features.
|
||||||
|
|
||||||
|
Combines elasticity-based pricing with surge/demand-based multipliers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_pricer: ElasticityBasedPricingFunction = None,
|
||||||
|
demand_sensitivity: float = 0.1,
|
||||||
|
surge_threshold: float = 0.7):
|
||||||
|
super().__init__()
|
||||||
|
self.base_pricer = base_pricer or ElasticityBasedPricingFunction()
|
||||||
|
self.demand_sensitivity = demand_sensitivity
|
||||||
|
self.surge_threshold = surge_threshold
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
self.base_pricer.fit(elasticity_df)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
# get base optimal prices from elasticity
|
||||||
|
base_optimal = self.base_pricer.transform(state_space, product_ids)
|
||||||
|
|
||||||
|
# compute surge multiplier from demand
|
||||||
|
if len(state_space.demand) > 0:
|
||||||
|
demand_normalized = state_space.demand / (state_space.demand.max() + 1e-8)
|
||||||
|
surge_multiplier = 1 + self.demand_sensitivity * np.maximum(
|
||||||
|
demand_normalized - self.surge_threshold, 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
surge_multiplier = np.ones_like(base_optimal)
|
||||||
|
|
||||||
|
return base_optimal * surge_multiplier
|
||||||
|
|
||||||
# Example usage:
|
# Example usage:
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
||||||
|
|
||||||
store_mode = 'hotel'
|
store_mode = 'hotel'
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
interaction_data = interaction_pipeline.fit_transform(None)
|
||||||
price_data = price_data_pipeline.fit_transform(None)
|
price_data = price_data_pipeline.fit_transform(None)
|
||||||
|
|||||||
5
experiments/procesing/providers/__init__.py
Executable file
5
experiments/procesing/providers/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.providers.supabase import SupabaseProvider
|
||||||
|
from procesing.providers.backend import BackendAPIProvider
|
||||||
|
|
||||||
|
__all__ = ['DataProvider', 'SupabaseProvider', 'BackendAPIProvider']
|
||||||
19
experiments/procesing/providers/backend.py
Executable file
19
experiments/procesing/providers/backend.py
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class BackendAPIProvider(DataProvider):
|
||||||
|
"""Concrete backend API implementation"""
|
||||||
|
def __init__(self, backend_url: str = None):
|
||||||
|
self.backend_url = backend_url or os.getenv("BACKEND_URL", "http://localhost:5000")
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
resp = requests.get(f"{self.backend_url}/api/kafka/dump?topic={topic}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if not data.get('success') or not data.get('data'):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return pd.DataFrame(data['data'])
|
||||||
21
experiments/procesing/providers/base.py
Executable file
21
experiments/procesing/providers/base.py
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
class DataProvider(ABC):
|
||||||
|
"""Abstract interface for data access, enables DI and testing"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
"""Fetch product catalog for given store mode"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
"""Fetch experiment metadata for given IDs"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
"""Fetch data from Kafka topic via backend API"""
|
||||||
|
pass
|
||||||
42
experiments/procesing/providers/supabase.py
Executable file
42
experiments/procesing/providers/supabase.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
class SupabaseProvider(DataProvider):
|
||||||
|
"""Concrete Supabase + backend API implementation"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
supabase_url: str = None,
|
||||||
|
supabase_key: str = None,):
|
||||||
|
load_dotenv()
|
||||||
|
self.supabase_url = supabase_url or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
self.supabase_key = supabase_key or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||||
|
self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||||
|
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||||
|
if not resp.data:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(resp.data)
|
||||||
|
# normalize type column: hotel has room_type, airline has flight_type
|
||||||
|
if 'room_type' in df.columns:
|
||||||
|
df['product_type'] = df['room_type']
|
||||||
|
elif 'flight_type' in df.columns:
|
||||||
|
df['product_type'] = df['flight_type']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if not experiment_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
resp = self.supabase.table('experiments').select(
|
||||||
|
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, '
|
||||||
|
'task:tasks(task_name, task_description, task_def_of_done)'
|
||||||
|
).in_('id', experiment_ids).execute()
|
||||||
|
|
||||||
|
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||||
39
experiments/procesing/steps/__init__.py
Executable file
39
experiments/procesing/steps/__init__.py
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||||
|
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||||
|
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||||
|
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||||
|
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||||
|
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||||
|
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||||
|
from procesing.steps.session import (
|
||||||
|
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||||
|
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||||
|
_extract_features_for_session
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseContextStep',
|
||||||
|
'FetchInteractionsStep',
|
||||||
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'JoinProductFeaturesStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'AugmentInteractionsStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'ExtractSessionFeaturesStep',
|
||||||
|
'JoinLabelsStep',
|
||||||
|
'ValidateDataStep',
|
||||||
|
'TemporalFeatureStep',
|
||||||
|
'BehavioralFeatureStep',
|
||||||
|
'ProductFeatureStep',
|
||||||
|
'UserAgentFeatureStep',
|
||||||
|
'_extract_features_for_session',
|
||||||
|
]
|
||||||
140
experiments/procesing/steps/augment.py
Executable file
140
experiments/procesing/steps/augment.py
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentInteractionsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Consolidated step: create price buckets, augment event names, join experiments.
|
||||||
|
Input: (interactions_df, price_logs_df)
|
||||||
|
Output: enriched interactions_df
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
interactions_df, price_logs_df = data
|
||||||
|
|
||||||
|
if interactions_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Step 1: Create price buckets
|
||||||
|
interactions_df = self._create_price_buckets(interactions_df)
|
||||||
|
|
||||||
|
# Step 2: Augment event names
|
||||||
|
interactions_df = self._augment_event_names(interactions_df)
|
||||||
|
|
||||||
|
# Step 3: Join experiments (optional)
|
||||||
|
if 'experimentId' in interactions_df.columns:
|
||||||
|
interactions_df = self._join_experiments(interactions_df)
|
||||||
|
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
def _create_price_buckets(self, df: pd.DataFrame):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
if 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _augment_event_names(self, df: pd.DataFrame):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _join_experiments(self, df: pd.DataFrame):
|
||||||
|
"""Join experiment metadata if experimentId present"""
|
||||||
|
exp_ids = df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return df
|
||||||
|
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids)
|
||||||
|
if experiments_df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df.merge(
|
||||||
|
experiments_df,
|
||||||
|
left_on='experimentId',
|
||||||
|
right_on='id',
|
||||||
|
how='left',
|
||||||
|
suffixes=('', '_exp')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePriceBucketsStep(BaseContextStep):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty or 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentEventNamesStep(BaseContextStep):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
32
experiments/procesing/steps/base.py
Executable file
32
experiments/procesing/steps/base.py
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||||
|
"""
|
||||||
|
Base for all pipeline steps.
|
||||||
|
Each step is stateless, context-driven, and performs ONE transformation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, context: PipelineContext):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def fit(self, X=None, y=None):
|
||||||
|
"""Most steps don't need training"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def transform(self, X) -> Any:
|
||||||
|
"""Transform input using context. Must be implemented by subclass."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_params(self, deep=True):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
return {'context': self.context}
|
||||||
|
|
||||||
|
def set_params(self, **params):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
if 'context' in params:
|
||||||
|
self.context = params['context']
|
||||||
|
return self
|
||||||
34
experiments/procesing/steps/chunk.py
Executable file
34
experiments/procesing/steps/chunk.py
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ChunkByTimeWindowStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Chunk dataframe into time windows.
|
||||||
|
Returns list of dicts with window metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
window_size = self.context.window_size
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values(ts_col)
|
||||||
|
df['_window'] = df[ts_col].dt.floor(window_size)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
||||||
|
chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_start + pd.Timedelta(window_size),
|
||||||
|
'window_idx': idx,
|
||||||
|
'data': group.drop(columns=['_window'])
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
61
experiments/procesing/steps/demand.py
Executable file
61
experiments/procesing/steps/demand.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ComputeDemandStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Compute demand vector for a single time window or dataframe.
|
||||||
|
Input: single chunk dict OR raw dataframe
|
||||||
|
Output: demand dataframe with [productId, demand_score]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, chunk):
|
||||||
|
# handle both chunk dict and raw dataframe
|
||||||
|
if isinstance(chunk, dict):
|
||||||
|
interactions = chunk['data']
|
||||||
|
window_meta = {k: v for k, v in chunk.items() if k != 'data'}
|
||||||
|
else:
|
||||||
|
interactions = chunk
|
||||||
|
window_meta = {}
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
# apply filters if configured
|
||||||
|
session_filter = self.context.config.get('session_filter')
|
||||||
|
experiment_filter = self.context.config.get('experiment_filter')
|
||||||
|
|
||||||
|
if session_filter and 'sessionId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['sessionId'] == session_filter]
|
||||||
|
if experiment_filter and 'experimentId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['experimentId'] == experiment_filter]
|
||||||
|
|
||||||
|
interactions_with_products = interactions.dropna(subset=['productId'])
|
||||||
|
|
||||||
|
if interactions_with_products.empty:
|
||||||
|
demand_df = pd.DataFrame({
|
||||||
|
'productId': unique_products,
|
||||||
|
'demand_score': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# crosstab for simple demand count
|
||||||
|
demand_df = pd.crosstab(
|
||||||
|
interactions_with_products['productId'],
|
||||||
|
'count'
|
||||||
|
).reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
demand_df.columns = ['productId', 'demand_score']
|
||||||
|
|
||||||
|
# attach window metadata if present
|
||||||
|
if window_meta:
|
||||||
|
return {**window_meta, 'demand_vector': demand_df}
|
||||||
|
return demand_df
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeDemandForChunksStep(BaseContextStep):
|
||||||
|
"""Apply ComputeDemandStep to list of chunks"""
|
||||||
|
|
||||||
|
def transform(self, chunks: list):
|
||||||
|
if not chunks:
|
||||||
|
return []
|
||||||
|
|
||||||
|
demand_step = ComputeDemandStep(self.context)
|
||||||
|
return [demand_step.transform(chunk) for chunk in chunks]
|
||||||
42
experiments/procesing/steps/elasticity.py
Executable file
42
experiments/procesing/steps/elasticity.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, List
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class AggregatePriceLogsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Aggregate price logs into time windows using VECTORIZED operations.
|
||||||
|
Input: price_logs_df
|
||||||
|
Output: DataFrame with columns [productId, price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, price_logs_df: pd.DataFrame):
|
||||||
|
if price_logs_df.empty:
|
||||||
|
return pd.DataFrame(columns=['productId', 'price'])
|
||||||
|
|
||||||
|
df = price_logs_df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values([ts_col, 'productId'])
|
||||||
|
products = self.context.products
|
||||||
|
# get base price from metadata if available 1) read the metadata col as json and get the base_price
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: row['metadata'].get('base_price', 0) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
df_indexed = df.set_index(ts_col)
|
||||||
|
# we return a df of average price per product over the entire period
|
||||||
|
# TODO: maybe consider different opration to handle price aggregation over time
|
||||||
|
avg_prices = df_indexed.groupby('productId')['price'].mean().reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
avg_prices.columns = ['productId', 'price']
|
||||||
|
# fill 0s with base_price from products
|
||||||
|
base_price_map = products.set_index('id')['base_price'].to_dict()
|
||||||
|
return avg_prices
|
||||||
81
experiments/procesing/steps/fetch.py
Executable file
81
experiments/procesing/steps/fetch.py
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class FetchInteractionsStep(BaseContextStep):
|
||||||
|
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Explode metadata JSON column
|
||||||
|
if 'metadata' in df.columns:
|
||||||
|
df = df.join(
|
||||||
|
pd.json_normalize(df.pop('metadata'), sep='.').add_prefix('metadata_')
|
||||||
|
)
|
||||||
|
|
||||||
|
df = df.dropna(subset=['eventName'])
|
||||||
|
# drop all where page has /admin/
|
||||||
|
df = df[~df['page'].str.contains('/admin/', na=False)]
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Remap dateIndex if present
|
||||||
|
if 'metadata_dateIndex' in df.columns:
|
||||||
|
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchPriceLogsStep(BaseContextStep):
|
||||||
|
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchExperimentsStep(BaseContextStep):
|
||||||
|
"""Fetch experiment metadata for given interaction data"""
|
||||||
|
|
||||||
|
def transform(self, interactions_df: pd.DataFrame):
|
||||||
|
if interactions_df.empty or 'experimentId' not in interactions_df.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
exp_ids = interactions_df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return self.context.provider.fetch_experiments(exp_ids)
|
||||||
58
experiments/procesing/steps/join.py
Executable file
58
experiments/procesing/steps/join.py
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class JoinExperimentsStep(BaseContextStep):
|
||||||
|
"""Join experiment metadata to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, experiments_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
interactions_df, experiments_df = data
|
||||||
|
|
||||||
|
if experiments_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Flatten nested task field if present
|
||||||
|
if 'task' in experiments_df.columns and experiments_df['task'].notnull().any():
|
||||||
|
task_norm = pd.json_normalize(experiments_df['task'].dropna())
|
||||||
|
task_norm.index = experiments_df[experiments_df['task'].notnull()].index
|
||||||
|
experiments_df = experiments_df.drop('task', axis=1).join(task_norm, rsuffix='_task')
|
||||||
|
|
||||||
|
# Rename for clarity
|
||||||
|
experiments_df = experiments_df.rename(columns={
|
||||||
|
'id': 'experimentId',
|
||||||
|
'subject_name': 'exp_subject',
|
||||||
|
'xp_human_only': 'exp_human_only',
|
||||||
|
'xp_market_mode': 'exp_market_mode',
|
||||||
|
'xp_task_id': 'exp_task_id'
|
||||||
|
})
|
||||||
|
|
||||||
|
return interactions_df.merge(experiments_df, on='experimentId', how='left')
|
||||||
|
|
||||||
|
class JoinProductFeaturesStep(BaseContextStep):
|
||||||
|
"""Join product features to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, products_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
demand_df, price_df = data
|
||||||
|
|
||||||
|
# get base prices from products if available
|
||||||
|
products = self.context.products
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: float(row['metadata'].get('base_price', 0.0)) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
products = products[['id', 'base_price']].rename(columns={'id': 'productId'})
|
||||||
|
|
||||||
|
if price_df.empty:
|
||||||
|
return demand_df
|
||||||
|
return demand_df.merge(price_df, on='productId', how='left').merge(products, on='productId', how='left')
|
||||||
55
experiments/procesing/steps/pricing.py
Executable file
55
experiments/procesing/steps/pricing.py
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from procesing.pricers.simple import StaticPricer
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.pricers import ElasticityBasedPricer
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self,
|
||||||
|
last_action : str,
|
||||||
|
last_productId : str,
|
||||||
|
last_price : float,
|
||||||
|
session_features : np.ndarray
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FitPricingFunctionStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Fit pricing function using data.
|
||||||
|
Input: pricing_data
|
||||||
|
Output: fitted pricing function instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, pricing_data: pd.DataFrame):
|
||||||
|
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||||
|
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||||
|
|
||||||
|
pricer = pricing_class(**pricing_params)
|
||||||
|
pricer.fit(pricing_data)
|
||||||
|
|
||||||
|
return pricer
|
||||||
|
|
||||||
|
|
||||||
|
class PredictPricesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Predict optimal prices using fitted pricing function.
|
||||||
|
Input: (pricer, state_space)
|
||||||
|
Output: prices_df [productId, predicted_price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
pricer, state_space = data
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
product_ids = products['id'].values
|
||||||
|
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
return pd.DataFrame({
|
||||||
|
'productId': product_ids,
|
||||||
|
'predicted_price': predicted_prices
|
||||||
|
})
|
||||||
261
experiments/procesing/steps/session.py
Normal file
261
experiments/procesing/steps/session.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
Session feature extraction for ML training pipeline.
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
EVENT_CATS = {
|
||||||
|
'page_view': ['page_view'],
|
||||||
|
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||||
|
'cart_add': ['add_item_to_cart'],
|
||||||
|
'purchase': ['purchase', 'checkout_complete'],
|
||||||
|
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||||
|
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||||
|
}
|
||||||
|
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||||
|
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||||
|
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||||
|
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_browser(s: str) -> str:
|
||||||
|
if pd.isna(s): return 'Unknown'
|
||||||
|
for name, pat in BROWSER_PATTERNS:
|
||||||
|
if re.search(pat, s): return name
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||||
|
|
||||||
|
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||||
|
super().__init__(context)
|
||||||
|
self.timeout_sec = timeout_sec
|
||||||
|
self.velocity_window = velocity_window
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'ts' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||||
|
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||||
|
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||||
|
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
session_duration_sec=('active_diff', 'sum'),
|
||||||
|
total_interactions=('sessionId', 'count'),
|
||||||
|
avg_time_between_events=('time_diff', 'mean'),
|
||||||
|
std_time_between_events=('time_diff', 'std'),
|
||||||
|
min_time_between_events=('time_diff', 'min'),
|
||||||
|
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||||
|
).reset_index()
|
||||||
|
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||||
|
agg['interaction_velocity'] = np.where(
|
||||||
|
agg['session_duration_sec'] > 0,
|
||||||
|
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||||
|
|
||||||
|
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||||
|
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||||
|
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||||
|
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class BehavioralFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized event counts and ratios per session."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'eventName' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
for cat, events in EVENT_CATS.items():
|
||||||
|
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||||
|
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||||
|
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||||
|
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||||
|
hover_events=('is_hover', 'sum'),
|
||||||
|
# filter_events=('is_filter', 'sum'),
|
||||||
|
).reset_index()
|
||||||
|
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||||
|
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||||
|
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||||
|
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||||
|
|
||||||
|
prod_df = df[df['productId'].notna()]
|
||||||
|
if prod_df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||||
|
|
||||||
|
agg = prod_df.groupby('sessionId').agg(
|
||||||
|
unique_products_viewed=('productId', 'nunique'),
|
||||||
|
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||||
|
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||||
|
max_price_seen=('price_seen', 'max'),
|
||||||
|
).reset_index()
|
||||||
|
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class UserAgentFeatureStep(BaseContextStep):
|
||||||
|
"""Parse userAgent into bot-detection signals."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'userAgent' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||||
|
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||||
|
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||||
|
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||||
|
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||||
|
Input: interactions_df
|
||||||
|
Output: session-level feature matrix
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
if X.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = X.copy()
|
||||||
|
|
||||||
|
# run all feature steps and merge on sessionId
|
||||||
|
temporal = TemporalFeatureStep(self.context).transform(df)
|
||||||
|
behavioral = BehavioralFeatureStep(self.context).transform(df)
|
||||||
|
product = ProductFeatureStep(self.context).transform(df)
|
||||||
|
ua = UserAgentFeatureStep(self.context).transform(df)
|
||||||
|
|
||||||
|
result = temporal
|
||||||
|
for other in [behavioral, product, ua]:
|
||||||
|
if not other.empty and 'sessionId' in other.columns:
|
||||||
|
result = result.merge(other, on='sessionId', how='left')
|
||||||
|
|
||||||
|
# carry forward experimentId for label joining
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
exp_map = df.groupby('sessionId')['experimentId'].first()
|
||||||
|
result = result.merge(exp_map, on='sessionId', how='left')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class JoinLabelsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Join experiment labels to session features.
|
||||||
|
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||||
|
Output: labeled feature matrix with is_agent column
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X : tuple) -> pd.DataFrame:
|
||||||
|
data = X;
|
||||||
|
if isinstance(data, tuple):
|
||||||
|
features_df, experiments_df = data
|
||||||
|
else:
|
||||||
|
features_df = data
|
||||||
|
if 'experimentId' not in features_df.columns:
|
||||||
|
return features_df
|
||||||
|
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||||
|
|
||||||
|
if features_df.empty:
|
||||||
|
return features_df
|
||||||
|
if experiments_df.empty:
|
||||||
|
features_df['is_agent'] = np.nan
|
||||||
|
return features_df
|
||||||
|
|
||||||
|
exp = experiments_df.copy()
|
||||||
|
if 'id' in exp.columns:
|
||||||
|
exp = exp.rename(columns={'id': 'experimentId'})
|
||||||
|
if 'xp_human_only' in exp.columns:
|
||||||
|
exp['is_agent'] = ~exp['xp_human_only']
|
||||||
|
|
||||||
|
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||||
|
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateDataStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Data quality checks before training.
|
||||||
|
Input: df
|
||||||
|
Output: df (unchanged, but logs validation report to context)
|
||||||
|
"""
|
||||||
|
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||||
|
if df.empty:
|
||||||
|
report['status'] = 'empty'
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||||
|
if missing:
|
||||||
|
report['status'] = 'invalid'
|
||||||
|
report['missing_cols'] = missing
|
||||||
|
|
||||||
|
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||||
|
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||||
|
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# legacy compat - kept for backwards compatibility with existing code
|
||||||
|
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||||
|
"""Single-session feature extraction (legacy interface)."""
|
||||||
|
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||||
|
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||||
|
'session_duration_sec', 'interaction_velocity',
|
||||||
|
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||||
|
if session_df.empty:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
session_df = session_df.copy()
|
||||||
|
if 'sessionId' not in session_df.columns:
|
||||||
|
session_df['sessionId'] = 'tmp'
|
||||||
|
|
||||||
|
# use a dummy context for the steps
|
||||||
|
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||||
|
ctx = DummyCtx()
|
||||||
|
|
||||||
|
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||||
|
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||||
|
p = ProductFeatureStep(ctx).transform(session_df)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for df in [t, b, p]:
|
||||||
|
if not df.empty:
|
||||||
|
for col in df.columns:
|
||||||
|
if col != 'sessionId':
|
||||||
|
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||||
|
|
||||||
|
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||||
|
for old, new in remap.items():
|
||||||
|
if old in result:
|
||||||
|
result[new] = result.pop(old)
|
||||||
|
return result
|
||||||
0
experiments/procesing/tests/__init__.py
Normal file
0
experiments/procesing/tests/__init__.py
Normal file
281
experiments/procesing/tests/conftest.py
Normal file
281
experiments/procesing/tests/conftest.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
|
||||||
|
|
||||||
|
class MockProvider(DataProvider):
|
||||||
|
"""Mock provider for testing, holds in-memory fixtures"""
|
||||||
|
|
||||||
|
def __init__(self, products_df=None, experiments_df=None, kafka_data=None):
|
||||||
|
self._products = products_df if products_df is not None else pd.DataFrame()
|
||||||
|
self._experiments = experiments_df if experiments_df is not None else pd.DataFrame()
|
||||||
|
self._kafka_data = kafka_data if kafka_data is not None else {}
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
return self._products.copy()
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if self._experiments.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
return self._experiments[
|
||||||
|
self._experiments['id'].isin(experiment_ids)
|
||||||
|
].copy()
|
||||||
|
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
return self._kafka_data.get(topic, pd.DataFrame()).copy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_products():
|
||||||
|
"""Standard product catalog fixture with realistic IDs from test data"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': [
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
],
|
||||||
|
'name': ['Junior Suite', 'Superior Room', 'Deluxe Room'],
|
||||||
|
'base_price': [200.0, 150.0, 180.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for interactions, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 203, 'timestamp': 1764102082676,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'learn_more_about_item',
|
||||||
|
'page': '/hotel/products/d018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'type': 'hotel', 'dateIndex': 1, 'roomType': 'Junior Suite'},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:22.674Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 204, 'timestamp': 1764102086982,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'page_view',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': None,
|
||||||
|
'metadata': {'referrer': ''},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:26.947Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 205, 'timestamp': 1764102091825,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'hover_over_title',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'Superior Room', 'dateIndex': 1, 'dwellTime': 1200},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:31.823Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 206, 'timestamp': 1764102094193,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1307},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:34.191Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 207, 'timestamp': 1764102101970,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1201},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:41.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions(mock_interactions_raw_kafka):
|
||||||
|
"""Processed interaction DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
records = [msg['value']['payload'] for msg in mock_interactions_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for price logs, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 32, 'timestamp': 1764104757969,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 162.47,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 33, 'timestamp': 1764104757995,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 743.49,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.993Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 34, 'timestamp': 1764104758011,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 163.87,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.009Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 35, 'timestamp': 1764104758050,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 397.46,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.049Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 36, 'timestamp': 1764104768865,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 401.66,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:06:08.864Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs(mock_price_logs_raw_kafka):
|
||||||
|
"""Processed price logs DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
# extract payloads and flatten
|
||||||
|
records = [msg['value']['payload'] for msg in mock_price_logs_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_experiments():
|
||||||
|
"""Standard experiment metadata fixture matching Supabase schema"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': ['53aefd07-f66a-4d7f-ba8b-7ea1fc562d35', 'bbbbcccc-dddd-eeee-ffff-000011112222'],
|
||||||
|
'created_at': pd.to_datetime(['2025-11-25T20:00:00Z', '2025-11-26T10:00:00Z']),
|
||||||
|
'subject_name': ['Session A', 'Session B'],
|
||||||
|
'xp_human_only': [True, False],
|
||||||
|
'xp_market_mode': ['hotel', 'airline'],
|
||||||
|
'xp_task_id': [None, None]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider(mock_products, mock_experiments, mock_interactions, mock_price_logs):
|
||||||
|
"""Fully configured mock provider"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=mock_products,
|
||||||
|
experiments_df=mock_experiments,
|
||||||
|
kafka_data={
|
||||||
|
'user-interactions': mock_interactions,
|
||||||
|
'price-logs': mock_price_logs
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipeline_context(mock_provider):
|
||||||
|
"""Standard pipeline context for testing"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=mock_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s',
|
||||||
|
n_price_buckets=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_provider():
|
||||||
|
"""Provider with no data, for edge case testing"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=pd.DataFrame(columns=['id', 'name', 'base_price']),
|
||||||
|
experiments_df=pd.DataFrame(columns=['id', 'created_at', 'subject_name', 'xp_human_only', 'xp_market_mode', 'xp_task_id']),
|
||||||
|
kafka_data={'user-interactions': pd.DataFrame(), 'price-logs': pd.DataFrame()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_context(empty_provider):
|
||||||
|
"""Context with empty provider"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=empty_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_interactions(mock_interactions):
|
||||||
|
"""Enriched interaction data for session feature extraction tests"""
|
||||||
|
df = mock_interactions.copy()
|
||||||
|
df['userAgent'] = ['Mozilla/5.0 Chrome/120', 'Mozilla/5.0 Chrome/120',
|
||||||
|
'HeadlessChrome/120', 'HeadlessChrome/120', 'HeadlessChrome/120']
|
||||||
|
df['metadata_base_price'] = [None, None, 150.0, 150.0, 200.0]
|
||||||
|
return df
|
||||||
45
experiments/procesing/tests/test_augement.py
Normal file
45
experiments/procesing/tests/test_augement.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bucketing(pipeline_context):
|
||||||
|
step = CreatePriceBucketsStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal price data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'metadata_price': random.sample(range(10, 1000), 100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
# test if is categorical
|
||||||
|
assert isinstance(result['price_bucket'].dtype, pd.CategoricalDtype)
|
||||||
|
assert result['price_bucket'].nunique() == 3 # as per context config
|
||||||
|
# distribution check
|
||||||
|
counts = result['price_bucket'].value_counts()
|
||||||
|
assert all(counts > 0)
|
||||||
|
assert counts.max() - counts.min() <= 10 # roughly equal distribution for 100 samples
|
||||||
|
# Test with empty DataFrame
|
||||||
|
df = pd.DataFrame()
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_names(pipeline_context):
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'eventName': ['click', 'view', 'purchase'],
|
||||||
|
'productId': ['prod_1', 'prod_2', None],
|
||||||
|
'price_bucket': ['PB_1', None, 'PB_3']
|
||||||
|
})
|
||||||
|
step = AugmentEventNamesStep(context=pipeline_context)
|
||||||
|
result = step.transform(df)
|
||||||
|
expected_event_names = [
|
||||||
|
'click_prod_1@PB_1',
|
||||||
|
'view',
|
||||||
|
'purchase'
|
||||||
|
]
|
||||||
|
assert result['eventName'].tolist() == expected_event_names
|
||||||
49
experiments/procesing/tests/test_demand.py
Normal file
49
experiments/procesing/tests/test_demand.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
ComputeDemandStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compute_demand(pipeline_context):
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
assert all(result['demand_score'] > 100/3 -10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_demand_skewed(pipeline_context):
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], weights=[0.7, 0.2, 0.1], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
# test for skewness
|
||||||
|
scores = result.set_index('productId')['demand_score'].to_dict()
|
||||||
|
assert scores['d018efc1-25e9-4284-b276-80386e048b25'] > \
|
||||||
|
scores['51266ddb-5b07-47b7-89ee-5b5cae94bb11'] > \
|
||||||
|
scores['2cd7f756-fc65-4ba0-ab01-74521c1fff43']
|
||||||
51
experiments/procesing/tests/test_fetch.py
Normal file
51
experiments/procesing/tests/test_fetch.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_interactions_data(pipeline_context):
|
||||||
|
step = FetchInteractionsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"eventName",
|
||||||
|
"dateIndex",
|
||||||
|
"experimentId",
|
||||||
|
"storeMode",
|
||||||
|
"metadata_elementText"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
|
||||||
|
def test_fetch_price_logs(pipeline_context):
|
||||||
|
step = FetchPriceLogsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"price",
|
||||||
|
"productId"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
prices = data['price'].to_list()
|
||||||
|
assert min(prices) >= 0
|
||||||
|
assert max(prices) <= 9999
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiments_fetching(pipeline_context):
|
||||||
|
interactions = FetchInteractionsStep(pipeline_context).transform(None)
|
||||||
|
assert interactions is not None
|
||||||
|
experiments = FetchExperimentsStep(pipeline_context)
|
||||||
|
experiment_data = experiments.transform(interactions)
|
||||||
|
assert experiment_data is not None
|
||||||
|
assert isinstance(experiment_data, pd.DataFrame)
|
||||||
|
assert not experiment_data.empty
|
||||||
|
assert 'id' in experiment_data.columns
|
||||||
|
assert len(experiment_data) == 2
|
||||||
|
assert '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35' in experiment_data['id'].values
|
||||||
87
experiments/procesing/tests/test_pricing.py
Normal file
87
experiments/procesing/tests/test_pricing.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit StaticPricer
|
||||||
|
pricer = StaticPricer()
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices match base prices
|
||||||
|
expected_prices = historical_data['base_price'].values
|
||||||
|
assert all(predicted_prices == expected_prices), "Predicted prices do not match base prices"
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit RandomPricer
|
||||||
|
pricer = RandomPricer(price_min=50.0, price_max=250.0, seed=42)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 250.0, "Predicted prices are above maximum bound"
|
||||||
|
# distribution check (not so strict)
|
||||||
|
assert len(set(predicted_prices)) > 1, "Predicted prices are not varied enough"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
def test_elasticity_based_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'productId': [1, 2, 3],
|
||||||
|
'elasticity': [-1.5, -0.5, -2.0],
|
||||||
|
'base_price': [100.0, 150.0, 200.0],
|
||||||
|
'mean_demand': [10, 20, 15]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit ElasticityBasedPricer
|
||||||
|
pricer = ElasticityBasedPricer(alpha=0.1, price_floor=50.0, price_ceil=300.0)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Create a mock state space with demand deviations
|
||||||
|
class MockStateSpace:
|
||||||
|
def __init__(self, demand):
|
||||||
|
self.demand = demand
|
||||||
|
|
||||||
|
# Simulate demand higher than mean for all products
|
||||||
|
state_space = MockStateSpace(demand=[15, 25, 20])
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 300.0, "Predicted prices are above maximum bound"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
# now we gotta check semantic validity
|
||||||
|
# since demand is higher than mean, prices should generally increase
|
||||||
|
for i, row in historical_data.iterrows():
|
||||||
|
base_price = row['base_price']
|
||||||
|
elasticity = row['elasticity']
|
||||||
|
expected_increase = base_price * (1 + 0.1 * abs(elasticity) * ((state_space.demand[i] - row['mean_demand']) / row['mean_demand']))
|
||||||
|
assert predicted_prices[i] >= base_price, f"Predicted price for product {row['productId']} did not increase as expected"
|
||||||
|
assert abs(predicted_prices[i] - expected_increase) < 1e-5, f"Predicted price for product {row['productId']} does not match expected calculation within 1e-5 tolerance"
|
||||||
8
experiments/pytest.ini
Normal file
8
experiments/pytest.ini
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = procesing/tests agents
|
||||||
|
python_files = test*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
180
lib/model_registry.py
Executable file
180
lib/model_registry.py
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
import redis
|
||||||
|
import pickle
|
||||||
|
import json
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ModelRegistry:
|
||||||
|
"""
|
||||||
|
Lightweight model registry using Redis for storing pricing models and elasticity data.
|
||||||
|
Models are serialized using pickle, metadata stored as JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, redis_host: str = None, redis_port: int = None):
|
||||||
|
host = redis_host or os.getenv('REDIS_HOST', 'localhost')
|
||||||
|
port = redis_port or int(os.getenv('REDIS_PORT', '6378'))
|
||||||
|
|
||||||
|
self.redis_client = redis.Redis(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
db=0,
|
||||||
|
decode_responses=False
|
||||||
|
)
|
||||||
|
self.metadata_prefix = "model:meta:"
|
||||||
|
self.data_prefix = "model:data:"
|
||||||
|
self.elasticity_prefix = "elasticity:"
|
||||||
|
self.prices_prefix = "prices:"
|
||||||
|
|
||||||
|
def publish_elasticity(self,
|
||||||
|
elasticity_df: pd.DataFrame,
|
||||||
|
model_name: str = 'latest',
|
||||||
|
metadata: Optional[Dict[str, Any]] = None):
|
||||||
|
"""
|
||||||
|
Store elasticity estimates in registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
elasticity_df: df with [productId, elasticity, std_error, n_obs]
|
||||||
|
model_name: identifier for this elasticity snapshot
|
||||||
|
metadata: additional info (timestamp, window_size, etc)
|
||||||
|
"""
|
||||||
|
key = f"{self.elasticity_prefix}{model_name}"
|
||||||
|
|
||||||
|
# serialize dataframe as JSON
|
||||||
|
data_json = elasticity_df.to_json(orient='records')
|
||||||
|
|
||||||
|
# store data
|
||||||
|
self.redis_client.set(key, data_json)
|
||||||
|
|
||||||
|
# store metadata
|
||||||
|
meta = metadata or {}
|
||||||
|
meta.update({
|
||||||
|
'n_products': len(elasticity_df),
|
||||||
|
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
||||||
|
'model_type': 'elasticity_snapshot'
|
||||||
|
})
|
||||||
|
|
||||||
|
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||||
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
|
log.info(f"Published elasticity model '{model_name}' with {len(elasticity_df)} products")
|
||||||
|
|
||||||
|
def get_elasticity(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||||
|
"""Retrieve elasticity estimates from registry."""
|
||||||
|
key = f"{self.elasticity_prefix}{model_name}"
|
||||||
|
data_json = self.redis_client.get(key)
|
||||||
|
|
||||||
|
if data_json is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# decode bytes to string if needed
|
||||||
|
if isinstance(data_json, bytes):
|
||||||
|
data_json = data_json.decode('utf-8')
|
||||||
|
|
||||||
|
return pd.read_json(data_json, orient='records')
|
||||||
|
|
||||||
|
def publish_pricing_model(self,
|
||||||
|
pricing_function,
|
||||||
|
model_name: str = 'latest',
|
||||||
|
metadata: Optional[Dict[str, Any]] = None):
|
||||||
|
"""
|
||||||
|
Store a fitted pricing function object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pricing_function: fitted PricingFunction instance
|
||||||
|
model_name: identifier
|
||||||
|
metadata: additional info
|
||||||
|
"""
|
||||||
|
key = f"{self.data_prefix}{model_name}"
|
||||||
|
|
||||||
|
# serialize object
|
||||||
|
model_bytes = pickle.dumps(pricing_function)
|
||||||
|
self.redis_client.set(key, model_bytes)
|
||||||
|
|
||||||
|
# store metadata
|
||||||
|
meta = metadata or {}
|
||||||
|
meta.update({
|
||||||
|
'model_class': pricing_function.__class__.__name__,
|
||||||
|
'model_type': 'pricing_function'
|
||||||
|
})
|
||||||
|
|
||||||
|
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||||
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
|
log.info(f"Published pricing model '{model_name}' ({meta['model_class']})")
|
||||||
|
|
||||||
|
def get_pricing_model(self, model_name: str = 'latest'):
|
||||||
|
"""Retrieve a pricing function from registry."""
|
||||||
|
key = f"{self.data_prefix}{model_name}"
|
||||||
|
model_bytes = self.redis_client.get(key)
|
||||||
|
|
||||||
|
if model_bytes is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return pickle.loads(model_bytes)
|
||||||
|
|
||||||
|
def list_models(self) -> Dict[str, Any]:
|
||||||
|
"""List all registered models with metadata."""
|
||||||
|
models = {}
|
||||||
|
|
||||||
|
for key in self.redis_client.scan_iter(f"{self.metadata_prefix}*"):
|
||||||
|
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
||||||
|
model_name = key_str.replace(self.metadata_prefix, '')
|
||||||
|
meta_json = self.redis_client.get(key)
|
||||||
|
|
||||||
|
if meta_json:
|
||||||
|
if isinstance(meta_json, bytes):
|
||||||
|
meta_json = meta_json.decode('utf-8')
|
||||||
|
models[model_name] = json.loads(meta_json)
|
||||||
|
|
||||||
|
return models
|
||||||
|
|
||||||
|
def publish_prices(self,
|
||||||
|
prices_df: pd.DataFrame,
|
||||||
|
model_name: str = 'latest',
|
||||||
|
metadata: Optional[Dict[str, Any]] = None):
|
||||||
|
"""Store predicted prices in registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prices_df: df with [productId, predicted_price, ...]
|
||||||
|
model_name: identifier for this price snapshot
|
||||||
|
metadata: additional info
|
||||||
|
"""
|
||||||
|
key = f"{self.prices_prefix}{model_name}"
|
||||||
|
data_json = prices_df.to_json(orient='records')
|
||||||
|
|
||||||
|
self.redis_client.set(key, data_json)
|
||||||
|
|
||||||
|
meta = metadata or {}
|
||||||
|
meta.update({
|
||||||
|
'n_products': len(prices_df),
|
||||||
|
'model_type': 'predicted_prices'
|
||||||
|
})
|
||||||
|
|
||||||
|
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
||||||
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
|
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
||||||
|
|
||||||
|
def get_prices(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||||
|
"""Retrieve predicted prices from registry."""
|
||||||
|
key = f"{self.prices_prefix}{model_name}"
|
||||||
|
data_json = self.redis_client.get(key)
|
||||||
|
|
||||||
|
if data_json is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(data_json, bytes):
|
||||||
|
data_json = data_json.decode('utf-8')
|
||||||
|
|
||||||
|
return pd.read_json(data_json, orient='records')
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if Redis connection is alive."""
|
||||||
|
try:
|
||||||
|
self.redis_client.ping()
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
|
pythonpath = experiments
|
||||||
testpaths = experiments
|
testpaths = experiments
|
||||||
python_files = test*.py
|
python_files = test*.py
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ pytest-asyncio
|
|||||||
uv
|
uv
|
||||||
scikit-learn
|
scikit-learn
|
||||||
supabase
|
supabase
|
||||||
|
pymc
|
||||||
|
|||||||
80
web/package-lock.json
generated
80
web/package-lock.json
generated
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
"next": "16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
@@ -526,15 +526,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||||
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==",
|
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||||
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==",
|
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -548,9 +548,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||||
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==",
|
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -564,9 +564,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||||
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==",
|
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -580,9 +580,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||||
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==",
|
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -596,9 +596,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||||
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==",
|
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -612,9 +612,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||||
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==",
|
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -628,9 +628,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||||
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==",
|
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -644,9 +644,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||||
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==",
|
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1447,12 +1447,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.0.0",
|
"version": "16.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.0.0",
|
"@next/env": "16.0.7",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
@@ -1465,14 +1465,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.0.0",
|
"@next/swc-darwin-arm64": "16.0.7",
|
||||||
"@next/swc-darwin-x64": "16.0.0",
|
"@next/swc-darwin-x64": "16.0.7",
|
||||||
"@next/swc-linux-arm64-gnu": "16.0.0",
|
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||||
"@next/swc-linux-arm64-musl": "16.0.0",
|
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||||
"@next/swc-linux-x64-gnu": "16.0.0",
|
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||||
"@next/swc-linux-x64-musl": "16.0.0",
|
"@next/swc-linux-x64-musl": "16.0.7",
|
||||||
"@next/swc-win32-arm64-msvc": "16.0.0",
|
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||||
"@next/swc-win32-x64-msvc": "16.0.0",
|
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||||
"sharp": "^0.34.4"
|
"sharp": "^0.34.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
"next": "16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|||||||
11
web/src/app/airline/checkout/page.tsx
Normal file
11
web/src/app/airline/checkout/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AirlineCheckout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-sky-50 to-blue-50">
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||||
|
Thank you for flying with us
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
const storeMode = process.env.STORE_MODE || 'hotel';
|
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||||
const userAgent = req.headers.get('user-agent') || undefined;
|
const userAgent = req.headers.get('user-agent') || undefined;
|
||||||
|
|
||||||
const event: EventBase = {
|
const event: EventBase = {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const productId = searchParams.get('productId');
|
const productId = searchParams.get('productId');
|
||||||
const sessionId = searchParams.get('sessionId');
|
const sessionId = searchParams.get('sessionId');
|
||||||
const experimentId = searchParams.get('experimentId');
|
const experimentId = searchParams.get('experimentId');
|
||||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || 'shop';
|
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -20,10 +20,40 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// stub: call external pricing provider (random for now)
|
|
||||||
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
|
||||||
const price = Math.round(basePrice * 100) / 100;
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
let price: number;
|
||||||
|
let basePrice: number | undefined;
|
||||||
|
let markup: number | undefined;
|
||||||
|
let elasticity: number | undefined;
|
||||||
|
|
||||||
|
// call real pricing provider
|
||||||
|
const providerUrl = process.env.PRICING_PROVIDER_URL || 'http://localhost:5001';
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (sessionId) queryParams.append('sessionId', sessionId);
|
||||||
|
if (experimentId) queryParams.append('experimentId', experimentId);
|
||||||
|
|
||||||
|
const providerResponse = await fetch(
|
||||||
|
`${providerUrl}/api/${storeMode}/price/${productId}?${queryParams.toString()}`,
|
||||||
|
{ headers: { 'Accept': 'application/json' }, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!providerResponse.ok) {
|
||||||
|
throw new Error(`Provider returned ${providerResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerData = await providerResponse.json();
|
||||||
|
price = providerData.price;
|
||||||
|
basePrice = providerData.base_price;
|
||||||
|
markup = providerData.markup;
|
||||||
|
elasticity = providerData.elasticity;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[pricing-provider-error]', err);
|
||||||
|
// fallback to random pricing if provider unavailable
|
||||||
|
const randomBase = 100 + Math.random() * 900;
|
||||||
|
price = Math.round(randomBase * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
// log price to kafka for elasticity computation
|
// log price to kafka for elasticity computation
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -43,19 +73,13 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[price-log-error]', err);
|
console.error('[price-log-error]', err);
|
||||||
// don't fail the pricing request if logging fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// log in dev
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[pricing-api]', {
|
console.log('[pricing-api]', {
|
||||||
productId,
|
productId, sessionId, experimentId, storeMode,
|
||||||
sessionId,
|
price, basePrice, markup, elasticity, timestamp,
|
||||||
experimentId,
|
|
||||||
storeMode,
|
|
||||||
price,
|
|
||||||
timestamp,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ export default function CartPage() {
|
|||||||
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
|
onClick={() => {
|
||||||
|
dispatchInteraction('checkout_start', undefined, { total, itemCount });
|
||||||
|
window.location.href = '/checkout';
|
||||||
|
}}
|
||||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
|
|||||||
11
web/src/app/hotel/checkout/page.tsx
Normal file
11
web/src/app/hotel/checkout/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function HotelCheckout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||||
|
Thank you for staying with us
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,20 @@
|
|||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, DateInput, Dropdown, DropdownCounter, SelectDropdown, SelectOption } from '@/components/ui';
|
||||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
const CITIES: SelectOption[] = [
|
||||||
|
{ value: 'JFK', label: 'New York (JFK)', sublabel: 'John F. Kennedy International' },
|
||||||
|
{ value: 'LAX', label: 'Los Angeles (LAX)', sublabel: 'Los Angeles International' },
|
||||||
|
{ value: 'ORD', label: 'Chicago (ORD)', sublabel: "O'Hare International" },
|
||||||
|
{ value: 'MIA', label: 'Miami (MIA)', sublabel: 'Miami International' },
|
||||||
|
{ value: 'SFO', label: 'San Francisco (SFO)', sublabel: 'San Francisco International' },
|
||||||
|
{ value: 'SEA', label: 'Seattle (SEA)', sublabel: 'Seattle-Tacoma International' },
|
||||||
|
{ value: 'ATL', label: 'Atlanta (ATL)', sublabel: 'Hartsfield-Jackson International' },
|
||||||
|
{ value: 'DFW', label: 'Dallas (DFW)', sublabel: 'Dallas/Fort Worth International' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
const PlaneIcon = () => (
|
const PlaneIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -22,11 +32,9 @@ const LocationIcon = () => (
|
|||||||
|
|
||||||
export default function AirlineHero() {
|
export default function AirlineHero() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [departDate, setDepartDate] = useState('');
|
const [departDate, setDepartDate] = useState('');
|
||||||
const [returnDate, setReturnDate] = useState('');
|
|
||||||
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
const [passengers, setPassengers] = useState({ adults: 1, children: 0, infants: 0 });
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
@@ -40,8 +48,6 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
if (origin) params.set('origin', origin);
|
if (origin) params.set('origin', origin);
|
||||||
if (destination) params.set('destination', destination);
|
if (destination) params.set('destination', destination);
|
||||||
if (tripType !== 'roundtrip') params.set('tripType', tripType);
|
|
||||||
if (returnDate && tripType === 'roundtrip') params.set('returnDate', returnDate);
|
|
||||||
|
|
||||||
params.set('adults', passengers.adults.toString());
|
params.set('adults', passengers.adults.toString());
|
||||||
params.set('children', passengers.children.toString());
|
params.set('children', passengers.children.toString());
|
||||||
@@ -66,28 +72,15 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
<div className="search-form">
|
<div className="search-form">
|
||||||
<form onSubmit={handleSearch}>
|
<form onSubmit={handleSearch}>
|
||||||
<div className="mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<RadioGroup
|
|
||||||
name="tripType"
|
|
||||||
value={tripType}
|
|
||||||
onChange={setTripType}
|
|
||||||
options={[
|
|
||||||
{ value: 'roundtrip', label: 'Round-trip' },
|
|
||||||
{ value: 'oneway', label: 'One-way' },
|
|
||||||
{ value: 'multicity', label: 'Multi-city' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="origin">From</Label>
|
<Label htmlFor="origin">From</Label>
|
||||||
<Input
|
<SelectDropdown
|
||||||
type="text"
|
|
||||||
id="origin"
|
id="origin"
|
||||||
value={origin}
|
value={origin}
|
||||||
onChange={(e) => setOrigin(e.target.value)}
|
onChange={setOrigin}
|
||||||
placeholder="Airport or city"
|
options={CITIES}
|
||||||
|
placeholder="Select origin"
|
||||||
icon={<PlaneIcon />}
|
icon={<PlaneIcon />}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -95,12 +88,12 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="destination">To</Label>
|
<Label htmlFor="destination">To</Label>
|
||||||
<Input
|
<SelectDropdown
|
||||||
type="text"
|
|
||||||
id="destination"
|
id="destination"
|
||||||
value={destination}
|
value={destination}
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
onChange={setDestination}
|
||||||
placeholder="Airport or city"
|
options={CITIES}
|
||||||
|
placeholder="Select destination"
|
||||||
icon={<LocationIcon />}
|
icon={<LocationIcon />}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -115,20 +108,6 @@ export default function AirlineHero() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="returnDate">Return</Label>
|
|
||||||
{tripType === 'roundtrip' ? (
|
|
||||||
<DateInput
|
|
||||||
id="returnDate"
|
|
||||||
value={returnDate}
|
|
||||||
onChange={(e) => setReturnDate(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DateInput id="returnDate" disabled />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
<div className="grid grid-cols-4 sm:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const AmenityIcon = ({ name }: { name: string }) => {
|
|||||||
breakfast: 'Breakfast',
|
breakfast: 'Breakfast',
|
||||||
spa: 'Spa',
|
spa: 'Spa',
|
||||||
};
|
};
|
||||||
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name}</span>;
|
return <span className="feature-tag">{iconMap[name.toLowerCase()] || name.replaceAll("_", " ")}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
||||||
@@ -47,18 +47,31 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
window.location.href = `/hotel/products/${hotel.id}`;
|
window.location.href = `/hotel/products/${hotel.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const imageUrl = `https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=400&h=300&fit=crop`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="hotel-card cursor-pointer"
|
className="hotel-card cursor-pointer"
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
<div className="hotel-image bg-gray-200 flex items-center justify-center">
|
<div className="hotel-image relative overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={hotel.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||||
|
if (fallback) fallback.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gray-200 flex items-center justify-center" style={{ display: 'none' }}>
|
||||||
<span className="text-gray-400 text-sm">Image</span>
|
<span className="text-gray-400 text-sm">Image</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hotel-info">
|
<div className="hotel-info">
|
||||||
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
<h3 ref={titleRef} className="hotel-name">{hotel.name}</h3>
|
||||||
<div className="hotel-location text-sm mb-2">{hotel.roomType}</div>
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
<div className="text-sm text-[var(--text-secondary)] mb-2">
|
||||||
{hotel.checkIn} - {hotel.checkOut}
|
{hotel.checkIn} - {hotel.checkOut}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,9 +80,6 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
<AmenityIcon key={a} name={a} />
|
<AmenityIcon key={a} name={a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hotel.refundable && (
|
|
||||||
<div className="free-cancellation mt-2">Free cancellation</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hotel-pricing">
|
<div className="hotel-pricing">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
import type { Hotel } from '@/lib/hotel-utils';
|
||||||
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
interface HotelDetailsProps {
|
interface HotelDetailsProps {
|
||||||
product: Hotel;
|
product: Hotel;
|
||||||
@@ -8,19 +10,63 @@ interface HotelDetailsProps {
|
|||||||
addedToCart: boolean;
|
addedToCart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PriceTotalDisplay = ({ productId, nights }: { productId: string; nights: number }) => {
|
||||||
|
const [price, setPrice] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPrice = async () => {
|
||||||
|
try {
|
||||||
|
const sessionRes = await fetch('/api/session');
|
||||||
|
const sessionData = await sessionRes.json();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
productId,
|
||||||
|
sessionId: sessionData.sessionId || '',
|
||||||
|
experimentId: sessionData.experimentId || '',
|
||||||
|
});
|
||||||
|
const res = await fetch(`/api/pricing?${params.toString()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setPrice(data.price);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to fetch price for total:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPrice();
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
if (!price) return <span className="text-4xl font-bold text-gray-900">Loading...</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-4xl font-bold text-gray-900">
|
||||||
|
${(price * nights).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||||
|
const imageUrl = `https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&h=600&fit=crop`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||||
{/* Image Section - Larger and cleaner */}
|
<div className="w-full lg:w-1/2 rounded-lg aspect-[4/3] overflow-hidden shrink-0">
|
||||||
<div className="w-full lg:w-1/2 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
if (e.currentTarget.nextElementSibling) {
|
||||||
|
(e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center" style={{ display: 'none' }}>
|
||||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Details Section - Full height/width usage */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="border-b pb-6 mb-6">
|
<div className="border-b pb-6 mb-6">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">{product.name}</h1>
|
||||||
<p className="text-xl text-gray-500">{product.roomType}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||||
@@ -39,24 +85,17 @@ export default function HotelDetails({ product, onAddToCart, addedToCart }: Hote
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{product.amenities.map(a => (
|
{product.amenities.map(a => (
|
||||||
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
<span key={a} className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-md text-sm font-medium">
|
||||||
{a}
|
{a.replaceAll('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.refundable && (
|
|
||||||
<div className="mb-8 p-4 bg-green-50 text-green-800 rounded-md inline-block">
|
|
||||||
<span className="font-medium">Free cancellation available</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
<div className="mt-auto pt-6 border-t flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
<p className="text-sm text-gray-500 mb-1">Price per night</p>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="mb-3">
|
||||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
<PriceDisplay productId={product.id} className="!text-2xl" />
|
||||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
import { InputHTMLAttributes } from 'react';
|
import { InputHTMLAttributes, useMemo } from 'react';
|
||||||
|
|
||||||
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
|
||||||
|
|
||||||
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
export default function DateInput({ className = '', ...props }: DateInpProps) {
|
||||||
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
|
const { minDate, maxDate } = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
|
||||||
|
const tenDaysOut = new Date(tomorrow);
|
||||||
|
tenDaysOut.setDate(tomorrow.getDate() + 9); // tomorrow + 9 = 10 days total
|
||||||
|
|
||||||
|
return {
|
||||||
|
minDate: tomorrow.toISOString().split('T')[0],
|
||||||
|
maxDate: tenDaysOut.toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={`input-field ${className}`.trim()}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const NavLink = ({ href, children }: { href: string; children: React.ReactNode }
|
|||||||
href={href}
|
href={href}
|
||||||
className={`px-4 py-2 rounded-md transition-colors ${
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
? 'bg-[var(--accent-primary)] font-semibold'
|
||||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -37,9 +37,7 @@ export default function Navigation() {
|
|||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<NavLink href="/">Home</NavLink>
|
<NavLink href="/">Home</NavLink>
|
||||||
<NavLink href="/products">Products</NavLink>
|
<NavLink href="/products">Products</NavLink>
|
||||||
<NavLink href="/search">Search</NavLink>
|
|
||||||
<NavLink href="/cart">Cart</NavLink>
|
<NavLink href="/cart">Cart</NavLink>
|
||||||
<NavLink href="/checkout">Checkout</NavLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
119
web/src/components/ui/SelectDropdown.tsx
Normal file
119
web/src/components/ui/SelectDropdown.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectDropdownProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectDropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
icon,
|
||||||
|
required,
|
||||||
|
id,
|
||||||
|
}: SelectDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setFilter('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === value);
|
||||||
|
const filtered = options.filter(
|
||||||
|
(o) =>
|
||||||
|
o.label.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
o.value.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
o.sublabel?.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (opt: SelectOption) => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
setFilter('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<div
|
||||||
|
className="input-field flex items-center gap-2 cursor-pointer box-border"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon && <span className="text-[var(--text-secondary)]">{icon}</span>}
|
||||||
|
{open ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
id={id}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 bg-transparent outline-none text-sm text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={`flex-1 text-sm ${value ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-20 mt-1 w-full bg-[var(--bg-primary)] border-2 border-[var(--accent-primary)] rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-[var(--text-secondary)]">No results</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelect(opt)}
|
||||||
|
className={`px-4 py-2 cursor-pointer transition-colors hover:bg-[var(--accent-primary-light)] ${
|
||||||
|
opt.value === value ? 'bg-[var(--accent-primary-light)]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)]">{opt.label}</div>
|
||||||
|
{opt.sublabel && <div className="text-xs text-[var(--text-secondary)]">{opt.sublabel}</div>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{required && !value && (
|
||||||
|
<input type="text" required className="sr-only" tabIndex={-1} value="" onChange={() => {}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ export { default as DateInput } from './DateInput';
|
|||||||
export { default as RadioGroup } from './RadioGroup';
|
export { default as RadioGroup } from './RadioGroup';
|
||||||
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
export { default as Dropdown, DropdownCounter } from './Dropdown';
|
||||||
export { default as Navigation } from './Navigation';
|
export { default as Navigation } from './Navigation';
|
||||||
|
export { default as SelectDropdown } from './SelectDropdown';
|
||||||
|
export type { SelectOption } from './SelectDropdown';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const envSchema = z.object({
|
|||||||
// parse and validate env at module load, fail fast with descriptive errors
|
// parse and validate env at module load, fail fast with descriptive errors
|
||||||
const parseEnv = (): Env => {
|
const parseEnv = (): Env => {
|
||||||
const result = envSchema.safeParse({
|
const result = envSchema.safeParse({
|
||||||
STORE_MODE: process.env.STORE_MODE,
|
STORE_MODE: process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE,
|
||||||
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
||||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export interface Hotel {
|
|||||||
checkOut: string;
|
checkOut: string;
|
||||||
dateIndex: number;
|
dateIndex: number;
|
||||||
amenities: string[];
|
amenities: string[];
|
||||||
refundable: boolean;
|
|
||||||
pricePerNight: number;
|
pricePerNight: number;
|
||||||
nights: number;
|
nights: number;
|
||||||
}
|
}
|
||||||
@@ -30,19 +29,37 @@ const EPOCH = new Date(0);
|
|||||||
|
|
||||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
export const transformProduct = (p: HotelProduct): Hotel => {
|
||||||
const { id, room_type, date_index, metadata } = p;
|
const { id, room_type, date_index, metadata } = p;
|
||||||
const checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
|
||||||
|
// DB stores date_index as days since epoch
|
||||||
|
// but if value is small (<1000), treat as days from today for backward compat
|
||||||
|
let checkIn: Date;
|
||||||
|
if (date_index < 1000) {
|
||||||
|
// legacy: treat as offset from today
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
checkIn = new Date(today.getTime() + date_index * 86400000);
|
||||||
|
} else {
|
||||||
|
// proper: days since epoch
|
||||||
|
checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
const nights = 1;
|
const nights = 1;
|
||||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
||||||
|
|
||||||
|
const formatOpts: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: checkIn.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: metadata?.name || room_type,
|
name: metadata?.name || room_type,
|
||||||
roomType: room_type,
|
roomType: room_type,
|
||||||
checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
checkIn: checkIn.toLocaleDateString('en-US', formatOpts),
|
||||||
checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
checkOut: checkOut.toLocaleDateString('en-US', formatOpts),
|
||||||
dateIndex: date_index,
|
dateIndex: date_index,
|
||||||
amenities: metadata?.amenities || [],
|
amenities: metadata?.amenities || [],
|
||||||
refundable: metadata?.refundable || false,
|
|
||||||
pricePerNight: metadata?.base_price || 100,
|
pricePerNight: metadata?.base_price || 100,
|
||||||
nights,
|
nights,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -278,6 +278,8 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mode="airline"] .input-field:focus {
|
[data-mode="airline"] .input-field:focus {
|
||||||
|
|||||||
Reference in New Issue
Block a user