mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
2 Commits
airflow-ad
...
22-create-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5469edfa98 | |||
| bf42fe2d60 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,8 +6,3 @@
|
|||||||
**/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,8 +49,4 @@ 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
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
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 (
|
|
||||||
StateSpace,
|
|
||||||
PredictPricesStep
|
|
||||||
)
|
|
||||||
from procesing import PipelineContext
|
|
||||||
sys.path.append(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)
|
|
||||||
|
|
||||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
|
||||||
def __init__(self, backend_url: str):
|
|
||||||
SupabaseProvider.__init__(self)
|
|
||||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
|
||||||
|
|
||||||
context = PipelineContext(
|
|
||||||
provider=Provider(backend_url=os.getenv("BACKEND_URL")),
|
|
||||||
store_mode=mode
|
|
||||||
)
|
|
||||||
|
|
||||||
pricing_model = registry.get_pricing_model('latest')
|
|
||||||
elasticity_df = registry.get_elasticity('latest')
|
|
||||||
|
|
||||||
if pricing_model is None or elasticity_df is None:
|
|
||||||
return PriceResponse(
|
|
||||||
productId=productId,
|
|
||||||
price=base_price,
|
|
||||||
base_price=base_price,
|
|
||||||
markup=1.0,
|
|
||||||
elasticity=None
|
|
||||||
)
|
|
||||||
|
|
||||||
products = context.products
|
|
||||||
if products.empty:
|
|
||||||
raise HTTPException(500, "No products available in catalog")
|
|
||||||
|
|
||||||
# merge elasticity with product base prices
|
|
||||||
products_with_meta = products.copy()
|
|
||||||
products_with_meta['base_price'] = products_with_meta['metadata'].apply(
|
|
||||||
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
|
|
||||||
)
|
|
||||||
|
|
||||||
merged = products_with_meta[['id', 'base_price']].rename(
|
|
||||||
columns={'id': 'productId'}
|
|
||||||
).merge(
|
|
||||||
elasticity_df[['productId', 'elasticity']],
|
|
||||||
on='productId',
|
|
||||||
how='left'
|
|
||||||
).fillna({'elasticity': 0.0})
|
|
||||||
|
|
||||||
# compute demand: use pricer's mean_demand if available, else default
|
|
||||||
demand_values = (pricing_model.mean_demand
|
|
||||||
if hasattr(pricing_model, 'mean_demand') and pricing_model.mean_demand is not None
|
|
||||||
else np.ones(len(merged)) * 10.0)
|
|
||||||
|
|
||||||
# build state space with session features if sessionId provided
|
|
||||||
session_features = pd.DataFrame()
|
|
||||||
if sessionId:
|
|
||||||
try:
|
|
||||||
# fetch recent session interactions from backend
|
|
||||||
from procesing.steps.session import ExtractSessionFeaturesStep
|
|
||||||
import requests
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
t_end = datetime.utcnow()
|
|
||||||
t_start = t_end - timedelta(hours=1)
|
|
||||||
backend_url = os.getenv("BACKEND_URL")
|
|
||||||
print(backend_url)
|
|
||||||
|
|
||||||
resp = requests.get(
|
|
||||||
f"{os.getenv('BACKEND_URL')}/api/kafka/dump", # TODO: THIS IS SHIT, must fix this
|
|
||||||
params={'topic': 'user-interactions', 't_start': t_start.isoformat(), 't_end': t_end.isoformat()},
|
|
||||||
timeout=2
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.ok:
|
|
||||||
msgs = resp.json().get('messages', [])
|
|
||||||
interactions_df = pd.DataFrame(msgs)
|
|
||||||
|
|
||||||
if not interactions_df.empty and 'sessionId' in interactions_df.columns:
|
|
||||||
session_interactions = interactions_df[interactions_df['sessionId'] == sessionId]
|
|
||||||
|
|
||||||
if not session_interactions.empty:
|
|
||||||
extractor = ExtractSessionFeaturesStep(context=context)
|
|
||||||
session_features_df = extractor.transform(session_interactions)
|
|
||||||
|
|
||||||
if not session_features_df.empty:
|
|
||||||
session_features = session_features_df.drop(columns=['sessionId'])
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[session-features-error] {e}")
|
|
||||||
# continue without session features
|
|
||||||
|
|
||||||
state = StateSpace(
|
|
||||||
demand=demand_values,
|
|
||||||
prices=merged['base_price'].values,
|
|
||||||
session_features=session_features,
|
|
||||||
product_ids=merged['productId'].values,
|
|
||||||
elasticity=merged['elasticity'].values,
|
|
||||||
metadata={'sessionId': sessionId, 'experimentId': experimentId}
|
|
||||||
)
|
|
||||||
|
|
||||||
oracle = PredictPricesStep(context=context)
|
|
||||||
prices_df = oracle.transform((pricing_model, state))
|
|
||||||
|
|
||||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
|
||||||
if product_price_row.empty:
|
|
||||||
raise HTTPException(404, f"No pricing available for product {productId}")
|
|
||||||
|
|
||||||
optimal_price = float(product_price_row['predicted_price'].iloc[0])
|
|
||||||
|
|
||||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
|
||||||
product_elasticity = (float(product_elasticity_row['elasticity'].iloc[0])
|
|
||||||
if not product_elasticity_row.empty else None)
|
|
||||||
|
|
||||||
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")))
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
pickle5>=0.0.11; python_version < '3.8'
|
|
||||||
@@ -11,7 +11,6 @@ from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
|||||||
from kafka.admin import NewTopic
|
from kafka.admin import NewTopic
|
||||||
from kafka.errors import TopicAlreadyExistsError
|
from kafka.errors import TopicAlreadyExistsError
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from supabase import create_client, Client
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -19,19 +18,6 @@ app = FastAPI()
|
|||||||
# kafka producer - lazy init
|
# kafka producer - lazy init
|
||||||
_producer: Optional[KafkaProducer] = None
|
_producer: Optional[KafkaProducer] = None
|
||||||
|
|
||||||
# supabase client - lazy init
|
|
||||||
_supabase: Optional[Client] = None
|
|
||||||
|
|
||||||
def get_supabase() -> Client:
|
|
||||||
global _supabase
|
|
||||||
if _supabase is None:
|
|
||||||
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
|
||||||
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
|
||||||
if not url or not key:
|
|
||||||
raise ValueError("Supabase credentials not configured")
|
|
||||||
_supabase = create_client(url, key)
|
|
||||||
return _supabase
|
|
||||||
|
|
||||||
def get_producer() -> KafkaProducer:
|
def get_producer() -> KafkaProducer:
|
||||||
global _producer
|
global _producer
|
||||||
if _producer is None:
|
if _producer is None:
|
||||||
@@ -64,14 +50,6 @@ class EventPayload(BaseModel):
|
|||||||
userAgent: Optional[str] = None
|
userAgent: Optional[str] = None
|
||||||
ts: Optional[str] = None
|
ts: Optional[str] = None
|
||||||
|
|
||||||
class PriceLogPayload(BaseModel):
|
|
||||||
productId: str
|
|
||||||
price: float
|
|
||||||
sessionId: str
|
|
||||||
experimentId: Optional[str] = None
|
|
||||||
storeMode: str
|
|
||||||
ts: Optional[str] = None
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -95,8 +73,7 @@ async def startup_event():
|
|||||||
)
|
)
|
||||||
|
|
||||||
topics = [
|
topics = [
|
||||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1)
|
||||||
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
admin.create_topics(new_topics=topics, validate_only=False)
|
admin.create_topics(new_topics=topics, validate_only=False)
|
||||||
@@ -148,52 +125,26 @@ async def ingest_logs(event: EventPayload):
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.post("/api/kafka/price-log")
|
|
||||||
async def ingest_price_log(price_log: PriceLogPayload):
|
|
||||||
try:
|
|
||||||
if not price_log.ts:
|
|
||||||
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
|
||||||
|
|
||||||
producer = get_producer()
|
|
||||||
future = producer.send(
|
|
||||||
'price-logs',
|
|
||||||
key=price_log.productId,
|
|
||||||
value=price_log.model_dump()
|
|
||||||
)
|
|
||||||
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRICE_LOG_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/kafka/dump")
|
@app.get("/api/kafka/dump")
|
||||||
def dump_logs(
|
def dump_logs(
|
||||||
topic: str = 'user-interactions',
|
|
||||||
last_n: Optional[int] = None,
|
last_n: Optional[int] = None,
|
||||||
t_start: Optional[str] = None,
|
t_start: Optional[str] = None,
|
||||||
t_end: Optional[str] = None
|
t_end: Optional[str] = None
|
||||||
):
|
):
|
||||||
"""dump all messages from specified kafka topic
|
"""dump all messages from user-interactions topic
|
||||||
|
|
||||||
params:
|
params:
|
||||||
topic: kafka topic to dump (default: user-interactions)
|
|
||||||
last_n: return only last n messages (default: all)
|
last_n: return only last n messages (default: all)
|
||||||
t_start: filter by start timestamp iso format
|
t_start: filter by start timestamp iso format (future use)
|
||||||
t_end: filter by end timestamp iso format
|
t_end: filter by end timestamp iso format (future use)
|
||||||
"""
|
"""
|
||||||
if topic not in ['user-interactions', 'price-logs']:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid topic")
|
|
||||||
|
|
||||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
port = os.getenv('KAFKA_PORT', '9092')
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
broker = f'{host}:{port}'
|
broker = f'{host}:{port}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
consumer = KafkaConsumer(
|
consumer = KafkaConsumer(
|
||||||
topic,
|
'user-interactions',
|
||||||
bootstrap_servers=[broker],
|
bootstrap_servers=[broker],
|
||||||
auto_offset_reset='earliest',
|
auto_offset_reset='earliest',
|
||||||
enable_auto_commit=False,
|
enable_auto_commit=False,
|
||||||
@@ -209,6 +160,7 @@ def dump_logs(
|
|||||||
|
|
||||||
# apply filters
|
# apply filters
|
||||||
if t_start or t_end:
|
if t_start or t_end:
|
||||||
|
# filter by timestamp range if provided
|
||||||
filtered = []
|
filtered = []
|
||||||
for e in events:
|
for e in events:
|
||||||
ts = e.get('ts')
|
ts = e.get('ts')
|
||||||
@@ -231,130 +183,6 @@ def dump_logs(
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@app.get("/api/products/{product_id}")
|
|
||||||
async def get_product_by_id(product_id: str):
|
|
||||||
"""fetch single product by id from either hotel_products or airline_products"""
|
|
||||||
try:
|
|
||||||
supabase = get_supabase()
|
|
||||||
|
|
||||||
# try hotel_products first
|
|
||||||
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
|
|
||||||
if response.data and len(response.data) > 0:
|
|
||||||
return {"success": True, "data": response.data[0]}
|
|
||||||
|
|
||||||
# try airline_products
|
|
||||||
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
|
|
||||||
if response.data and len(response.data) > 0:
|
|
||||||
return {"success": True, "data": response.data[0]}
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRODUCT_BY_ID_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@app.get("/api/products/type/{product_type}")
|
|
||||||
async def get_products(
|
|
||||||
product_type: str,
|
|
||||||
dateIndex: Optional[int] = None,
|
|
||||||
origin: Optional[str] = None,
|
|
||||||
destination: Optional[str] = None,
|
|
||||||
tripType: Optional[str] = None,
|
|
||||||
adults: Optional[int] = None,
|
|
||||||
children: Optional[int] = None,
|
|
||||||
infants: Optional[int] = None,
|
|
||||||
rooms: Optional[int] = None
|
|
||||||
):
|
|
||||||
"""fetch products from supabase based on type (hotel or airline)
|
|
||||||
|
|
||||||
params:
|
|
||||||
product_type: either 'hotel' or 'airline'
|
|
||||||
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
|
||||||
origin: (airline) departure airport code
|
|
||||||
destination: (airline/hotel) arrival airport or hotel location
|
|
||||||
tripType: (airline) roundtrip, oneway, multicity
|
|
||||||
adults, children, infants: passenger counts
|
|
||||||
rooms: (hotel) number of rooms
|
|
||||||
"""
|
|
||||||
if product_type not in ['hotel', 'airline']:
|
|
||||||
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
|
|
||||||
|
|
||||||
try:
|
|
||||||
supabase = get_supabase()
|
|
||||||
table = f'{product_type}_products'
|
|
||||||
|
|
||||||
query = supabase.table(table).select('*')
|
|
||||||
|
|
||||||
# filter by exact date_index if provided
|
|
||||||
if dateIndex is not None:
|
|
||||||
query = query.eq('date_index', dateIndex)
|
|
||||||
|
|
||||||
response = query.execute()
|
|
||||||
results = response.data
|
|
||||||
|
|
||||||
# apply in-memory filters based on metadata for airline products
|
|
||||||
if product_type == 'airline' and results:
|
|
||||||
filtered = []
|
|
||||||
for product in results:
|
|
||||||
metadata = product.get('metadata', {})
|
|
||||||
|
|
||||||
# filter by origin airport
|
|
||||||
if origin:
|
|
||||||
dep = metadata.get('departure', {})
|
|
||||||
if dep.get('airport') != origin:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# filter by destination airport
|
|
||||||
if destination:
|
|
||||||
arr = metadata.get('arrival', {})
|
|
||||||
if arr.get('airport') != destination:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# passenger count validation (ensure total capacity)
|
|
||||||
if adults is not None or children is not None or infants is not None:
|
|
||||||
total_pax = (adults or 0) + (children or 0) + (infants or 0)
|
|
||||||
avail = product.get('availability', 0)
|
|
||||||
if avail < total_pax:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(product)
|
|
||||||
|
|
||||||
results = filtered
|
|
||||||
|
|
||||||
# apply in-memory filters for hotel products
|
|
||||||
elif product_type == 'hotel' and results:
|
|
||||||
filtered = []
|
|
||||||
for product in results:
|
|
||||||
metadata = product.get('metadata', {})
|
|
||||||
|
|
||||||
# filter by occupancy capacity
|
|
||||||
if adults is not None:
|
|
||||||
max_occ = metadata.get('max_occupancy', 2)
|
|
||||||
if max_occ < adults:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# filter by room availability
|
|
||||||
if rooms is not None:
|
|
||||||
avail = product.get('availability', 0)
|
|
||||||
if avail < rooms:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(product)
|
|
||||||
|
|
||||||
results = filtered
|
|
||||||
|
|
||||||
return {"success": True, "count": len(results), "data": results}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
print(f"[PRODUCTS_ERROR] {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ uvicorn[standard]==0.24.0
|
|||||||
kafka-python==2.0.2
|
kafka-python==2.0.2
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
supabase==2.9.1
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- 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:
|
depends_on:
|
||||||
- kafka
|
- kafka
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -71,153 +68,6 @@ 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
|
|
||||||
volumes:
|
|
||||||
- ./experiments/airflow/dags:/opt/airflow/dags
|
|
||||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
|
||||||
- ./experiments/airflow/plugins:/opt/airflow/plugins
|
|
||||||
- ./experiments/procesing:/opt/airflow/procesing
|
|
||||||
- ./lib:/opt/airflow/lib
|
|
||||||
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
|
|
||||||
- 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"
|
|
||||||
volumes:
|
|
||||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
|
||||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
|
||||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
|
||||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
|
||||||
- ./lib:/opt/airflow/lib:ro
|
|
||||||
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
|
|
||||||
- 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
|
|
||||||
volumes:
|
|
||||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
|
||||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
|
||||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
|
||||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
|
||||||
- ./lib:/opt/airflow/lib:ro
|
|
||||||
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}
|
|
||||||
ports:
|
|
||||||
- "${PROVIDER_PORT:-5001}:5001"
|
|
||||||
volumes:
|
|
||||||
- ./lib:/app/lib:ro
|
|
||||||
- ./experiments/procesing:/app/procesing:ro
|
|
||||||
- ./backend/provider:/app/provider:ro
|
|
||||||
command: python -m uvicorn provider.app:app --host 0.0.0.0 --port 5001
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
phantom_kafka_data:
|
phantom_kafka_data:
|
||||||
phantom_redis_data:
|
phantom_redis_data:
|
||||||
postgres_data:
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
# Structure will be mounted via volumes:
|
|
||||||
# /app/lib -> lib/
|
|
||||||
# /app/procesing -> experiments/procesing/
|
|
||||||
# /app/provider -> backend/provider/
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
|
||||||
|
|
||||||
CMD ["python", "-m", "uvicorn", "provider.app:app", "--host", "0.0.0.0", "--port", "5001"]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import asyncio
|
import asyncio
|
||||||
JTBD= "Find me the cheapest room in Madrid for 2 people in the next two days, review each hotel room in detail and then add it to cart."
|
JTBD= "Name all the products on this site and try to find out more about each product by clicking into them (they might not open)"
|
||||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal=JTBD, url="http://localhost:3000/products", timeout=300)
|
||||||
goal=JTBD,
|
|
||||||
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
|
||||||
timeout=300)
|
|
||||||
R=asyncio.run(agent.act())
|
R=asyncio.run(agent.act())
|
||||||
print(R)
|
print(R)
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
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,
|
|
||||||
CreatePriceBucketsStep,
|
|
||||||
AugmentEventNamesStep,
|
|
||||||
ChunkByTimeWindowStep,
|
|
||||||
ComputeDemandForChunksStep,
|
|
||||||
AggregatePriceLogsStep,
|
|
||||||
ComputeElasticityStep,
|
|
||||||
BuildStateSpaceStep,
|
|
||||||
FitPricingFunctionStep,
|
|
||||||
PredictPricesStep,
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
|
||||||
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'),
|
|
||||||
window_size=dag_conf.get('window_size', '30s'),
|
|
||||||
n_price_buckets=dag_conf.get('n_price_buckets', 5),
|
|
||||||
elasticity_method=dag_conf.get('elasticity_method', 'point'),
|
|
||||||
min_observations=dag_conf.get('min_observations', 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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 create_price_buckets(**kwargs):
|
|
||||||
"""Task: Create price buckets for interactions"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = CreatePriceBucketsStep(context)
|
|
||||||
df = step.transform(df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='interactions_bucketed', value=pickle.dumps(df))
|
|
||||||
logging.info(f"Created price buckets for {len(df)} interactions")
|
|
||||||
return len(df)
|
|
||||||
|
|
||||||
def augment_event_names(**kwargs):
|
|
||||||
"""Task: Augment event names with product and price schema"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
df = pickle.loads(ti.xcom_pull(key='interactions_bucketed'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = AugmentEventNamesStep(context)
|
|
||||||
df = step.transform(df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='interactions_final', value=pickle.dumps(df))
|
|
||||||
logging.info(f"Augmented event names for {len(df)} interactions")
|
|
||||||
return len(df)
|
|
||||||
|
|
||||||
def chunk_interactions(**kwargs):
|
|
||||||
"""Task: Chunk interactions into time windows"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
df = pickle.loads(ti.xcom_pull(key='interactions_final'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = ChunkByTimeWindowStep(context)
|
|
||||||
chunks = step.transform(df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='interaction_chunks', value=pickle.dumps(chunks))
|
|
||||||
logging.info(f"Generated {len(chunks)} interaction chunks")
|
|
||||||
return len(chunks)
|
|
||||||
|
|
||||||
def compute_demand(**kwargs):
|
|
||||||
"""Task: Compute demand vectors for all chunks"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
chunks = pickle.loads(ti.xcom_pull(key='interaction_chunks'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = ComputeDemandForChunksStep(context)
|
|
||||||
demand_chunks = step.transform(chunks)
|
|
||||||
|
|
||||||
ti.xcom_push(key='demand_chunks', value=pickle.dumps(demand_chunks))
|
|
||||||
logging.info(f"Computed demand for {len(demand_chunks)} chunks")
|
|
||||||
return len(demand_chunks)
|
|
||||||
|
|
||||||
def aggregate_price_logs(**kwargs):
|
|
||||||
"""Task: Aggregate price logs into time windows """
|
|
||||||
ti = kwargs['ti']
|
|
||||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = AggregatePriceLogsStep(context)
|
|
||||||
price_chunks = step.transform(df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='price_chunks', value=pickle.dumps(price_chunks))
|
|
||||||
logging.info(f"Aggregated {len(price_chunks)} price chunks")
|
|
||||||
return len(price_chunks)
|
|
||||||
|
|
||||||
def compute_elasticity(**kwargs):
|
|
||||||
"""Task: Compute price elasticity from demand and price chunks"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
demand_chunks = pickle.loads(ti.xcom_pull(key='demand_chunks'))
|
|
||||||
price_chunks = pickle.loads(ti.xcom_pull(key='price_chunks'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = ComputeElasticityStep(context)
|
|
||||||
elasticity_df = step.transform((demand_chunks, price_chunks))
|
|
||||||
|
|
||||||
ti.xcom_push(key='elasticity_results', value=pickle.dumps(elasticity_df))
|
|
||||||
logging.info(f"Computed elasticity for {len(elasticity_df)} products")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'n_products': len(elasticity_df),
|
|
||||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
|
||||||
'median_elasticity': float(elasticity_df['elasticity'].median())
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_state_space(**kwargs):
|
|
||||||
"""Task: Build state space from elasticity"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = BuildStateSpaceStep(context)
|
|
||||||
state_space = step.transform(elasticity_df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='state_space', value=pickle.dumps(state_space))
|
|
||||||
logging.info("Built state space for pricing")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def fit_pricing_function(**kwargs):
|
|
||||||
"""Task: Fit pricing function using elasticity"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = FitPricingFunctionStep(context)
|
|
||||||
pricer = step.transform(elasticity_df)
|
|
||||||
|
|
||||||
ti.xcom_push(key='pricer', value=pickle.dumps(pricer))
|
|
||||||
logging.info("Fitted pricing function")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def predict_prices(**kwargs):
|
|
||||||
"""Task: Predict optimal prices"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
|
||||||
state_space = pickle.loads(ti.xcom_pull(key='state_space'))
|
|
||||||
|
|
||||||
context = get_context(**kwargs)
|
|
||||||
step = PredictPricesStep(context)
|
|
||||||
prices_df = step.transform((pricer, state_space))
|
|
||||||
|
|
||||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
|
||||||
logging.info(f"Predicted prices for {len(prices_df)} products")
|
|
||||||
return len(prices_df)
|
|
||||||
|
|
||||||
def publish_results(**kwargs):
|
|
||||||
"""Task: Publish elasticity and pricing results to model registry"""
|
|
||||||
ti = kwargs['ti']
|
|
||||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
|
||||||
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(),
|
|
||||||
'window_size': dag_conf.get('window_size', '30s'),
|
|
||||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
|
||||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual'
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.publish_elasticity(elasticity_df, model_name='latest', metadata=metadata)
|
|
||||||
|
|
||||||
# get fitted pricer from XCom
|
|
||||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
|
||||||
registry.publish_pricing_model(
|
|
||||||
pricer,
|
|
||||||
model_name='latest',
|
|
||||||
metadata={**metadata, 'model_type': type(pricer).__name__}
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info(f"Published elasticity + pricing for {len(elasticity_df)} products")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'n_products': len(elasticity_df),
|
|
||||||
'registry_status': 'success',
|
|
||||||
'elasticity_mean': float(elasticity_df['elasticity'].mean())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# DAG definition
|
|
||||||
with DAG(
|
|
||||||
'elasticity_pricing_pipeline',
|
|
||||||
default_args=default_args,
|
|
||||||
description='E2E refactored pipeline: atomic steps with proper separation',
|
|
||||||
schedule_interval='*/15 * * * *',
|
|
||||||
start_date=days_ago(1),
|
|
||||||
catchup=False,
|
|
||||||
max_active_runs=1,
|
|
||||||
tags=['pricing', 'elasticity', 'research', 'refactored'],
|
|
||||||
) 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# interaction processing branch
|
|
||||||
t_create_buckets = PythonOperator(
|
|
||||||
task_id='create_price_buckets',
|
|
||||||
python_callable=create_price_buckets,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
t_augment_events = PythonOperator(
|
|
||||||
task_id='augment_event_names',
|
|
||||||
python_callable=augment_event_names,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
t_chunk_interactions = PythonOperator(
|
|
||||||
task_id='chunk_interactions',
|
|
||||||
python_callable=chunk_interactions,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
t_compute_demand = PythonOperator(
|
|
||||||
task_id='compute_demand',
|
|
||||||
python_callable=compute_demand,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# price processing branch (VECTORIZED)
|
|
||||||
t_aggregate_prices = PythonOperator(
|
|
||||||
task_id='aggregate_price_logs',
|
|
||||||
python_callable=aggregate_price_logs,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# convergence: compute elasticity
|
|
||||||
t_compute_elasticity = PythonOperator(
|
|
||||||
task_id='compute_elasticity',
|
|
||||||
python_callable=compute_elasticity,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# pricing tasks
|
|
||||||
t_build_state = PythonOperator(
|
|
||||||
task_id='build_state_space',
|
|
||||||
python_callable=build_state_space,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
t_fit_pricer = PythonOperator(
|
|
||||||
task_id='fit_pricing_function',
|
|
||||||
python_callable=fit_pricing_function,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
t_predict_prices = PythonOperator(
|
|
||||||
task_id='predict_prices',
|
|
||||||
python_callable=predict_prices,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# publish to registry
|
|
||||||
t_publish = PythonOperator(
|
|
||||||
task_id='publish_results',
|
|
||||||
python_callable=publish_results,
|
|
||||||
provide_context=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# dependency graph (clear atomic flow)
|
|
||||||
# parallel fetches
|
|
||||||
[t_fetch_interactions, t_fetch_price_logs]
|
|
||||||
|
|
||||||
# interaction branch: fetch -> bucket -> augment -> chunk -> demand
|
|
||||||
t_fetch_interactions >> t_create_buckets >> t_augment_events >> t_chunk_interactions >> t_compute_demand
|
|
||||||
|
|
||||||
# price branch: fetch -> aggregate (vectorized)
|
|
||||||
t_fetch_price_logs >> t_aggregate_prices
|
|
||||||
|
|
||||||
# convergence: both branches -> elasticity
|
|
||||||
[t_compute_demand, t_aggregate_prices] >> t_compute_elasticity
|
|
||||||
|
|
||||||
# pricing: elasticity -> state + fit -> predict -> publish
|
|
||||||
t_compute_elasticity >> [t_build_state, t_fit_pricer] >> t_predict_prices >> t_publish
|
|
||||||
957
experiments/data_export.ipynb
Normal file
957
experiments/data_export.ipynb
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 10,
|
||||||
|
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"True"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 10,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"from kafka import KafkaConsumer\n",
|
||||||
|
"import pandas as pd\n",
|
||||||
|
"import json\n",
|
||||||
|
"import numpy as np\n",
|
||||||
|
"import os\n",
|
||||||
|
"from dotenv import load_dotenv\n",
|
||||||
|
"import matplotlib.pyplot as plt\n",
|
||||||
|
"from IPython.display import display, SVG, Image\n",
|
||||||
|
"load_dotenv()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 11,
|
||||||
|
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||||
|
"RangeIndex: 73 entries, 0 to 72\n",
|
||||||
|
"Data columns (total 13 columns):\n",
|
||||||
|
" # Column Non-Null Count Dtype \n",
|
||||||
|
"--- ------ -------------- ----- \n",
|
||||||
|
" 0 sessionId 73 non-null object \n",
|
||||||
|
" 1 eventName 73 non-null object \n",
|
||||||
|
" 2 page 73 non-null object \n",
|
||||||
|
" 3 productId 67 non-null object \n",
|
||||||
|
" 4 storeMode 73 non-null object \n",
|
||||||
|
" 5 userAgent 73 non-null object \n",
|
||||||
|
" 6 ts 73 non-null object \n",
|
||||||
|
" 7 metadata_referrer 6 non-null object \n",
|
||||||
|
" 8 metadata_roomType 45 non-null object \n",
|
||||||
|
" 9 metadata_price 45 non-null float64\n",
|
||||||
|
" 10 metadata_nights 45 non-null float64\n",
|
||||||
|
" 11 metadata_elementText 22 non-null object \n",
|
||||||
|
" 12 metadata_dwellTime 22 non-null float64\n",
|
||||||
|
"dtypes: float64(3), object(10)\n",
|
||||||
|
"memory usage: 7.5+ KB\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
||||||
|
"topic = \"user-interactions\"\n",
|
||||||
|
"consumer = KafkaConsumer(\n",
|
||||||
|
" topic, \n",
|
||||||
|
" enable_auto_commit=True,\n",
|
||||||
|
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
||||||
|
" auto_offset_reset='earliest', \n",
|
||||||
|
" bootstrap_servers=['localhost:9092'])\n",
|
||||||
|
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
||||||
|
"df = []\n",
|
||||||
|
"for m in messages.values():\n",
|
||||||
|
" for i in m:\n",
|
||||||
|
" df.append(i.value)\n",
|
||||||
|
"df = pd.DataFrame(df)\n",
|
||||||
|
"# explode metadata col json\n",
|
||||||
|
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
||||||
|
"df.info()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 12,
|
||||||
|
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/html": [
|
||||||
|
"<div>\n",
|
||||||
|
"<style scoped>\n",
|
||||||
|
" .dataframe tbody tr th:only-of-type {\n",
|
||||||
|
" vertical-align: middle;\n",
|
||||||
|
" }\n",
|
||||||
|
"\n",
|
||||||
|
" .dataframe tbody tr th {\n",
|
||||||
|
" vertical-align: top;\n",
|
||||||
|
" }\n",
|
||||||
|
"\n",
|
||||||
|
" .dataframe thead th {\n",
|
||||||
|
" text-align: right;\n",
|
||||||
|
" }\n",
|
||||||
|
"</style>\n",
|
||||||
|
"<table border=\"1\" class=\"dataframe\">\n",
|
||||||
|
" <thead>\n",
|
||||||
|
" <tr style=\"text-align: right;\">\n",
|
||||||
|
" <th></th>\n",
|
||||||
|
" <th>sessionId</th>\n",
|
||||||
|
" <th>eventName</th>\n",
|
||||||
|
" <th>page</th>\n",
|
||||||
|
" <th>productId</th>\n",
|
||||||
|
" <th>storeMode</th>\n",
|
||||||
|
" <th>userAgent</th>\n",
|
||||||
|
" <th>ts</th>\n",
|
||||||
|
" <th>metadata_referrer</th>\n",
|
||||||
|
" <th>metadata_roomType</th>\n",
|
||||||
|
" <th>metadata_price</th>\n",
|
||||||
|
" <th>metadata_nights</th>\n",
|
||||||
|
" <th>metadata_elementText</th>\n",
|
||||||
|
" <th>metadata_dwellTime</th>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </thead>\n",
|
||||||
|
" <tbody>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>0</th>\n",
|
||||||
|
" <td>d176d7c9-4027-4702-9e31-2a71395cdda0</td>\n",
|
||||||
|
" <td>page_view</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>None</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:23:46.270Z</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>1</th>\n",
|
||||||
|
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||||
|
" <td>page_view</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>None</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||||
|
" <td>2025-11-14T13:26:00.291Z</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>2</th>\n",
|
||||||
|
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||||
|
" <td>page_view</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>None</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||||
|
" <td>2025-11-14T13:26:07.769Z</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>3</th>\n",
|
||||||
|
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||||
|
" <td>2025-11-14T13:26:15.010Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Premium Room</td>\n",
|
||||||
|
" <td>269.0</td>\n",
|
||||||
|
" <td>1.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>4</th>\n",
|
||||||
|
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||||
|
" <td>page_view</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>None</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||||
|
" <td>2025-11-14T13:27:15.457Z</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>5</th>\n",
|
||||||
|
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||||
|
" <td>2025-11-14T13:27:15.591Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Premium Room</td>\n",
|
||||||
|
" <td>264.0</td>\n",
|
||||||
|
" <td>2.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>432</th>\n",
|
||||||
|
" <td>214d9fad-9b00-40c3-bd0e-7739b6acd654</td>\n",
|
||||||
|
" <td>click</td>\n",
|
||||||
|
" <td>1762448192425</td>\n",
|
||||||
|
" <td>DIV</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>/</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>1623.0</td>\n",
|
||||||
|
" <td>493.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>6</th>\n",
|
||||||
|
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||||
|
" <td>2025-11-14T13:27:21.483Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Premium Room</td>\n",
|
||||||
|
" <td>264.0</td>\n",
|
||||||
|
" <td>2.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>7</th>\n",
|
||||||
|
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||||
|
" <td>hover_over_title</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||||
|
" <td>2025-11-14T13:27:22.646Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Grand Plaza Hotel</td>\n",
|
||||||
|
" <td>1200.0</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>8</th>\n",
|
||||||
|
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||||
|
" <td>2025-11-14T13:27:25.889Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Premium Room</td>\n",
|
||||||
|
" <td>264.0</td>\n",
|
||||||
|
" <td>2.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>35</th>\n",
|
||||||
|
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||||
|
" <td>page_view</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>None</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:53:59.993Z</td>\n",
|
||||||
|
" <td></td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>36</th>\n",
|
||||||
|
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:54:10.705Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Premium Room</td>\n",
|
||||||
|
" <td>223.0</td>\n",
|
||||||
|
" <td>3.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>37</th>\n",
|
||||||
|
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||||
|
" <td>hover_over_title</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-0</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:54:11.771Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>416.0</td>\n",
|
||||||
|
" <td>397.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Grand Plaza Hotel</td>\n",
|
||||||
|
" <td>1200.0</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>38</th>\n",
|
||||||
|
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||||
|
" <td>view_item_page</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-1</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:54:29.772Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Standard Room</td>\n",
|
||||||
|
" <td>267.0</td>\n",
|
||||||
|
" <td>5.0</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" <tr>\n",
|
||||||
|
" <th>39</th>\n",
|
||||||
|
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||||
|
" <td>hover_over_title</td>\n",
|
||||||
|
" <td>/products</td>\n",
|
||||||
|
" <td>htl-1</td>\n",
|
||||||
|
" <td>hotel</td>\n",
|
||||||
|
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||||
|
" <td>2025-11-14T13:54:30.833Z</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>NaN</td>\n",
|
||||||
|
" <td>Seaside Resort</td>\n",
|
||||||
|
" <td>1200.0</td>\n",
|
||||||
|
" </tr>\n",
|
||||||
|
" </tbody>\n",
|
||||||
|
"</table>\n",
|
||||||
|
"</div>"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
" sessionId eventName page \\\n",
|
||||||
|
"0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n",
|
||||||
|
"1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n",
|
||||||
|
"2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n",
|
||||||
|
"3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n",
|
||||||
|
"4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n",
|
||||||
|
"5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||||
|
"6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||||
|
"7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n",
|
||||||
|
"8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||||
|
"35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n",
|
||||||
|
"36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||||
|
"37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||||
|
"38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||||
|
"39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||||
|
"\n",
|
||||||
|
" productId storeMode userAgent \\\n",
|
||||||
|
"0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||||
|
"2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||||
|
"3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||||
|
"4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||||
|
"5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||||
|
"6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||||
|
"7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||||
|
"8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||||
|
"35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||||
|
"\n",
|
||||||
|
" ts metadata_referrer metadata_roomType \\\n",
|
||||||
|
"0 2025-11-14T13:23:46.270Z NaN \n",
|
||||||
|
"1 2025-11-14T13:26:00.291Z NaN \n",
|
||||||
|
"2 2025-11-14T13:26:07.769Z NaN \n",
|
||||||
|
"3 2025-11-14T13:26:15.010Z NaN Premium Room \n",
|
||||||
|
"4 2025-11-14T13:27:15.457Z NaN \n",
|
||||||
|
"5 2025-11-14T13:27:15.591Z NaN Premium Room \n",
|
||||||
|
"6 2025-11-14T13:27:21.483Z NaN Premium Room \n",
|
||||||
|
"7 2025-11-14T13:27:22.646Z NaN NaN \n",
|
||||||
|
"8 2025-11-14T13:27:25.889Z NaN Premium Room \n",
|
||||||
|
"35 2025-11-14T13:53:59.993Z NaN \n",
|
||||||
|
"36 2025-11-14T13:54:10.705Z NaN Premium Room \n",
|
||||||
|
"37 2025-11-14T13:54:11.771Z NaN NaN \n",
|
||||||
|
"38 2025-11-14T13:54:29.772Z NaN Standard Room \n",
|
||||||
|
"39 2025-11-14T13:54:30.833Z NaN NaN \n",
|
||||||
|
"\n",
|
||||||
|
" metadata_price metadata_nights metadata_elementText metadata_dwellTime \n",
|
||||||
|
"0 NaN NaN NaN NaN \n",
|
||||||
|
"1 NaN NaN NaN NaN \n",
|
||||||
|
"2 NaN NaN NaN NaN \n",
|
||||||
|
"3 269.0 1.0 NaN NaN \n",
|
||||||
|
"4 NaN NaN NaN NaN \n",
|
||||||
|
"5 264.0 2.0 NaN NaN \n",
|
||||||
|
"6 264.0 2.0 NaN NaN \n",
|
||||||
|
"7 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||||
|
"8 264.0 2.0 NaN NaN \n",
|
||||||
|
"35 NaN NaN NaN NaN \n",
|
||||||
|
"36 223.0 3.0 NaN NaN \n",
|
||||||
|
"37 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||||
|
"38 267.0 5.0 NaN NaN \n",
|
||||||
|
"39 NaN NaN Seaside Resort 1200.0 "
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 12,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"df.groupby('sessionId').head()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 13,
|
||||||
|
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"['013fc334-4045-4d5a-8739-dd0a8766a63b',\n",
|
||||||
|
" '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n",
|
||||||
|
" 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n",
|
||||||
|
" 'f0317a5d-e424-44e9-b784-c8f7291ffe31']"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 13,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 14,
|
||||||
|
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# map sessions to experiments"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 15,
|
||||||
|
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||||
|
" df = df.dropna(subset=['eventName'])\n",
|
||||||
|
" events = df['eventName'].tolist()\n",
|
||||||
|
" labels = pd.Index(events).unique().tolist()\n",
|
||||||
|
" idx = {e:i for i,e in enumerate(labels)}\n",
|
||||||
|
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
||||||
|
" for a, b in zip(events, events[1:]):\n",
|
||||||
|
" M[idx[a], idx[b]] += 1\n",
|
||||||
|
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
||||||
|
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
||||||
|
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
||||||
|
" return P, labels"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 16,
|
||||||
|
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
||||||
|
"from graphviz import Digraph\n",
|
||||||
|
"import numpy as np\n",
|
||||||
|
"import pandas as pd\n",
|
||||||
|
"\n",
|
||||||
|
"def _as_prob_df(matrix, labels=None):\n",
|
||||||
|
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
||||||
|
" if isinstance(matrix, pd.DataFrame):\n",
|
||||||
|
" # Ensure square and aligned\n",
|
||||||
|
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
||||||
|
" return matrix\n",
|
||||||
|
" matrix = np.asarray(matrix, dtype=float)\n",
|
||||||
|
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
||||||
|
" if labels is None:\n",
|
||||||
|
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
||||||
|
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
||||||
|
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
||||||
|
"\n",
|
||||||
|
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
||||||
|
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
||||||
|
" edges = []\n",
|
||||||
|
" for src in P.index:\n",
|
||||||
|
" for dst in P.columns:\n",
|
||||||
|
" w = float(P.loc[src, dst])\n",
|
||||||
|
" if w > threshold:\n",
|
||||||
|
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
||||||
|
" return edges\n",
|
||||||
|
"\n",
|
||||||
|
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" fname: output file stem (no extension)\n",
|
||||||
|
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
||||||
|
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
||||||
|
" threshold: hide edges with weight <= threshold\n",
|
||||||
|
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
||||||
|
" view: open after rendering\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
||||||
|
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
||||||
|
"\n",
|
||||||
|
" g = Digraph(format=fmt)\n",
|
||||||
|
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
||||||
|
" g.attr(\"node\", shape=\"circle\")\n",
|
||||||
|
"\n",
|
||||||
|
" # ensure isolated nodes appear\n",
|
||||||
|
" for node in P.index:\n",
|
||||||
|
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
||||||
|
"\n",
|
||||||
|
" for src, dst, label in edges:\n",
|
||||||
|
" g.edge(src, dst, label=label)\n",
|
||||||
|
"\n",
|
||||||
|
" g.render(fname, view=view, cleanup=True)\n",
|
||||||
|
" return g\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 17,
|
||||||
|
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"013fc334-4045-4d5a-8739-dd0a8766a63b\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"image/svg+xml": [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||||
|
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||||
|
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||||
|
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||||
|
" -->\n",
|
||||||
|
"<!-- Pages: 1 -->\n",
|
||||||
|
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||||
|
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||||
|
"<!-- page_view -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>page_view</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-235.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>view_item_page</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-235.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- page_view->view_item_page -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>page_view->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-235.83C113.69,-235.83 133.31,-235.83 152.25,-235.83\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-239.33 162.1,-235.83 152.1,-232.33 152.1,-239.33\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-239.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->view_item_page -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-302.59C217.1,-314.51 223.14,-322.84 232.88,-322.84 239.27,-322.84 244.07,-319.26 247.28,-313.42\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-314.62 250.52,-304.02 243.95,-312.33 250.57,-314.62\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-326.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.68</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_title -->\n",
|
||||||
|
"<g id=\"node3\" class=\"node\">\n",
|
||||||
|
"<title>hover_over_title</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->hover_over_title -->\n",
|
||||||
|
"<g id=\"edge3\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->hover_over_title</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M300.48,-250.14C307.03,-251.43 313.58,-252.69 319.89,-253.83 340.12,-257.51 362.05,-261.1 382.5,-264.27\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"381.77,-267.7 392.19,-265.76 382.83,-260.78 381.77,-267.7\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-263.17\" font-family=\"Times,serif\" font-size=\"14.00\">0.29</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_paragraph -->\n",
|
||||||
|
"<g id=\"node4\" class=\"node\">\n",
|
||||||
|
"<title>hover_over_paragraph</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||||
|
"<g id=\"edge4\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M292.09,-199.63C316.79,-184.27 346.14,-166.02 373.44,-149.04\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"375.08,-152.15 381.72,-143.89 371.38,-146.2 375.08,-152.15\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-185.68\" font-family=\"Times,serif\" font-size=\"14.00\">0.04</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_title->view_item_page -->\n",
|
||||||
|
"<g id=\"edge5\" class=\"edge\">\n",
|
||||||
|
"<title>hover_over_title->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M399.53,-246.73C384.12,-240.88 367.42,-235.6 351.39,-232.58 339.13,-230.28 326.03,-229.26 313.19,-229.04\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"313.51,-225.54 303.51,-229.04 313.51,-232.54 313.51,-225.54\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-236.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7f0779e818b0>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[]\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"image/svg+xml": [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||||
|
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||||
|
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||||
|
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||||
|
" -->\n",
|
||||||
|
"<!-- Pages: 1 -->\n",
|
||||||
|
"<svg width=\"8pt\" height=\"8pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 8.00 8.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||||
|
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 4)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-4 4,-4 4,4 -4,4\"/>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7f6800fac980>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||||
|
" [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n",
|
||||||
|
" [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||||
|
" [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n",
|
||||||
|
"238dc588-a7ab-4c0e-bccd-6abca5076c66\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"image/svg+xml": [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||||
|
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||||
|
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||||
|
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||||
|
" -->\n",
|
||||||
|
"<!-- Pages: 1 -->\n",
|
||||||
|
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||||
|
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||||
|
"<!-- page_view -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>page_view</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-109.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-105.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>view_item_page</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-197.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-193.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- page_view->view_item_page -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>page_view->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M92.02,-130.47C112.32,-140.25 137.13,-152.2 160.18,-163.3\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"158.39,-166.32 168.92,-167.51 161.43,-160.02 158.39,-166.32\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-157.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->view_item_page -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-264.59C217.1,-276.51 223.14,-284.84 232.88,-284.84 239.27,-284.84 244.07,-281.26 247.28,-275.42\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-276.62 250.52,-266.02 243.95,-274.33 250.57,-276.62\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-288.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.19</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_title -->\n",
|
||||||
|
"<g id=\"node3\" class=\"node\">\n",
|
||||||
|
"<title>hover_over_title</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->hover_over_title -->\n",
|
||||||
|
"<g id=\"edge3\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->hover_over_title</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M289.6,-237.16C299.36,-242.77 309.67,-247.94 319.89,-251.83 339.45,-259.28 361.4,-264.43 382.1,-267.98\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"381.52,-271.43 391.95,-269.55 382.62,-264.52 381.52,-271.43\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-265.16\" font-family=\"Times,serif\" font-size=\"14.00\">0.38</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_paragraph -->\n",
|
||||||
|
"<g id=\"node4\" class=\"node\">\n",
|
||||||
|
"<title>hover_over_paragraph</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||||
|
"<g id=\"edge4\" class=\"edge\">\n",
|
||||||
|
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M300.22,-180.71C317.22,-175.46 335.24,-169.12 351.39,-161.83 358.97,-158.41 366.67,-154.57 374.29,-150.49\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"375.84,-153.63 382.92,-145.75 372.47,-147.5 375.84,-153.63\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-178.15\" font-family=\"Times,serif\" font-size=\"14.00\">0.44</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_title->view_item_page -->\n",
|
||||||
|
"<g id=\"edge5\" class=\"edge\">\n",
|
||||||
|
"<title>hover_over_title->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M398.52,-248.36C383.21,-242.16 366.82,-235.87 351.39,-230.58 338.42,-226.15 324.5,-221.86 310.94,-217.93\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"312.2,-214.65 301.62,-215.28 310.28,-221.39 312.2,-214.65\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-234.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_paragraph->page_view -->\n",
|
||||||
|
"<g id=\"edge6\" class=\"edge\">\n",
|
||||||
|
"<title>hover_over_paragraph->page_view</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M369.13,-95.76C310.26,-97.17 232.59,-99.41 163.87,-102.58 145.72,-103.42 125.98,-104.58 108.06,-105.73\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"107.86,-102.24 98.1,-106.38 108.31,-109.22 107.86,-102.24\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-106.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.14</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- hover_over_paragraph->view_item_page -->\n",
|
||||||
|
"<g id=\"edge7\" class=\"edge\">\n",
|
||||||
|
"<title>hover_over_paragraph->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M372.68,-119.15C354.84,-125.32 336.5,-132.51 319.89,-140.58 312.9,-143.98 305.81,-147.87 298.86,-151.98\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"297.49,-148.71 290.78,-156.91 301.14,-154.69 297.49,-148.71\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-144.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.86</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0. 1. 0. 0. ]\n",
|
||||||
|
" [0. 0.1875 0.375 0.4375 ]\n",
|
||||||
|
" [0. 1. 0. 0. ]\n",
|
||||||
|
" [0.14285714 0.85714286 0. 0. ]]\n",
|
||||||
|
"d176d7c9-4027-4702-9e31-2a71395cdda0\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"image/svg+xml": [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||||
|
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||||
|
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||||
|
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||||
|
" -->\n",
|
||||||
|
"<!-- Pages: 1 -->\n",
|
||||||
|
"<svg width=\"104pt\" height=\"104pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 104.00 104.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||||
|
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 100.37)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-100.37 100.37,-100.37 100.37,4 -4,4\"/>\n",
|
||||||
|
"<!-- page_view -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>page_view</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-48.19\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-43.51\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[0.]]\n",
|
||||||
|
"f0317a5d-e424-44e9-b784-c8f7291ffe31\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"image/svg+xml": [
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||||
|
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||||
|
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||||
|
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||||
|
" -->\n",
|
||||||
|
"<!-- Pages: 1 -->\n",
|
||||||
|
"<svg width=\"310pt\" height=\"160pt\"\n",
|
||||||
|
" viewBox=\"0.00 0.00 310.00 160.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||||
|
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 156.44)\">\n",
|
||||||
|
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-156.44 305.89,-156.44 305.89,4 -4,4\"/>\n",
|
||||||
|
"<!-- page_view -->\n",
|
||||||
|
"<g id=\"node1\" class=\"node\">\n",
|
||||||
|
"<title>page_view</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-69.01\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- page_view->page_view -->\n",
|
||||||
|
"<g id=\"edge1\" class=\"edge\">\n",
|
||||||
|
"<title>page_view->page_view</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M33.03,-115.09C34.09,-126.6 39.14,-135.19 48.19,-135.19 53.98,-135.19 58.13,-131.66 60.65,-126.1\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"64.01,-127.11 62.98,-116.56 57.21,-125.45 64.01,-127.11\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-139.14\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- view_item_page -->\n",
|
||||||
|
"<g id=\"node2\" class=\"node\">\n",
|
||||||
|
"<title>view_item_page</title>\n",
|
||||||
|
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-69.01\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"<!-- page_view->view_item_page -->\n",
|
||||||
|
"<g id=\"edge2\" class=\"edge\">\n",
|
||||||
|
"<title>page_view->view_item_page</title>\n",
|
||||||
|
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-69.01C113.69,-69.01 133.31,-69.01 152.25,-69.01\"/>\n",
|
||||||
|
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-72.51 162.1,-69.01 152.1,-65.51 152.1,-72.51\"/>\n",
|
||||||
|
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-72.96\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</g>\n",
|
||||||
|
"</svg>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"<graphviz.graphs.Digraph at 0x7f6800bf50f0>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"[[5.0e-001 5.0e-001]\n",
|
||||||
|
" [9.9e-324 1.5e-323]]\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"def explore_session(session_id: str):\n",
|
||||||
|
" subset = df[df['sessionId'] == session_id]\n",
|
||||||
|
" print(session_id)\n",
|
||||||
|
" P, labels = build_transition_prob_matrix(subset)\n",
|
||||||
|
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
||||||
|
" display(g)\n",
|
||||||
|
" return P\n",
|
||||||
|
"for session in sessions:\n",
|
||||||
|
" print(explore_session(session))"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python (PHANTOM)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "phantom"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.13.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from procesing.context import PipelineContext
|
|
||||||
from procesing.providers import DataProvider, SupabaseProvider, BackendAPIProvider
|
|
||||||
from procesing.steps import (
|
|
||||||
BaseContextStep,
|
|
||||||
FetchInteractionsStep,
|
|
||||||
FetchPriceLogsStep,
|
|
||||||
FetchExperimentsStep,
|
|
||||||
JoinExperimentsStep,
|
|
||||||
CreatePriceBucketsStep,
|
|
||||||
AugmentEventNamesStep,
|
|
||||||
ChunkByTimeWindowStep,
|
|
||||||
ComputeDemandStep,
|
|
||||||
ComputeDemandForChunksStep,
|
|
||||||
AggregatePriceLogsStep,
|
|
||||||
ComputeElasticityStep,
|
|
||||||
StateSpace,
|
|
||||||
BuildStateSpaceStep,
|
|
||||||
FitPricingFunctionStep,
|
|
||||||
PredictPricesStep,
|
|
||||||
)
|
|
||||||
from procesing.pipelines import (
|
|
||||||
interaction_extraction_pipeline,
|
|
||||||
price_extraction_pipeline,
|
|
||||||
elasticity_computation_pipeline,
|
|
||||||
pricing_pipeline,
|
|
||||||
full_pipeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'PipelineContext',
|
|
||||||
'DataProvider',
|
|
||||||
'SupabaseProvider',
|
|
||||||
'BackendAPIProvider',
|
|
||||||
'BaseContextStep',
|
|
||||||
'FetchInteractionsStep',
|
|
||||||
'FetchPriceLogsStep',
|
|
||||||
'FetchExperimentsStep',
|
|
||||||
'JoinExperimentsStep',
|
|
||||||
'CreatePriceBucketsStep',
|
|
||||||
'AugmentEventNamesStep',
|
|
||||||
'ChunkByTimeWindowStep',
|
|
||||||
'ComputeDemandStep',
|
|
||||||
'ComputeDemandForChunksStep',
|
|
||||||
'AggregatePriceLogsStep',
|
|
||||||
'ComputeElasticityStep',
|
|
||||||
'StateSpace',
|
|
||||||
'BuildStateSpaceStep',
|
|
||||||
'FitPricingFunctionStep',
|
|
||||||
'PredictPricesStep',
|
|
||||||
'interaction_extraction_pipeline',
|
|
||||||
'price_extraction_pipeline',
|
|
||||||
'elasticity_computation_pipeline',
|
|
||||||
'pricing_pipeline',
|
|
||||||
'full_pipeline',
|
|
||||||
]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,332 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
from supabase import create_client, Client
|
|
||||||
import os
|
|
||||||
|
|
||||||
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 TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
|
||||||
"""
|
|
||||||
Compute price elasticity from time-series demand and price data.
|
|
||||||
|
|
||||||
Elasticity = (% change in quantity) / (% change in price)
|
|
||||||
|
|
||||||
Works with chunked time-window data from ChunkInteractionsIntoSteps.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
method:str='point',
|
|
||||||
min_observations:int=2,
|
|
||||||
smooth_window:Optional[int]=None):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
method: 'point' (point elasticity) or 'arc' (arc elasticity)
|
|
||||||
min_observations: min data points needed per product
|
|
||||||
smooth_window: if set, apply rolling avg smoothing to time series
|
|
||||||
"""
|
|
||||||
self.method = method
|
|
||||||
self.min_observations = min_observations
|
|
||||||
self.smooth_window = smooth_window
|
|
||||||
|
|
||||||
def fit(self, X):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self,
|
|
||||||
demand_chunks: List[Dict],
|
|
||||||
price_chunks: List[Dict],
|
|
||||||
store_mode: str = 'hotel') -> pd.DataFrame:
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
demand_chunks: list from ChunkInteractionsIntoSteps + DemandEstimator
|
|
||||||
each item: {'window_start', 'window_end', 'demand_vector'}
|
|
||||||
price_chunks: list of dicts with {'window_start', 'window_end', 'price_vector'}
|
|
||||||
store_mode: 'hotel' or 'airline' to fetch all products
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
df with [productId, elasticity, std_error, n_observations]
|
|
||||||
"""
|
|
||||||
# fetch all products from database
|
|
||||||
all_products = supabase.table(f'{store_mode}_products').select("id").execute()
|
|
||||||
all_product_ids = [p['id'] for p in all_products.data]
|
|
||||||
|
|
||||||
aligned = self._align_chunks(demand_chunks, price_chunks)
|
|
||||||
if not aligned:
|
|
||||||
# return all products with zero elasticity
|
|
||||||
return pd.DataFrame({
|
|
||||||
'productId': all_product_ids,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# build time series per product
|
|
||||||
product_series = self._build_product_timeseries(aligned)
|
|
||||||
|
|
||||||
# compute elasticity per product
|
|
||||||
elasticities = []
|
|
||||||
for pid, series in product_series.items():
|
|
||||||
if len(series) < self.min_observations:
|
|
||||||
# assign 0 elasticity for products with insufficient data
|
|
||||||
elasticities.append({
|
|
||||||
'productId': pid,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': len(series)
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# apply smoothing if requested
|
|
||||||
if self.smooth_window and len(series) >= self.smooth_window:
|
|
||||||
series = self._smooth_series(series, self.smooth_window)
|
|
||||||
|
|
||||||
elast = self._compute_elasticity(series)
|
|
||||||
elasticities.append({
|
|
||||||
'productId': pid,
|
|
||||||
'elasticity': elast['value'],
|
|
||||||
'std_error': elast.get('std_error', 0.0),
|
|
||||||
'n_obs': len(series)
|
|
||||||
})
|
|
||||||
|
|
||||||
result_df = pd.DataFrame(elasticities)
|
|
||||||
|
|
||||||
# fill in missing products with zero elasticity
|
|
||||||
observed_pids = set(result_df['productId'].unique())
|
|
||||||
missing_pids = [pid for pid in all_product_ids if pid not in observed_pids]
|
|
||||||
|
|
||||||
if missing_pids:
|
|
||||||
missing_df = pd.DataFrame({
|
|
||||||
'productId': missing_pids,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': 0
|
|
||||||
})
|
|
||||||
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
|
||||||
|
|
||||||
return result_df
|
|
||||||
|
|
||||||
def _align_chunks(self, demand_chunks, price_chunks):
|
|
||||||
"""Align demand and price data by matching time windows."""
|
|
||||||
aligned = []
|
|
||||||
|
|
||||||
# create lookup for price chunks by window_start
|
|
||||||
price_lookup = {chunk['window_start']: chunk for chunk in price_chunks}
|
|
||||||
|
|
||||||
for demand_chunk in demand_chunks:
|
|
||||||
window_start = demand_chunk['window_start']
|
|
||||||
if window_start in price_lookup:
|
|
||||||
aligned.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': demand_chunk['window_end'],
|
|
||||||
'demand': demand_chunk['demand_vector'],
|
|
||||||
'prices': price_lookup[window_start]['price_vector']
|
|
||||||
})
|
|
||||||
|
|
||||||
return aligned
|
|
||||||
|
|
||||||
def _build_product_timeseries(self, aligned_chunks):
|
|
||||||
"""Build time series [price, quantity] per product."""
|
|
||||||
# vectorize chunk merging instead of iterating rows
|
|
||||||
all_merged = []
|
|
||||||
for chunk in aligned_chunks:
|
|
||||||
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
|
||||||
merged['timestamp'] = chunk['window_start']
|
|
||||||
all_merged.append(merged[['productId', 'timestamp', 'price', 'demand_score']])
|
|
||||||
|
|
||||||
if not all_merged:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# concat all chunks and group by productId in one pass
|
|
||||||
combined = pd.concat(all_merged, ignore_index=True)
|
|
||||||
series_by_product = {
|
|
||||||
pid: group[['timestamp', 'price', 'demand_score']].rename(
|
|
||||||
columns={'demand_score': 'quantity'}
|
|
||||||
).to_dict('records')
|
|
||||||
for pid, group in combined.groupby('productId')
|
|
||||||
}
|
|
||||||
|
|
||||||
return series_by_product
|
|
||||||
|
|
||||||
def _smooth_series(self, series, window):
|
|
||||||
"""Apply rolling average smoothing."""
|
|
||||||
df = pd.DataFrame(series)
|
|
||||||
df['price_smooth'] = df['price'].rolling(window=window, center=True).mean()
|
|
||||||
df['quantity_smooth'] = df['quantity'].rolling(window=window, center=True).mean()
|
|
||||||
df = df.dropna()
|
|
||||||
|
|
||||||
return [{'timestamp': row['timestamp'],
|
|
||||||
'price': row['price_smooth'],
|
|
||||||
'quantity': row['quantity_smooth']}
|
|
||||||
for _, row in df.iterrows()]
|
|
||||||
|
|
||||||
def _compute_elasticity(self, series):
|
|
||||||
"""Compute elasticity from time series."""
|
|
||||||
if len(series) < 2:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
prices = np.array([s['price'] for s in series])
|
|
||||||
quantities = np.array([s['quantity'] for s in series])
|
|
||||||
|
|
||||||
# filter out zero/negative values
|
|
||||||
valid = (prices > 0) & (quantities > 0)
|
|
||||||
if valid.sum() < 2:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
prices = prices[valid]
|
|
||||||
quantities = quantities[valid]
|
|
||||||
|
|
||||||
if self.method == 'point':
|
|
||||||
return self._point_elasticity(prices, quantities)
|
|
||||||
elif self.method == 'arc':
|
|
||||||
return self._arc_elasticity(prices, quantities)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown method: {self.method}")
|
|
||||||
|
|
||||||
def _point_elasticity(self, prices, quantities):
|
|
||||||
"""
|
|
||||||
Point elasticity using log-log regression.
|
|
||||||
log(Q) = a + b*log(P), elasticity = b
|
|
||||||
"""
|
|
||||||
if len(prices) < 2:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
log_p = np.log(prices)
|
|
||||||
log_q = np.log(quantities)
|
|
||||||
|
|
||||||
# simple linear regression
|
|
||||||
if log_p.std() == 0:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
cov = np.cov(log_p, log_q)[0, 1]
|
|
||||||
var = np.var(log_p)
|
|
||||||
b = cov / var
|
|
||||||
|
|
||||||
# std error estimate (avoid div by zero)
|
|
||||||
if len(prices) <= 2:
|
|
||||||
se_b = 0.0
|
|
||||||
else:
|
|
||||||
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
|
||||||
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
|
||||||
se_b = np.sqrt(mse / (len(prices) * var))
|
|
||||||
|
|
||||||
return {'value': b, 'std_error': se_b}
|
|
||||||
|
|
||||||
def _arc_elasticity(self, prices, quantities):
|
|
||||||
"""
|
|
||||||
Arc elasticity: average of period-over-period elasticities.
|
|
||||||
E_t = (ΔQ/Q_avg) / (ΔP/P_avg)
|
|
||||||
"""
|
|
||||||
elasticities = []
|
|
||||||
|
|
||||||
for i in range(1, len(prices)):
|
|
||||||
p1, p2 = prices[i-1], prices[i]
|
|
||||||
q1, q2 = quantities[i-1], quantities[i]
|
|
||||||
|
|
||||||
p_avg = (p1 + p2) / 2
|
|
||||||
q_avg = (q1 + q2) / 2
|
|
||||||
|
|
||||||
if p_avg == 0 or q_avg == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
delta_p = p2 - p1
|
|
||||||
delta_q = q2 - q1
|
|
||||||
|
|
||||||
if delta_p == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
e = (delta_q / q_avg) / (delta_p / p_avg)
|
|
||||||
elasticities.append(e)
|
|
||||||
|
|
||||||
if not elasticities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'value': np.mean(elasticities),
|
|
||||||
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_price_logs(price_logs: pd.DataFrame,
|
|
||||||
window_size: str = '1H',
|
|
||||||
ts_col: str = 'ts',
|
|
||||||
store_mode : str = 'hotel') -> List[Dict]:
|
|
||||||
"""
|
|
||||||
Recover price vectors treating prices as persistent state changes.
|
|
||||||
|
|
||||||
Prices are set-operations that persist until next change. For each window:
|
|
||||||
- If price logs exist: average all changes within window
|
|
||||||
- If no logs: carry forward last price before window end
|
|
||||||
|
|
||||||
Args:
|
|
||||||
price_logs: df with [productId, price, ts, ...]
|
|
||||||
window_size: time window size matching ChunkInteractionsIntoSteps
|
|
||||||
ts_col: timestamp column name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list of dicts with {'window_start', 'window_end', 'price_vector'}
|
|
||||||
where price_vector is df with [productId, price]
|
|
||||||
"""
|
|
||||||
if price_logs.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = price_logs.copy()
|
|
||||||
|
|
||||||
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'])
|
|
||||||
all_products=supabase.table(f'{store_mode}_products').select("id, room_type, date_index, metadata, availability").execute()
|
|
||||||
all_products = pd.DataFrame(all_products.data)
|
|
||||||
unique_products = all_products['id'].unique()
|
|
||||||
|
|
||||||
# generate windows across data range
|
|
||||||
min_time, max_time = df[ts_col].min(), df[ts_col].max()
|
|
||||||
windows = pd.date_range(
|
|
||||||
start=min_time.floor(window_size),
|
|
||||||
end=max_time,
|
|
||||||
freq=window_size
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
|
|
||||||
for window_start in windows:
|
|
||||||
window_end = window_start + pd.Timedelta(window_size)
|
|
||||||
price_vector = []
|
|
||||||
|
|
||||||
# all products with price history by window_end
|
|
||||||
#historical_products = df[df[ts_col] < window_end]['productId'].unique()
|
|
||||||
historical_products = unique_products.tolist()
|
|
||||||
|
|
||||||
for pid in historical_products:
|
|
||||||
product_data = df[df['productId'] == pid]
|
|
||||||
|
|
||||||
# logs within window
|
|
||||||
in_window = product_data[
|
|
||||||
(product_data[ts_col] >= window_start) &
|
|
||||||
(product_data[ts_col] < window_end)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not in_window.empty:
|
|
||||||
# average changes within window
|
|
||||||
price = in_window['price'].mean()
|
|
||||||
else:
|
|
||||||
# carry forward: last price before window end
|
|
||||||
before_window = product_data[product_data[ts_col] < window_end]
|
|
||||||
if before_window.empty:
|
|
||||||
continue
|
|
||||||
price = before_window['price'].iloc[-1]
|
|
||||||
|
|
||||||
price_vector.append({'productId': pid, 'price': price})
|
|
||||||
|
|
||||||
if price_vector:
|
|
||||||
chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_end,
|
|
||||||
'price_vector': pd.DataFrame(price_vector)
|
|
||||||
})
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
120
experiments/procesing/extract.py
Normal file
120
experiments/procesing/extract.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
def get_data_from_kafka() -> pd.DataFrame:
|
||||||
|
"""fetch all events from backend dump endpoint"""
|
||||||
|
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump")
|
||||||
|
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'])
|
||||||
|
# explode metadata col json
|
||||||
|
if 'metadata' in df.columns:
|
||||||
|
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
||||||
|
df = df.dropna(subset=['eventName'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def join_with_experiments(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def augment_event_titles(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
# 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 extract() -> pd.DataFrame:
|
||||||
|
df = get_data_from_kafka()
|
||||||
|
df = join_with_experiments(df)
|
||||||
|
df = augment_event_titles(df)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class DataExtractor(BaseEstimator, TransformerMixin):
|
||||||
|
def fit(self, X=None, y=None):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
return extract()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
df = extract()
|
||||||
|
print(df.head())
|
||||||
|
print(df.tail())
|
||||||
|
print(df.info())
|
||||||
158
experiments/procesing/mapping.py
Normal file
158
experiments/procesing/mapping.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
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)
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
15
experiments/procesing/pipeline.py
Normal file
15
experiments/procesing/pipeline.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from extract import DataExtractor
|
||||||
|
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
steps = [
|
||||||
|
('data_extraction', DataExtractor()),
|
||||||
|
#('transition_matrix', SessionTransitionProbMatrixTransformer(threshold=0.05)),
|
||||||
|
]
|
||||||
|
pipeline = Pipeline(steps)
|
||||||
|
result = pipeline.fit_transform(None)
|
||||||
|
print(result)
|
||||||
|
print(result.info())
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
from sklearn.pipeline import Pipeline
|
|
||||||
import pandas as pd
|
|
||||||
from procesing.context import PipelineContext
|
|
||||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
|
||||||
from typing import Union
|
|
||||||
from procesing.steps import (
|
|
||||||
FetchInteractionsStep,
|
|
||||||
FetchPriceLogsStep,
|
|
||||||
FetchExperimentsStep,
|
|
||||||
JoinExperimentsStep,
|
|
||||||
CreatePriceBucketsStep,
|
|
||||||
AugmentEventNamesStep,
|
|
||||||
ChunkByTimeWindowStep,
|
|
||||||
ComputeDemandForChunksStep,
|
|
||||||
AggregatePriceLogsStep,
|
|
||||||
ComputeElasticityStep,
|
|
||||||
BuildStateSpaceStep,
|
|
||||||
FitPricingFunctionStep,
|
|
||||||
PredictPricesStep,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 elasticity_computation_pipeline(context: PipelineContext,
|
|
||||||
interactions_df: pd.DataFrame,
|
|
||||||
price_logs_df: pd.DataFrame):
|
|
||||||
"""
|
|
||||||
Compute elasticity from interactions and price logs.
|
|
||||||
Manual orchestration needed for branching logic.
|
|
||||||
"""
|
|
||||||
# branch 1: chunk interactions and compute demand
|
|
||||||
chunk_step = ChunkByTimeWindowStep(context)
|
|
||||||
interaction_chunks = chunk_step.transform(interactions_df)
|
|
||||||
|
|
||||||
demand_step = ComputeDemandForChunksStep(context)
|
|
||||||
demand_chunks = demand_step.transform(interaction_chunks)
|
|
||||||
|
|
||||||
# branch 2: aggregate price logs
|
|
||||||
price_step = AggregatePriceLogsStep(context)
|
|
||||||
price_chunks = price_step.transform(price_logs_df)
|
|
||||||
|
|
||||||
# convergence: compute elasticity
|
|
||||||
elasticity_step = ComputeElasticityStep(context)
|
|
||||||
elasticity_df = elasticity_step.transform((demand_chunks, price_chunks))
|
|
||||||
|
|
||||||
return elasticity_df
|
|
||||||
|
|
||||||
|
|
||||||
def pricing_pipeline(context: PipelineContext, elasticity_df: pd.DataFrame):
|
|
||||||
"""
|
|
||||||
Generate optimal prices from elasticity estimates.
|
|
||||||
"""
|
|
||||||
# build state space
|
|
||||||
state_step = BuildStateSpaceStep(context)
|
|
||||||
state_space = state_step.transform(elasticity_df)
|
|
||||||
|
|
||||||
# fit pricing function
|
|
||||||
fit_step = FitPricingFunctionStep(context)
|
|
||||||
pricer = fit_step.transform(elasticity_df)
|
|
||||||
|
|
||||||
# predict prices
|
|
||||||
predict_step = PredictPricesStep(context)
|
|
||||||
prices_df = predict_step.transform((pricer, state_space))
|
|
||||||
|
|
||||||
return prices_df
|
|
||||||
|
|
||||||
|
|
||||||
def full_pipeline(context: PipelineContext):
|
|
||||||
"""
|
|
||||||
Complete end-to-end pipeline: data extraction -> elasticity -> pricing
|
|
||||||
Returns: (elasticity_df, prices_df)
|
|
||||||
"""
|
|
||||||
# extract interactions
|
|
||||||
interaction_pipe = interaction_extraction_pipeline(context)
|
|
||||||
interactions_df = interaction_pipe.fit_transform(None)
|
|
||||||
|
|
||||||
# extract price logs
|
|
||||||
price_pipe = price_extraction_pipeline(context)
|
|
||||||
price_logs_df = price_pipe.fit_transform(None)
|
|
||||||
|
|
||||||
if interactions_df.empty or price_logs_df.empty:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
# compute elasticity
|
|
||||||
elasticity_df = elasticity_computation_pipeline(
|
|
||||||
context,
|
|
||||||
interactions_df,
|
|
||||||
price_logs_df
|
|
||||||
)
|
|
||||||
|
|
||||||
if elasticity_df is None or elasticity_df.empty:
|
|
||||||
return elasticity_df, None
|
|
||||||
|
|
||||||
# generate prices
|
|
||||||
prices_df = pricing_pipeline(context, elasticity_df)
|
|
||||||
|
|
||||||
return elasticity_df, prices_df
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
|
||||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
|
||||||
def __init__(self, backend_url: str):
|
|
||||||
SupabaseProvider.__init__(self)
|
|
||||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
|
||||||
# example run
|
|
||||||
context = PipelineContext(
|
|
||||||
provider=Provider(backend_url="http://localhost:5000"),
|
|
||||||
store_mode='hotel',
|
|
||||||
)
|
|
||||||
|
|
||||||
elasticity_df, prices_df = full_pipeline(context)
|
|
||||||
|
|
||||||
if elasticity_df is not None and not elasticity_df.empty:
|
|
||||||
print("Elasticity Estimates:")
|
|
||||||
print(elasticity_df.to_string(index=False))
|
|
||||||
else:
|
|
||||||
print("No elasticity estimates computed.")
|
|
||||||
|
|
||||||
if prices_df is not None and not prices_df.empty:
|
|
||||||
print("\nPredicted Prices:")
|
|
||||||
print(prices_df.to_string(index=False))
|
|
||||||
else:
|
|
||||||
print("No prices predicted.")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from procesing.pricers.base import PricingFunction
|
|
||||||
from procesing.pricers.elasticity import ElasticityBasedPricer
|
|
||||||
from procesing.pricers.simple import StaticPricer, RandomPricer
|
|
||||||
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'PricingFunction',
|
|
||||||
'ElasticityBasedPricer',
|
|
||||||
'StaticPricer',
|
|
||||||
'RandomPricer',
|
|
||||||
'SessionAwarePricer',
|
|
||||||
'ProductSpecificSessionPricer'
|
|
||||||
]
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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, historical_data: pd.DataFrame, **kwargs):
|
|
||||||
"""
|
|
||||||
Offline training on historical data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
historical_data: DataFrame with elasticity, prices, demand signals
|
|
||||||
**kwargs: additional training parameters
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def predict(self, state_space) -> 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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
r"""
|
|
||||||
Our state space comes as:
|
|
||||||
$Q_t in R^n$ - our demand at a time t
|
|
||||||
$P_t in R^n$ - prices at time t
|
|
||||||
$S_t$ some form of interaction session features
|
|
||||||
|
|
||||||
This is a single sate which we map under
|
|
||||||
|
|
||||||
$f: (Q, S, H) \to P_{t+1}$
|
|
||||||
|
|
||||||
With:
|
|
||||||
|
|
||||||
$H_t = \{Q_{t-k}, P_{t-k}, S_{t-k}\}$
|
|
||||||
|
|
||||||
|
|
||||||
We can have f be literally anything, analytical or learned or rule based or an RL policy.
|
|
||||||
|
|
||||||
Our goal is to mazimize the expected revenue:
|
|
||||||
|
|
||||||
$E[R_T] = E[\sum_{t=1}^T P_t^T \dot Q_t]$
|
|
||||||
|
|
||||||
subject to Q_t = g(P_t, S_t) : demand response to price (estimated via elasticity) and P_t ≥ C : prices above cost floor and additionally minimizing the following:
|
|
||||||
|
|
||||||
$L_{agent} = R_{oracle} - R_{observed}
|
|
||||||
|
|
||||||
where: R_oracle = revenue if we knew agent intentions (from recon session) and R_observed = revenue under current pricing policy f
|
|
||||||
|
|
||||||
I would start be defning a pricing function interface and standardizing how to train that based on historical data and define how to make it behave for online training (if we do that)
|
|
||||||
|
|
||||||
We also need to develop a solid benchmark with mapping revenue and full KPIs from session interactions to measure differences between different price learning methods
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv()
|
|
||||||
from supabase import create_client, Client
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def expected_revenue(prices: np.ndarray, demand: np.ndarray) -> float:
|
|
||||||
"""Returns: expected revenue R_t = P_t^T * Q_t"""
|
|
||||||
return float(np.dot(prices, demand))
|
|
||||||
|
|
||||||
class StateSpace:
|
|
||||||
def __init__(self,
|
|
||||||
demand : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
|
||||||
prices : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
|
||||||
session_features : pd.DataFrame):
|
|
||||||
self.demand = demand # Q_t
|
|
||||||
self.prices = prices # P_t
|
|
||||||
self.session_features = session_features # S_t
|
|
||||||
self.history = [] # H_t
|
|
||||||
|
|
||||||
class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def fit(self, historical_data):
|
|
||||||
"""
|
|
||||||
Train the pricing function based on historical data.
|
|
||||||
historical_data: list of StateSpace instances with known outcomes
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Train method must be implemented by subclass.")
|
|
||||||
|
|
||||||
def transform(self, state_space) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Predict the next prices given the current state space.
|
|
||||||
state_space: StateSpace instance
|
|
||||||
Returns: predicted prices P_{t+1}
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Predict method must be implemented by subclass.")
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleLinearPricingFunction(PricingFunction):
|
|
||||||
def __init__(self, price_sensitivity: float = -0.1):
|
|
||||||
super().__init__()
|
|
||||||
self.price_sensitivity = price_sensitivity
|
|
||||||
|
|
||||||
def fit(self, historical_data):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, state_space: StateSpace) -> np.ndarray:
|
|
||||||
new_prices = state_space.prices + self.price_sensitivity * state_space.demand
|
|
||||||
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:
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
|
||||||
|
|
||||||
store_mode = 'hotel'
|
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
|
||||||
price_data = price_data_pipeline.fit_transform(None)
|
|
||||||
|
|
||||||
elasticity_df = elasticity_pipeline(interaction_data, price_data, window_size="30s", store_mode=store_mode)
|
|
||||||
|
|
||||||
# fetch all products with base prices from database
|
|
||||||
products_resp = supabase.table(f'{store_mode}_products').select("id, metadata").execute()
|
|
||||||
products_df = pd.DataFrame(products_resp.data)
|
|
||||||
|
|
||||||
# extract base_price from metadata
|
|
||||||
products_df['base_price'] = products_df['metadata'].apply(lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0)
|
|
||||||
products_df = products_df.rename(columns={'id': 'productId'})[['productId', 'base_price']]
|
|
||||||
|
|
||||||
# override with logged prices where available
|
|
||||||
if not price_data.empty:
|
|
||||||
if 'ts' in price_data.columns and not pd.api.types.is_datetime64_any_dtype(price_data['ts']):
|
|
||||||
price_data['ts'] = pd.to_datetime(price_data['ts'])
|
|
||||||
|
|
||||||
# get latest logged price per product
|
|
||||||
price_logs_agg = price_data.sort_values('ts').groupby('productId', as_index=False).last()
|
|
||||||
|
|
||||||
# merge: start with all products (base prices), override with logged prices
|
|
||||||
products_df = products_df.merge(
|
|
||||||
price_logs_agg[['productId', 'price']],
|
|
||||||
on='productId',
|
|
||||||
how='left'
|
|
||||||
)
|
|
||||||
products_df['final_price'] = products_df['price'].fillna(products_df['base_price'])
|
|
||||||
else:
|
|
||||||
products_df['final_price'] = products_df['base_price']
|
|
||||||
|
|
||||||
# merge with elasticity
|
|
||||||
if elasticity_df is not None and not elasticity_df.empty:
|
|
||||||
price_data_merged = products_df[['productId', 'final_price']].merge(
|
|
||||||
elasticity_df[['productId', 'elasticity']],
|
|
||||||
on='productId',
|
|
||||||
how='left'
|
|
||||||
).fillna({'elasticity': 0.0})
|
|
||||||
|
|
||||||
prices = price_data_merged['final_price'].values
|
|
||||||
elasticities = price_data_merged['elasticity'].values
|
|
||||||
else:
|
|
||||||
prices = np.array([])
|
|
||||||
elasticities = np.array([])
|
|
||||||
|
|
||||||
print(elasticities)
|
|
||||||
print(prices)
|
|
||||||
|
|
||||||
state_space = StateSpace(
|
|
||||||
demand=elasticities,
|
|
||||||
prices=prices,
|
|
||||||
session_features=interaction_data
|
|
||||||
)
|
|
||||||
|
|
||||||
pricing_function = SimpleLinearPricingFunction(price_sensitivity=-0.05)
|
|
||||||
pricing_function.fit([]) # No training data for simple model
|
|
||||||
predicted_prices = pricing_function.transform(state_space)
|
|
||||||
|
|
||||||
print("Predicted Prices:", predicted_prices)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from procesing.providers.base import DataProvider
|
|
||||||
from procesing.providers.supabase import SupabaseProvider
|
|
||||||
from procesing.providers.backend import BackendAPIProvider
|
|
||||||
|
|
||||||
__all__ = ['DataProvider', 'SupabaseProvider', 'BackendAPIProvider']
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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'])
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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:
|
|
||||||
resp = self.supabase.table(f'{store_mode}_products').select(
|
|
||||||
"id, room_type, date_index, metadata, availability"
|
|
||||||
).execute()
|
|
||||||
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from procesing.steps.base import BaseContextStep
|
|
||||||
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
|
||||||
from procesing.steps.join import JoinExperimentsStep
|
|
||||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep
|
|
||||||
from procesing.steps.chunk import ChunkByTimeWindowStep
|
|
||||||
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
|
||||||
from procesing.steps.elasticity import AggregatePriceLogsStep, ComputeElasticityStep
|
|
||||||
from procesing.steps.pricing import StateSpace, BuildStateSpaceStep, FitPricingFunctionStep, PredictPricesStep
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'BaseContextStep',
|
|
||||||
'FetchInteractionsStep',
|
|
||||||
'FetchPriceLogsStep',
|
|
||||||
'FetchExperimentsStep',
|
|
||||||
'JoinExperimentsStep',
|
|
||||||
'CreatePriceBucketsStep',
|
|
||||||
'AugmentEventNamesStep',
|
|
||||||
'ChunkByTimeWindowStep',
|
|
||||||
'ComputeDemandStep',
|
|
||||||
'ComputeDemandForChunksStep',
|
|
||||||
'AggregatePriceLogsStep',
|
|
||||||
'ComputeElasticityStep',
|
|
||||||
'StateSpace',
|
|
||||||
'BuildStateSpaceStep',
|
|
||||||
'FitPricingFunctionStep',
|
|
||||||
'PredictPricesStep',
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from procesing.steps.base import BaseContextStep
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
from procesing.context import PipelineContext
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
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: list of price chunks with [productId, price]
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, price_logs_df: pd.DataFrame):
|
|
||||||
if price_logs_df.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = price_logs_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, 'productId'])
|
|
||||||
products = self.context.products
|
|
||||||
unique_products = products['id'].unique()
|
|
||||||
|
|
||||||
# VECTORIZED: group by product, resample by time window, compute mean
|
|
||||||
df_indexed = df.set_index(ts_col)
|
|
||||||
|
|
||||||
windowed = (
|
|
||||||
df_indexed
|
|
||||||
.groupby('productId')['price']
|
|
||||||
.resample(window_size)
|
|
||||||
.mean()
|
|
||||||
.reset_index()
|
|
||||||
)
|
|
||||||
|
|
||||||
# forward fill missing windows (carry last known price)
|
|
||||||
windowed = windowed.sort_values([ts_col, 'productId'])
|
|
||||||
windowed['price'] = windowed.groupby('productId')['price'].ffill()
|
|
||||||
windowed = windowed.dropna(subset=['price'])
|
|
||||||
|
|
||||||
# group into chunks by window
|
|
||||||
chunks = []
|
|
||||||
for window_start, group in windowed.groupby(ts_col):
|
|
||||||
price_vector = group[['productId', 'price']].copy()
|
|
||||||
|
|
||||||
# fill missing products with last known price before this window
|
|
||||||
missing_products = set(unique_products) - set(price_vector['productId'])
|
|
||||||
if missing_products:
|
|
||||||
for pid in missing_products:
|
|
||||||
last_price = df_indexed[
|
|
||||||
(df_indexed['productId'] == pid) &
|
|
||||||
(df_indexed.index < window_start)
|
|
||||||
]['price']
|
|
||||||
|
|
||||||
if not last_price.empty:
|
|
||||||
price_vector = pd.concat([
|
|
||||||
price_vector,
|
|
||||||
pd.DataFrame({'productId': [pid], 'price': [last_price.iloc[-1]]})
|
|
||||||
], ignore_index=True)
|
|
||||||
|
|
||||||
if not price_vector.empty:
|
|
||||||
chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_start + pd.Timedelta(window_size),
|
|
||||||
'price_vector': price_vector
|
|
||||||
})
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
class ComputeElasticityStep(BaseContextStep):
|
|
||||||
"""
|
|
||||||
Compute price elasticity from demand and price chunks.
|
|
||||||
Input: (demand_chunks, price_chunks)
|
|
||||||
Output: elasticity_df [productId, elasticity, std_error, n_obs]
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, chunk_tuple: tuple):
|
|
||||||
demand_chunks, price_chunks = chunk_tuple
|
|
||||||
|
|
||||||
method = self.context.config.get('elasticity_method', 'point')
|
|
||||||
min_obs = self.context.config.get('min_observations', 2)
|
|
||||||
|
|
||||||
products = self.context.products
|
|
||||||
all_product_ids = products['id'].unique()
|
|
||||||
|
|
||||||
# align chunks by window_start
|
|
||||||
aligned = self._align_chunks(demand_chunks, price_chunks)
|
|
||||||
|
|
||||||
if not aligned:
|
|
||||||
return pd.DataFrame({
|
|
||||||
'productId': all_product_ids,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# build time series per product
|
|
||||||
product_series = self._build_timeseries(aligned)
|
|
||||||
|
|
||||||
# compute elasticity per product
|
|
||||||
elasticities = []
|
|
||||||
for pid, series in product_series.items():
|
|
||||||
if len(series) < min_obs:
|
|
||||||
elasticities.append({
|
|
||||||
'productId': pid,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': len(series)
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
elast = self._compute_elasticity(series, method)
|
|
||||||
elasticities.append({
|
|
||||||
'productId': pid,
|
|
||||||
'elasticity': elast['value'],
|
|
||||||
'std_error': elast.get('std_error', 0.0),
|
|
||||||
'n_obs': len(series)
|
|
||||||
})
|
|
||||||
|
|
||||||
result_df = pd.DataFrame(elasticities)
|
|
||||||
|
|
||||||
# fill missing products with zero elasticity
|
|
||||||
observed_pids = set(result_df['productId'])
|
|
||||||
missing_pids = [p for p in all_product_ids if p not in observed_pids]
|
|
||||||
|
|
||||||
if missing_pids:
|
|
||||||
missing_df = pd.DataFrame({
|
|
||||||
'productId': missing_pids,
|
|
||||||
'elasticity': 0.0,
|
|
||||||
'std_error': 0.0,
|
|
||||||
'n_obs': 0
|
|
||||||
})
|
|
||||||
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
|
||||||
|
|
||||||
return result_df
|
|
||||||
|
|
||||||
def _align_chunks(self, demand_chunks: List[Dict], price_chunks: List[Dict]):
|
|
||||||
"""Align demand and price chunks by window_start"""
|
|
||||||
price_lookup = {c['window_start']: c for c in price_chunks}
|
|
||||||
aligned = []
|
|
||||||
|
|
||||||
for dc in demand_chunks:
|
|
||||||
ws = dc['window_start']
|
|
||||||
if ws in price_lookup:
|
|
||||||
aligned.append({
|
|
||||||
'window_start': ws,
|
|
||||||
'window_end': dc['window_end'],
|
|
||||||
'demand': dc['demand_vector'],
|
|
||||||
'prices': price_lookup[ws]['price_vector']
|
|
||||||
})
|
|
||||||
|
|
||||||
return aligned
|
|
||||||
|
|
||||||
def _build_timeseries(self, aligned: List[Dict]):
|
|
||||||
"""Build time series [timestamp, price, quantity] per product"""
|
|
||||||
series_by_product = {}
|
|
||||||
|
|
||||||
for chunk in aligned:
|
|
||||||
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
|
||||||
|
|
||||||
for _, row in merged.iterrows():
|
|
||||||
pid = row['productId']
|
|
||||||
if pid not in series_by_product:
|
|
||||||
series_by_product[pid] = []
|
|
||||||
|
|
||||||
series_by_product[pid].append({
|
|
||||||
'timestamp': chunk['window_start'],
|
|
||||||
'price': row['price'],
|
|
||||||
'quantity': row['demand_score']
|
|
||||||
})
|
|
||||||
|
|
||||||
return series_by_product
|
|
||||||
|
|
||||||
def _compute_elasticity(self, series: List[Dict], method: str):
|
|
||||||
"""Compute point or arc elasticity"""
|
|
||||||
prices = np.array([s['price'] for s in series])
|
|
||||||
quantities = np.array([s['quantity'] for s in series])
|
|
||||||
|
|
||||||
# filter out zero/negative values
|
|
||||||
valid = (prices > 0) & (quantities > 0)
|
|
||||||
if valid.sum() < 2:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
prices = prices[valid]
|
|
||||||
quantities = quantities[valid]
|
|
||||||
|
|
||||||
if method == 'point':
|
|
||||||
return self._point_elasticity(prices, quantities)
|
|
||||||
elif method == 'arc':
|
|
||||||
return self._arc_elasticity(prices, quantities)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown elasticity method: {method}")
|
|
||||||
|
|
||||||
def _point_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
|
||||||
"""Point elasticity via log-log regression: log(Q) = a + b*log(P), elasticity = b"""
|
|
||||||
if len(prices) < 2:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
log_p = np.log(prices)
|
|
||||||
log_q = np.log(quantities)
|
|
||||||
|
|
||||||
if log_p.std() == 0:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
cov = np.cov(log_p, log_q)[0, 1]
|
|
||||||
var = np.var(log_p)
|
|
||||||
b = cov / var
|
|
||||||
|
|
||||||
# std error estimate
|
|
||||||
if len(prices) > 2:
|
|
||||||
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
|
||||||
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
|
||||||
se_b = np.sqrt(mse / (len(prices) * var))
|
|
||||||
else:
|
|
||||||
se_b = 0.0
|
|
||||||
|
|
||||||
return {'value': b, 'std_error': se_b}
|
|
||||||
|
|
||||||
def _arc_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
|
||||||
"""Arc elasticity: average period-over-period elasticity"""
|
|
||||||
elasticities = []
|
|
||||||
|
|
||||||
for i in range(1, len(prices)):
|
|
||||||
p1, p2 = prices[i-1], prices[i]
|
|
||||||
q1, q2 = quantities[i-1], quantities[i]
|
|
||||||
|
|
||||||
p_avg = (p1 + p2) / 2
|
|
||||||
q_avg = (q1 + q2) / 2
|
|
||||||
|
|
||||||
if p_avg == 0 or q_avg == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
delta_p = p2 - p1
|
|
||||||
delta_q = q2 - q1
|
|
||||||
|
|
||||||
if delta_p == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
e = (delta_q / q_avg) / (delta_p / p_avg)
|
|
||||||
elasticities.append(e)
|
|
||||||
|
|
||||||
if not elasticities:
|
|
||||||
return {'value': 0.0, 'std_error': 0.0}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'value': np.mean(elasticities),
|
|
||||||
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
from procesing.steps.base import BaseContextStep
|
|
||||||
|
|
||||||
class FetchInteractionsStep(BaseContextStep):
|
|
||||||
"""Fetch raw interaction data from Kafka topic"""
|
|
||||||
|
|
||||||
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'])
|
|
||||||
|
|
||||||
# Remap dateIndex if present
|
|
||||||
if 'metadata_dateIndex' in df.columns:
|
|
||||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
|
||||||
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class FetchPriceLogsStep(BaseContextStep):
|
|
||||||
"""Fetch price log data from Kafka topic"""
|
|
||||||
|
|
||||||
def transform(self, X=None):
|
|
||||||
return self.context.provider.fetch_kafka_topic('price-logs')
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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')
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from procesing.steps.base import BaseContextStep
|
|
||||||
from procesing.pricers import ElasticityBasedPricer
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StateSpace:
|
|
||||||
"""
|
|
||||||
State representation for pricing functions.
|
|
||||||
|
|
||||||
Components:
|
|
||||||
Q_t: demand ∈ R^n (current demand signal per product)
|
|
||||||
P_t: prices ∈ R^n (current/base prices)
|
|
||||||
S_t: session_features (behavioral signals, interaction data)
|
|
||||||
H_t: history = {Q_{t-k}, P_{t-k}, S_{t-k}} for k in [1, history_length]
|
|
||||||
|
|
||||||
Additionally stores:
|
|
||||||
- product_ids: product identifiers (n,)
|
|
||||||
- elasticity: price elasticity per product (n,)
|
|
||||||
- metadata: arbitrary context (experiment_id, timestamp, etc.)
|
|
||||||
"""
|
|
||||||
demand: np.ndarray # Q_t ∈ R^n
|
|
||||||
prices: np.ndarray # P_t ∈ R^n
|
|
||||||
session_features: pd.DataFrame = field(default_factory=pd.DataFrame) # S_t
|
|
||||||
|
|
||||||
# augmented state components
|
|
||||||
product_ids: Optional[np.ndarray] = None
|
|
||||||
elasticity: Optional[np.ndarray] = None
|
|
||||||
|
|
||||||
# historical trajectory H_t = {(Q_{t-k}, P_{t-k}, S_{t-k})}
|
|
||||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
# metadata for context
|
|
||||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Validate dimensions."""
|
|
||||||
n = len(self.demand)
|
|
||||||
assert len(self.prices) == n, "demand and prices must have same dimension"
|
|
||||||
if self.elasticity is not None:
|
|
||||||
assert len(self.elasticity) == n, "elasticity must match dimension"
|
|
||||||
if self.product_ids is not None:
|
|
||||||
assert len(self.product_ids) == n, "product_ids must match dimension"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def n_products(self) -> int:
|
|
||||||
"""Number of products in state space."""
|
|
||||||
return len(self.demand)
|
|
||||||
|
|
||||||
def add_history(self, q: np.ndarray, p: np.ndarray, s: pd.DataFrame, max_length: int = 10):
|
|
||||||
"""Append historical state to trajectory H_t."""
|
|
||||||
self.history.append({'demand': q, 'prices': p, 'session_features': s})
|
|
||||||
if len(self.history) > max_length:
|
|
||||||
self.history.pop(0)
|
|
||||||
|
|
||||||
def get_history_window(self, k: int = 5) -> List[Dict[str, Any]]:
|
|
||||||
"""Retrieve last k historical states."""
|
|
||||||
return self.history[-k:] if len(self.history) >= k else self.history
|
|
||||||
|
|
||||||
|
|
||||||
class BuildStateSpaceStep(BaseContextStep):
|
|
||||||
"""
|
|
||||||
Build state space from elasticity, demand, and price data.
|
|
||||||
|
|
||||||
Input: elasticity_df [productId, elasticity, ...], optional demand_df
|
|
||||||
Output: StateSpace instance with Q_t, P_t, elasticity, product_ids
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, elasticity_df: pd.DataFrame, demand_df: Optional[pd.DataFrame] = None):
|
|
||||||
products = self.context.products
|
|
||||||
|
|
||||||
# extract base prices from product metadata
|
|
||||||
products_with_prices = products.copy()
|
|
||||||
if 'metadata' in products_with_prices.columns:
|
|
||||||
products_with_prices['base_price'] = products_with_prices['metadata'].apply(
|
|
||||||
lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
products_with_prices['base_price'] = 0
|
|
||||||
|
|
||||||
# merge with elasticity
|
|
||||||
merged = products_with_prices[['id', 'base_price']].rename(
|
|
||||||
columns={'id': 'productId'}
|
|
||||||
).merge(
|
|
||||||
elasticity_df[['productId', 'elasticity']],
|
|
||||||
on='productId',
|
|
||||||
how='left'
|
|
||||||
).fillna({'elasticity': 0.0, 'base_price': 0.0})
|
|
||||||
|
|
||||||
# merge with demand if provided, else use default
|
|
||||||
if demand_df is not None and 'demand' in demand_df.columns:
|
|
||||||
merged = merged.merge(
|
|
||||||
demand_df[['productId', 'demand']],
|
|
||||||
on='productId',
|
|
||||||
how='left'
|
|
||||||
).fillna({'demand': 0.0})
|
|
||||||
demand_vector = merged['demand'].values
|
|
||||||
else:
|
|
||||||
# default: uniform demand or use elasticity as proxy
|
|
||||||
demand_vector = np.ones(len(merged)) * 10.0
|
|
||||||
|
|
||||||
return StateSpace(
|
|
||||||
demand=demand_vector,
|
|
||||||
prices=merged['base_price'].values,
|
|
||||||
session_features=pd.DataFrame(),
|
|
||||||
product_ids=merged['productId'].values,
|
|
||||||
elasticity=merged['elasticity'].values,
|
|
||||||
metadata={'timestamp': pd.Timestamp.now().isoformat()}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FitPricingFunctionStep(BaseContextStep):
|
|
||||||
"""
|
|
||||||
Fit pricing function using elasticity data.
|
|
||||||
Input: elasticity_df
|
|
||||||
Output: fitted pricing function instance
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, elasticity_df: pd.DataFrame):
|
|
||||||
pricing_class = self.context.config.get('pricing_function_class', ElasticityBasedPricer)
|
|
||||||
pricing_params = self.context.config.get('pricing_function_params', {})
|
|
||||||
|
|
||||||
pricer = pricing_class(**pricing_params)
|
|
||||||
pricer.fit(elasticity_df)
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""
|
|
||||||
Session feature extraction for S_t component of state space.
|
|
||||||
Computes behavioral signals from interaction data already in pipeline.
|
|
||||||
"""
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from collections import Counter
|
|
||||||
from procesing.steps.base import BaseContextStep
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractSessionFeaturesStep(BaseContextStep):
|
|
||||||
"""
|
|
||||||
Extract session-level behavioral features from interaction logs.
|
|
||||||
|
|
||||||
Input: interactions_df (user-interactions from earlier pipeline step)
|
|
||||||
Output: session_features DataFrame [sessionId, feature_1, feature_2, ...]
|
|
||||||
|
|
||||||
Features computed:
|
|
||||||
- total_interactions: count of all events
|
|
||||||
- page_views, item_views, searches, cart_adds: event type counts
|
|
||||||
- hovers: hover event counts
|
|
||||||
- unique_products_viewed: distinct product IDs
|
|
||||||
- interaction_velocity: events per minute
|
|
||||||
- session_duration_sec: time span of session
|
|
||||||
- avg_time_between_events: mean inter-event time
|
|
||||||
- product_view_depth: max views for single product (attention signal)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, interactions_df: pd.DataFrame) -> pd.DataFrame:
|
|
||||||
if interactions_df.empty:
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
# ensure timestamp column
|
|
||||||
if 'ts' in interactions_df.columns:
|
|
||||||
interactions_df = interactions_df.copy()
|
|
||||||
interactions_df['ts'] = pd.to_datetime(interactions_df['ts'])
|
|
||||||
|
|
||||||
# group by session and compute features
|
|
||||||
session_features = []
|
|
||||||
for session_id, session_df in interactions_df.groupby('sessionId'):
|
|
||||||
features = self._extract_features_for_session(session_id, session_df)
|
|
||||||
session_features.append(features)
|
|
||||||
|
|
||||||
return pd.DataFrame(session_features)
|
|
||||||
|
|
||||||
def _extract_features_for_session(self, session_id: str, session_df: pd.DataFrame) -> Dict[str, Any]:
|
|
||||||
"""Compute features for single session."""
|
|
||||||
features = {'sessionId': session_id}
|
|
||||||
|
|
||||||
# basic counts
|
|
||||||
features['total_interactions'] = len(session_df)
|
|
||||||
|
|
||||||
event_counts = session_df['eventName'].value_counts().to_dict()
|
|
||||||
features['page_views'] = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
|
||||||
features['item_views'] = event_counts.get('view_item_page', 0)
|
|
||||||
features['searches'] = event_counts.get('search', 0)
|
|
||||||
features['cart_adds'] = event_counts.get('add_item_to_cart', 0)
|
|
||||||
|
|
||||||
# hover events
|
|
||||||
hover_events = ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button']
|
|
||||||
features['hovers'] = sum(event_counts.get(ev, 0) for ev in hover_events)
|
|
||||||
|
|
||||||
# product-level signals
|
|
||||||
product_ids = session_df['productId'].dropna()
|
|
||||||
features['unique_products_viewed'] = product_ids.nunique()
|
|
||||||
|
|
||||||
if len(product_ids) > 0:
|
|
||||||
product_view_counts = Counter(product_ids)
|
|
||||||
features['product_view_depth'] = max(product_view_counts.values())
|
|
||||||
else:
|
|
||||||
features['product_view_depth'] = 0
|
|
||||||
|
|
||||||
# temporal features
|
|
||||||
if 'ts' in session_df.columns:
|
|
||||||
timestamps = session_df['ts'].sort_values()
|
|
||||||
features['session_duration_sec'] = (timestamps.max() - timestamps.min()).total_seconds()
|
|
||||||
|
|
||||||
if features['session_duration_sec'] > 0:
|
|
||||||
features['interaction_velocity'] = (features['total_interactions'] / features['session_duration_sec']) * 60
|
|
||||||
else:
|
|
||||||
features['interaction_velocity'] = 0.0
|
|
||||||
|
|
||||||
# inter-event timing
|
|
||||||
if len(timestamps) > 1:
|
|
||||||
time_diffs = timestamps.diff().dropna().dt.total_seconds()
|
|
||||||
features['avg_time_between_events'] = time_diffs.mean()
|
|
||||||
features['std_time_between_events'] = time_diffs.std()
|
|
||||||
else:
|
|
||||||
features['avg_time_between_events'] = 0.0
|
|
||||||
features['std_time_between_events'] = 0.0
|
|
||||||
else:
|
|
||||||
features['session_duration_sec'] = 0.0
|
|
||||||
features['interaction_velocity'] = 0.0
|
|
||||||
features['avg_time_between_events'] = 0.0
|
|
||||||
features['std_time_between_events'] = 0.0
|
|
||||||
|
|
||||||
# cart/conversion signals
|
|
||||||
features['cart_to_view_ratio'] = features['cart_adds'] / features['item_views'] if features['item_views'] > 0 else 0.0
|
|
||||||
|
|
||||||
return features
|
|
||||||
|
|
||||||
|
|
||||||
class FilterSessionInteractionsStep(BaseContextStep):
|
|
||||||
"""
|
|
||||||
Filter interactions DataFrame to specific session.
|
|
||||||
|
|
||||||
Input: (interactions_df, session_id)
|
|
||||||
Output: interactions_df filtered to session_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
def transform(self, data: tuple) -> pd.DataFrame:
|
|
||||||
interactions_df, session_id = data
|
|
||||||
return interactions_df[interactions_df['sessionId'] == session_id].copy()
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
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': 'shop',
|
|
||||||
'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': 'shop',
|
|
||||||
'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': 'shop',
|
|
||||||
'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': 'shop',
|
|
||||||
'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': 'shop',
|
|
||||||
'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', 'shop'],
|
|
||||||
'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'
|
|
||||||
)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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']
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
from procesing.steps import (
|
|
||||||
AggregatePriceLogsStep,
|
|
||||||
ComputeElasticityStep
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_aggregate_price_logs_basic(pipeline_context):
|
|
||||||
"""Test basic price aggregation into time windows"""
|
|
||||||
step = AggregatePriceLogsStep(pipeline_context)
|
|
||||||
|
|
||||||
# Create price logs with known window structure
|
|
||||||
df = pd.DataFrame({
|
|
||||||
'ts': pd.date_range(start='2023-01-01 10:00:00', periods=100, freq='10s'),
|
|
||||||
'productId': np.tile([
|
|
||||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
|
||||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
|
||||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
|
||||||
], 34)[:100],
|
|
||||||
'price': np.random.uniform(100, 200, 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
result = step.transform(df)
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) > 0
|
|
||||||
# each chunk should have window metadata and price vector
|
|
||||||
for chunk in result:
|
|
||||||
assert 'window_start' in chunk
|
|
||||||
assert 'window_end' in chunk
|
|
||||||
assert 'price_vector' in chunk
|
|
||||||
assert isinstance(chunk['price_vector'], pd.DataFrame)
|
|
||||||
assert 'productId' in chunk['price_vector'].columns
|
|
||||||
assert 'price' in chunk['price_vector'].columns
|
|
||||||
|
|
||||||
|
|
||||||
def test_aggregate_price_logs_handles_gaps(pipeline_context):
|
|
||||||
"""Test that price aggregation forward-fills missing windows"""
|
|
||||||
step = AggregatePriceLogsStep(pipeline_context)
|
|
||||||
|
|
||||||
# create sparse data with gaps
|
|
||||||
df = pd.DataFrame({
|
|
||||||
'ts': pd.to_datetime([
|
|
||||||
'2023-01-01 10:00:00',
|
|
||||||
'2023-01-01 10:00:05',
|
|
||||||
'2023-01-01 10:02:00', # gap of ~2 mins
|
|
||||||
'2023-01-01 10:02:30'
|
|
||||||
]),
|
|
||||||
'productId': [
|
|
||||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
|
||||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
|
||||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
|
||||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11'
|
|
||||||
],
|
|
||||||
'price': [100, 102, 150, 153]
|
|
||||||
})
|
|
||||||
|
|
||||||
result = step.transform(df)
|
|
||||||
assert isinstance(result, list)
|
|
||||||
# should have multiple windows despite gaps
|
|
||||||
assert len(result) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_elasticity_with_known_relationship(pipeline_context):
|
|
||||||
"""Test elasticity computation with known price-demand relationship"""
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
# simulate elastic demand: when price ↑10%, demand ↓15% (elasticity ~ -1.5)
|
|
||||||
base_price = 100
|
|
||||||
base_demand = 50
|
|
||||||
|
|
||||||
demand_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [base_demand]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [base_demand * 0.85] # 15% decrease
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [base_demand * 0.70] # further decrease
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
price_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [base_price]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [base_price * 1.10] # 10% increase
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [base_price * 1.20] # 20% increase
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
assert isinstance(result, pd.DataFrame)
|
|
||||||
assert not result.empty
|
|
||||||
assert 'productId' in result.columns
|
|
||||||
assert 'elasticity' in result.columns
|
|
||||||
assert 'n_obs' in result.columns
|
|
||||||
|
|
||||||
# check elasticity is negative (normal good)
|
|
||||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
|
||||||
assert len(product_elast) == 1
|
|
||||||
assert product_elast.iloc[0]['elasticity'] < 0
|
|
||||||
# should be roughly elastic (< -1)
|
|
||||||
assert product_elast.iloc[0]['n_obs'] == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_elasticity_inelastic_product(pipeline_context):
|
|
||||||
"""Test with inelastic demand: price changes, demand barely moves"""
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
base_price = 150
|
|
||||||
base_demand = 40
|
|
||||||
|
|
||||||
demand_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
|
||||||
'demand_score': [base_demand]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
|
||||||
'demand_score': [base_demand * 0.98] # tiny 2% decrease
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
price_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
|
||||||
'price': [base_price]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
|
||||||
'price': [base_price * 1.20] # 20% increase
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
product_elast = result[result['productId'] == '51266ddb-5b07-47b7-89ee-5b5cae94bb11']
|
|
||||||
assert len(product_elast) == 1
|
|
||||||
# inelastic: elasticity between 0 and -1
|
|
||||||
assert -1 < product_elast.iloc[0]['elasticity'] < 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_elasticity_multiple_products(pipeline_context):
|
|
||||||
"""Test elasticity computation across multiple products simultaneously"""
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
products = [
|
|
||||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
|
||||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
|
||||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
|
||||||
]
|
|
||||||
|
|
||||||
# create 5 time windows with all 3 products
|
|
||||||
demand_chunks = []
|
|
||||||
price_chunks = []
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
ts = pd.Timestamp('2023-01-01 10:00:00') + pd.Timedelta(f'{i*30}s')
|
|
||||||
|
|
||||||
demand_chunks.append({
|
|
||||||
'window_start': ts,
|
|
||||||
'window_end': ts + pd.Timedelta('30s'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': products,
|
|
||||||
'demand_score': [
|
|
||||||
50 * (0.9 ** i), # elastic: decreases as price rises
|
|
||||||
40 * (0.98 ** i), # inelastic: barely changes
|
|
||||||
30 * (0.85 ** i) # very elastic
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
price_chunks.append({
|
|
||||||
'window_start': ts,
|
|
||||||
'window_end': ts + pd.Timedelta('30s'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': products,
|
|
||||||
'price': [
|
|
||||||
100 * (1.05 ** i),
|
|
||||||
150 * (1.10 ** i),
|
|
||||||
120 * (1.08 ** i)
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
assert isinstance(result, pd.DataFrame)
|
|
||||||
assert len(result) == 3 # all products should have elasticity
|
|
||||||
assert set(result['productId']) == set(products)
|
|
||||||
assert all(result['n_obs'] == 5)
|
|
||||||
assert all(result['elasticity'] < 0) # all normal goods
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_elasticity_insufficient_data(pipeline_context):
|
|
||||||
"""Test behavior with insufficient observations"""
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
# only 1 observation
|
|
||||||
demand_chunks = [{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [50]
|
|
||||||
})
|
|
||||||
}]
|
|
||||||
|
|
||||||
price_chunks = [{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [100]
|
|
||||||
})
|
|
||||||
}]
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
# should still return result but with low n_obs
|
|
||||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
|
||||||
assert len(product_elast) == 1
|
|
||||||
assert product_elast.iloc[0]['n_obs'] == 1
|
|
||||||
assert product_elast.iloc[0]['elasticity'] == 0.0 # not enough data
|
|
||||||
|
|
||||||
|
|
||||||
def test_compute_elasticity_misaligned_chunks(pipeline_context):
|
|
||||||
"""Test with non-overlapping demand and price windows"""
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
demand_chunks = [{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [50]
|
|
||||||
})
|
|
||||||
}]
|
|
||||||
|
|
||||||
price_chunks = [{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 11:00:00'), # different time
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 11:00:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [100]
|
|
||||||
})
|
|
||||||
}]
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
# should handle gracefully with no aligned data
|
|
||||||
assert isinstance(result, pd.DataFrame)
|
|
||||||
assert all(result['n_obs'] == 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_elasticity_arc_method(pipeline_context):
|
|
||||||
"""Test arc elasticity computation method"""
|
|
||||||
# configure context for arc method
|
|
||||||
pipeline_context.config['elasticity_method'] = 'arc'
|
|
||||||
step = ComputeElasticityStep(pipeline_context)
|
|
||||||
|
|
||||||
demand_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [100]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'demand_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'demand_score': [80]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
price_chunks = [
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [100]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
|
||||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
|
||||||
'price_vector': pd.DataFrame({
|
|
||||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
|
||||||
'price': [110]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = step.transform((demand_chunks, price_chunks))
|
|
||||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
|
||||||
assert len(product_elast) == 1
|
|
||||||
assert product_elast.iloc[0]['elasticity'] < 0
|
|
||||||
# reset config
|
|
||||||
pipeline_context.config['elasticity_method'] = 'point'
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import random
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
|
||||||
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
|
||||||
|
|
||||||
if not SUPABASE_SERVICE_KEY:
|
|
||||||
log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment")
|
|
||||||
raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations")
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
|
||||||
|
|
||||||
DAYS = 14
|
|
||||||
|
|
||||||
# hotel room configurations
|
|
||||||
ROOMS = {
|
|
||||||
"Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4},
|
|
||||||
"Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3},
|
|
||||||
"Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2},
|
|
||||||
"Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2},
|
|
||||||
"Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2},
|
|
||||||
"Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
# flight configurations
|
|
||||||
FLIGHTS = {
|
|
||||||
"JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250},
|
|
||||||
"JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850},
|
|
||||||
"ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180},
|
|
||||||
"SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420},
|
|
||||||
"ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600},
|
|
||||||
"LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120},
|
|
||||||
"MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380},
|
|
||||||
"DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160},
|
|
||||||
"SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720},
|
|
||||||
"LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850},
|
|
||||||
}
|
|
||||||
|
|
||||||
def gen_hotel_products():
|
|
||||||
"""generate hotel room products for next DAYS days"""
|
|
||||||
data = []
|
|
||||||
for day in range(DAYS):
|
|
||||||
for room_type, rdata in ROOMS.items():
|
|
||||||
data.append({
|
|
||||||
'room_type': room_type,
|
|
||||||
'date_index': day + 1,
|
|
||||||
'metadata': rdata,
|
|
||||||
'availability': random.randint(0, rdata['total'])
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
|
|
||||||
def gen_airline_products():
|
|
||||||
"""generate flight products for next DAYS days"""
|
|
||||||
data = []
|
|
||||||
for day in range(DAYS):
|
|
||||||
for flight_type, fdata in FLIGHTS.items():
|
|
||||||
data.append({
|
|
||||||
'flight_type': flight_type,
|
|
||||||
'date_index': day + 1,
|
|
||||||
'metadata': fdata,
|
|
||||||
'availability': random.randint(0, fdata['total'])
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
|
|
||||||
def clear_table(table_name: str):
|
|
||||||
"""clear all records from a table"""
|
|
||||||
try:
|
|
||||||
resp = supabase.table(table_name).select('id').execute()
|
|
||||||
if resp.data:
|
|
||||||
ids = [row['id'] for row in resp.data]
|
|
||||||
chunk_size = 100
|
|
||||||
for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"):
|
|
||||||
chunk = ids[i:i+chunk_size]
|
|
||||||
supabase.table(table_name).delete().in_('id', chunk).execute()
|
|
||||||
log.info(f"Deleted {len(ids)} records from {table_name}")
|
|
||||||
else:
|
|
||||||
log.info(f"{table_name} already empty")
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to clear {table_name}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def seed_table(table_name: str, data: list[dict]):
|
|
||||||
"""insert records into a table"""
|
|
||||||
try:
|
|
||||||
chunk_size = 100
|
|
||||||
total = len(data)
|
|
||||||
for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"):
|
|
||||||
chunk = data[i:i+chunk_size]
|
|
||||||
supabase.table(table_name).insert(chunk).execute()
|
|
||||||
log.info(f"Inserted {total} records into {table_name}")
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to seed {table_name}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def main():
|
|
||||||
|
|
||||||
log.info("Generating hotel products...")
|
|
||||||
hotel_products = gen_hotel_products()
|
|
||||||
log.info(f"Generated {len(hotel_products)} hotel products")
|
|
||||||
|
|
||||||
log.info("Generating airline products...")
|
|
||||||
airline_products = gen_airline_products()
|
|
||||||
log.info(f"Generated {len(airline_products)} airline products\n")
|
|
||||||
|
|
||||||
log.info("Clearing existing products...")
|
|
||||||
clear_table('hotel_products')
|
|
||||||
clear_table('airline_products')
|
|
||||||
|
|
||||||
log.info("Seeding products...")
|
|
||||||
seed_table('hotel_products', hotel_products)
|
|
||||||
seed_table('airline_products', airline_products)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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:"
|
|
||||||
|
|
||||||
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 health_check(self) -> bool:
|
|
||||||
"""Check if Redis connection is alive."""
|
|
||||||
try:
|
|
||||||
self.redis_client.ping()
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
@@ -21,10 +21,7 @@ add_file() {
|
|||||||
# Add section header and code listing (no language-specific highlighting)
|
# Add section header and code listing (no language-specific highlighting)
|
||||||
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
||||||
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
||||||
# Convert to ASCII: transliterate what's possible, drop the rest
|
cat "$filepath" >> "$OUTPUT_FILE"
|
||||||
# LC_ALL=C forces ASCII locale for consistent behavior across environments
|
|
||||||
LC_ALL=C iconv -f UTF-8 -t ASCII//TRANSLIT//IGNORE "$filepath" 2>/dev/null >> "$OUTPUT_FILE" || \
|
|
||||||
LC_ALL=C tr -cd '\11\12\15\40-\176' < "$filepath" >> "$OUTPUT_FILE"
|
|
||||||
echo "" >> "$OUTPUT_FILE"
|
echo "" >> "$OUTPUT_FILE"
|
||||||
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
||||||
echo "" >> "$OUTPUT_FILE"
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = experiments
|
|
||||||
testpaths = experiments
|
testpaths = experiments
|
||||||
python_files = test*.py
|
python_files = test*.py
|
||||||
python_classes = Test*
|
python_classes = Test*
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
|
||||||
import { useCart } from '@/contexts/CartContext';
|
|
||||||
import AirlineDetails from '@/components/feats/airline/AirlineDetails';
|
|
||||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
|
||||||
const e = new CustomEvent('definedInteraction', {
|
|
||||||
detail: { eventName, productId, metadata },
|
|
||||||
});
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AirlineProductPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const { addItem } = useCart();
|
|
||||||
const [product, setProduct] = useState<Flight | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [added, setAdded] = useState(false);
|
|
||||||
|
|
||||||
const productId = params.id as string;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProduct = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/products/${productId}`);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
const transformed = transformProduct(json.data as AirlineProduct);
|
|
||||||
setProduct(transformed);
|
|
||||||
|
|
||||||
// fire learn_more_about_item event when product loads
|
|
||||||
dispatchInteraction('learn_more_about_item', productId, {
|
|
||||||
type: 'airline',
|
|
||||||
dateIndex: transformed.dateIndex,
|
|
||||||
flightType: transformed.flightType,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load product');
|
|
||||||
console.error('[FETCH_FLIGHT_ERROR]', e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchProduct();
|
|
||||||
}, [productId]);
|
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
|
||||||
if (!product) return;
|
|
||||||
|
|
||||||
addItem({
|
|
||||||
id: productId,
|
|
||||||
type: 'airline',
|
|
||||||
name: product.flightType,
|
|
||||||
price: product.basePrice,
|
|
||||||
metadata: {
|
|
||||||
departure: product.departure,
|
|
||||||
arrival: product.arrival,
|
|
||||||
duration: product.duration,
|
|
||||||
cabinClass: product.cabinClass,
|
|
||||||
},
|
|
||||||
dateIndex: product.dateIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchInteraction('add_item_to_cart', productId, {
|
|
||||||
type: 'airline',
|
|
||||||
price: product.basePrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
setAdded(true);
|
|
||||||
setTimeout(() => setAdded(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
{loading && <div className="text-center py-8">Loading flight details...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
|
|
||||||
{!loading && !error && product && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="mt-6 text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
← Back to flights
|
|
||||||
</button>
|
|
||||||
<AirlineDetails
|
|
||||||
product={product}
|
|
||||||
onAddToCart={handleAddToCart}
|
|
||||||
addedToCart={added}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,73 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
function FlightsList() {
|
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||||
const searchParams = useSearchParams();
|
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||||
const [flights, setFlights] = useState<Flight[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
interface Flight {
|
||||||
const fetchFlights = async () => {
|
id: string;
|
||||||
try {
|
departure: { time: string; airport: string };
|
||||||
const url = new URL('/api/products', window.location.origin);
|
arrival: { time: string; airport: string };
|
||||||
url.searchParams.set('type', 'airline');
|
duration: string;
|
||||||
|
stops: number;
|
||||||
|
cabinClass: CabinClass;
|
||||||
|
fareRule: FareRule;
|
||||||
|
refundable: boolean;
|
||||||
|
basePrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
// forward all relevant search params to the API
|
const genRandomFlights = (): Flight[] => {
|
||||||
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
const airports = ['JFK', 'LAX', 'ORD', 'ATL', 'DFW', 'SFO', 'SEA', 'MIA'];
|
||||||
params.forEach(param => {
|
const cabins: CabinClass[] = ['economy', 'premium', 'business', 'first'];
|
||||||
const val = searchParams.get(param);
|
const fareRules: FareRule[] = ['flexible', 'standard', 'basic'];
|
||||||
if (val) url.searchParams.set(param, val);
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
return Array.from({ length: 12 }, (_, i) => {
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
const depHour = Math.floor(Math.random() * 24);
|
||||||
const json = await res.json();
|
const arrHour = (depHour + Math.floor(Math.random() * 6) + 2) % 24;
|
||||||
const transformed = json.data.map((p: AirlineProduct) => transformProduct(p));
|
const stops = Math.random() > 0.6 ? 0 : Math.floor(Math.random() * 2) + 1;
|
||||||
setFlights(transformed);
|
const cabin = cabins[Math.floor(Math.random() * cabins.length)];
|
||||||
} catch (e) {
|
const fareRule = fareRules[Math.floor(Math.random() * fareRules.length)];
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
|
||||||
console.error('[FETCH_ERROR]', e);
|
const basePrice = Math.floor(
|
||||||
} finally {
|
(cabin === 'economy' ? 200 : cabin === 'premium' ? 400 : cabin === 'business' ? 800 : 1500) +
|
||||||
setLoading(false);
|
Math.random() * 300
|
||||||
}
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `flt-${i}`,
|
||||||
|
departure: {
|
||||||
|
time: `${depHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||||
|
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
time: `${arrHour.toString().padStart(2, '0')}:${Math.floor(Math.random() * 60).toString().padStart(2, '0')}`,
|
||||||
|
airport: airports[Math.floor(Math.random() * airports.length)],
|
||||||
|
},
|
||||||
|
duration: `${Math.floor(Math.random() * 5) + 2}h ${Math.floor(Math.random() * 60)}m`,
|
||||||
|
stops,
|
||||||
|
cabinClass: cabin,
|
||||||
|
fareRule,
|
||||||
|
refundable: Math.random() > 0.7,
|
||||||
|
basePrice,
|
||||||
};
|
};
|
||||||
fetchFlights();
|
});
|
||||||
}, [searchParams]);
|
};
|
||||||
|
|
||||||
|
export default function AirlineProducts() {
|
||||||
|
const flights = genRandomFlights();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
<Navigation />
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
<h1 className="text-3xl font-bold mb-6">Available Flights</h1>
|
||||||
{!loading && !error && (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{flights.map((f) => (
|
{flights.map((f) => (
|
||||||
<AirlineCard key={f.id} flight={f} />
|
<AirlineCard key={f.id} flight={f} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AirlineProducts() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
|
||||||
<FlightsList />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export async function GET(req: NextRequest) {
|
|||||||
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 || 'shop';
|
||||||
|
|
||||||
|
// log in dev
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('[pricing-api]', {
|
||||||
|
productId,
|
||||||
|
sessionId,
|
||||||
|
experimentId,
|
||||||
|
storeMode,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'productId is required' },
|
{ error: 'productId is required' },
|
||||||
@@ -20,73 +31,14 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
// stub: call external pricing provider (random for now)
|
||||||
let price: number;
|
const basePrice = 100 + Math.random() * 900; // 100-1000 range
|
||||||
let basePrice: number | undefined;
|
const price = Math.round(basePrice * 100) / 100;
|
||||||
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
|
|
||||||
if (sessionId) {
|
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
|
||||||
try {
|
|
||||||
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
productId,
|
|
||||||
price,
|
|
||||||
sessionId,
|
|
||||||
experimentId: experimentId || undefined,
|
|
||||||
storeMode,
|
|
||||||
ts: timestamp,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[price-log-error]', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[pricing-api]', {
|
|
||||||
productId, sessionId, experimentId, storeMode,
|
|
||||||
price, basePrice, markup, elasticity, timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: PricingResponse = {
|
const response: PricingResponse = {
|
||||||
price,
|
price,
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
cachedAt: timestamp,
|
cachedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'product id is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
|
||||||
const url = new URL(`${backendUrl}/api/products/${id}`);
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Backend returned ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PRODUCT_DETAIL_ERROR]', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch product details' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const type = searchParams.get('type');
|
|
||||||
|
|
||||||
if (!type || !['hotel', 'airline'].includes(type)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'type parameter must be "hotel" or "airline"' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
|
||||||
const url = new URL(`${backendUrl}/api/products/type/${type}`);
|
|
||||||
|
|
||||||
// forward all query params to backend (excluding 'type')
|
|
||||||
searchParams.forEach((value, key) => {
|
|
||||||
if (key !== 'type') {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Backend returned ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PRODUCTS_PROXY_ERROR]', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch products' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Navigation } from '@/components/ui';
|
|
||||||
import { useCart } from '@/contexts/CartContext';
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
|
||||||
const e = new CustomEvent('definedInteraction', {
|
|
||||||
detail: { eventName, productId, metadata },
|
|
||||||
});
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CartPage() {
|
|
||||||
const { items, removeItem, clearCart, itemCount } = useCart();
|
|
||||||
|
|
||||||
const handleRemove = (id: string, type: string) => {
|
|
||||||
removeItem(id);
|
|
||||||
dispatchInteraction('remove_item', id, { type });
|
|
||||||
};
|
|
||||||
let itemTypes = Array.from(new Set(items.map(item => item.type)))[0] || 'items';
|
|
||||||
|
|
||||||
|
|
||||||
const total = items.reduce((sum, item) => sum + item.price, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
|
||||||
{itemCount > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={clearCart}
|
|
||||||
className="text-sm text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Clear cart
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{itemCount === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
|
||||||
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
{items.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex justify-between items-start p-4 border rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
|
||||||
{item.type}
|
|
||||||
</span>
|
|
||||||
<h3 className="font-semibold">{item.name}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.type === 'hotel' && (
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<p>{String(item.metadata.roomType)}</p>
|
|
||||||
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
|
||||||
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.type === 'airline' && (
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<p>{String(item.metadata.cabinClass)} Class</p>
|
|
||||||
<p>{String((item.metadata.departure as any)?.airport)} → {String((item.metadata.arrival as any)?.airport)}</p>
|
|
||||||
<p>Duration: {String(item.metadata.duration)}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right ml-4">
|
|
||||||
<p className="text-xl font-bold mb-2">${item.price}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(item.id, item.type)}
|
|
||||||
className="text-sm text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<span className="text-xl font-semibold">Total</span>
|
|
||||||
<span className="text-3xl font-bold">${total.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => dispatchInteraction('checkout_start', undefined, { total, itemCount })}
|
|
||||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Proceed to Checkout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
|
||||||
import { useCart } from '@/contexts/CartContext';
|
|
||||||
import HotelDetails from '@/components/feats/hotel/HotelDetails';
|
|
||||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
|
||||||
import type { EventName } from '@/lib/events';
|
|
||||||
|
|
||||||
const dispatchInteraction = (eventName: EventName, productId?: string, metadata?: Record<string, unknown>) => {
|
|
||||||
const e = new CustomEvent('definedInteraction', {
|
|
||||||
detail: { eventName, productId, metadata },
|
|
||||||
});
|
|
||||||
document.dispatchEvent(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HotelProductPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const { addItem } = useCart();
|
|
||||||
const [product, setProduct] = useState<Hotel | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [added, setAdded] = useState(false);
|
|
||||||
|
|
||||||
const productId = params.id as string;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProduct = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/products/${productId}`);
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
const transformed = transformProduct(json.data as HotelProduct);
|
|
||||||
setProduct(transformed);
|
|
||||||
|
|
||||||
// fire learn_more_about_item event when product loads
|
|
||||||
dispatchInteraction('learn_more_about_item', productId, {
|
|
||||||
type: 'hotel',
|
|
||||||
dateIndex: transformed.dateIndex,
|
|
||||||
roomType: transformed.roomType,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load product');
|
|
||||||
console.error('[FETCH_HOTEL_ERROR]', e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchProduct();
|
|
||||||
}, [productId]);
|
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
|
||||||
if (!product) return;
|
|
||||||
|
|
||||||
addItem({
|
|
||||||
id: productId,
|
|
||||||
type: 'hotel',
|
|
||||||
name: product.name,
|
|
||||||
price: product.pricePerNight,
|
|
||||||
metadata: {
|
|
||||||
roomType: product.roomType,
|
|
||||||
nights: product.nights,
|
|
||||||
checkIn: product.checkIn,
|
|
||||||
checkOut: product.checkOut,
|
|
||||||
},
|
|
||||||
dateIndex: product.dateIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatchInteraction('add_item_to_cart', productId, {
|
|
||||||
type: 'hotel',
|
|
||||||
price: product.pricePerNight,
|
|
||||||
});
|
|
||||||
|
|
||||||
setAdded(true);
|
|
||||||
setTimeout(() => setAdded(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
{loading && <div className="text-center py-8">Loading hotel details...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
|
|
||||||
{!loading && !error && product && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="mt-6 text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
← Back to rooms
|
|
||||||
</button>
|
|
||||||
<HotelDetails
|
|
||||||
product={product}
|
|
||||||
onAddToCart={handleAddToCart}
|
|
||||||
addedToCart={added}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { Navigation } from '@/components/ui';
|
import { Navigation } from '@/components/ui';
|
||||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
function RoomsList() {
|
interface Hotel {
|
||||||
const searchParams = useSearchParams();
|
id: string;
|
||||||
const [rooms, setRooms] = useState<Hotel[]>([]);
|
name: string;
|
||||||
const [loading, setLoading] = useState(true);
|
roomType: string;
|
||||||
const [error, setError] = useState<string | null>(null);
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
useEffect(() => {
|
amenities: string[];
|
||||||
const fetchRooms = async () => {
|
refundable: boolean;
|
||||||
try {
|
pricePerNight: number;
|
||||||
const url = new URL('/api/products', window.location.origin);
|
nights: number;
|
||||||
url.searchParams.set('type', 'hotel');
|
|
||||||
|
|
||||||
// forward all relevant search params to the API
|
|
||||||
const params = ['dateIndex', 'destination', 'adults', 'rooms'];
|
|
||||||
params.forEach(param => {
|
|
||||||
const val = searchParams.get(param);
|
|
||||||
if (val) url.searchParams.set(param, val);
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url.toString());
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
const transformed = json.data.map((p: HotelProduct) => transformProduct(p));
|
|
||||||
setRooms(transformed);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
|
||||||
console.error('[FETCH_ERROR]', e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchRooms();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Available Rooms</h1>
|
|
||||||
{loading && <div className="text-center py-8">Loading...</div>}
|
|
||||||
{error && <div className="text-red-500 text-center py-8">{error}</div>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{rooms.map((r) => (
|
|
||||||
<HotelCard key={r.id} hotel={r} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genRandomHotels = (): Hotel[] => {
|
||||||
|
const names = [
|
||||||
|
'Grand Plaza Hotel',
|
||||||
|
'Seaside Resort',
|
||||||
|
'Downtown Suites',
|
||||||
|
'Mountain View Lodge',
|
||||||
|
'City Center Inn',
|
||||||
|
'Luxury Beach Resort',
|
||||||
|
'Urban Boutique Hotel',
|
||||||
|
'Garden View Hotel',
|
||||||
|
];
|
||||||
|
const roomTypes = ['Standard Room', 'Deluxe Room', 'Suite', 'Executive Suite', 'Premium Room'];
|
||||||
|
const amenities = ['wifi', 'pool', 'gym', 'parking', 'breakfast', 'spa'];
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const nights = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const basePrice = Math.floor(80 + Math.random() * 220);
|
||||||
|
const selectedAmenities = amenities
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, Math.floor(Math.random() * 3) + 2);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const checkInDate = new Date(today);
|
||||||
|
checkInDate.setDate(today.getDate() + Math.floor(Math.random() * 10));
|
||||||
|
const checkOutDate = new Date(checkInDate);
|
||||||
|
checkOutDate.setDate(checkInDate.getDate() + nights);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `htl-${i}`,
|
||||||
|
name: names[i % names.length],
|
||||||
|
roomType: roomTypes[Math.floor(Math.random() * roomTypes.length)],
|
||||||
|
checkIn: checkInDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
|
checkOut: checkOutDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
|
amenities: selectedAmenities,
|
||||||
|
refundable: Math.random() > 0.5,
|
||||||
|
pricePerNight: basePrice,
|
||||||
|
nights,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export default function HotelProducts() {
|
export default function HotelProducts() {
|
||||||
|
const hotels = genRandomHotels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
<h1 className="text-3xl font-bold mb-6">Available Hotels</h1>
|
||||||
<RoomsList />
|
<div className="space-y-4">
|
||||||
</Suspense>
|
{hotels.map((h) => (
|
||||||
|
<HotelCard key={h.id} hotel={h} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { TrackingProvider } from "@/components/TrackingProvider";
|
import { TrackingProvider } from "@/components/TrackingProvider";
|
||||||
import { CartProvider } from "@/contexts/CartContext";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -29,9 +28,7 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<CartProvider>
|
<TrackingProvider>{children}</TrackingProvider>
|
||||||
<TrackingProvider>{children}</TrackingProvider>
|
|
||||||
</CartProvider>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import type { Flight } from '@/lib/airline-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -12,17 +11,32 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
document.dispatchEvent(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CabinClass = 'economy' | 'premium' | 'business' | 'first';
|
||||||
|
type FareRule = 'flexible' | 'standard' | 'basic';
|
||||||
|
|
||||||
|
interface Flight {
|
||||||
|
id: string;
|
||||||
|
departure: { time: string; airport: string };
|
||||||
|
arrival: { time: string; airport: string };
|
||||||
|
duration: string;
|
||||||
|
stops: number;
|
||||||
|
cabinClass: CabinClass;
|
||||||
|
fareRule: FareRule;
|
||||||
|
refundable: boolean;
|
||||||
|
basePrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AirlineCard({ flight }: { flight: Flight }) {
|
export default function AirlineCard({ flight }: { flight: Flight }) {
|
||||||
const durationRef = useHoverTracking({
|
const durationRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: flight.duration, dateIndex: flight.dateIndex },
|
metadata: { elementText: flight.duration },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: flight.id,
|
productId: flight.id,
|
||||||
metadata: { elementText: 'price', dateIndex: flight.dateIndex },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -30,9 +44,7 @@ export default function AirlineCard({ flight }: { flight: Flight }) {
|
|||||||
cabinClass: flight.cabinClass,
|
cabinClass: flight.cabinClass,
|
||||||
fareRule: flight.fareRule,
|
fareRule: flight.fareRule,
|
||||||
price: flight.basePrice,
|
price: flight.basePrice,
|
||||||
dateIndex: flight.dateIndex,
|
|
||||||
});
|
});
|
||||||
window.location.href = `/airline/products/${flight.id}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { Flight } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
interface AirlineDetailsProps {
|
|
||||||
product: Flight;
|
|
||||||
onAddToCart: () => void;
|
|
||||||
addedToCart: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AirlineDetails({ product, onAddToCart, addedToCart }: AirlineDetailsProps) {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
|
||||||
{/* Image Section */}
|
|
||||||
<div className="w-full lg:w-1/3 bg-gray-100 rounded-lg aspect-square flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-gray-400 text-lg font-medium">Flight Image</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Section */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<div className="flex justify-between items-start border-b pb-6 mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-1">{product.flightType}</h1>
|
|
||||||
<p className="text-lg text-gray-500">{product.cabinClass} Class</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-4xl font-bold text-gray-900">${product.basePrice}</p>
|
|
||||||
{product.refundable && (
|
|
||||||
<span className="inline-block mt-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-xs font-medium">
|
|
||||||
Refundable
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-10">
|
|
||||||
<div className="text-center min-w-[100px]">
|
|
||||||
<p className="text-3xl font-bold text-gray-900">{product.departure.time}</p>
|
|
||||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.departure.airport}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 px-8 flex flex-col items-center">
|
|
||||||
<p className="text-sm text-gray-500 mb-2">{product.duration}</p>
|
|
||||||
<div className="w-full h-0.5 bg-gray-200 relative flex items-center justify-center">
|
|
||||||
<div className="absolute w-3 h-3 bg-gray-400 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
|
||||||
{product.stops === 0 ? 'Nonstop' : `${product.stops} stop${product.stops > 1 ? 's' : ''}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center min-w-[100px]">
|
|
||||||
<p className="text-3xl font-bold text-gray-900">{product.arrival.time}</p>
|
|
||||||
<p className="text-sm text-gray-500 font-medium mt-1">{product.arrival.airport}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between pt-6 border-t">
|
|
||||||
<div className="text-gray-600">
|
|
||||||
<span className="font-bold text-gray-900">{product.availability}</span> seats remaining
|
|
||||||
<span className="mx-2">•</span>
|
|
||||||
{product.fareRule}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onAddToCart}
|
|
||||||
disabled={addedToCart}
|
|
||||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
|
||||||
>
|
|
||||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, RadioGroup, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
import { dateToDaysFromToday } from '@/lib/airline-utils';
|
|
||||||
|
|
||||||
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
type TripType = 'roundtrip' | 'oneway' | 'multicity';
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function AirlineHero() {
|
export default function AirlineHero() {
|
||||||
const router = useRouter();
|
|
||||||
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
const [tripType, setTripType] = useState<TripType>('roundtrip');
|
||||||
const [origin, setOrigin] = useState('');
|
const [origin, setOrigin] = useState('');
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
@@ -31,23 +28,7 @@ export default function AirlineHero() {
|
|||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const params = new URLSearchParams();
|
console.log({ tripType, origin, destination, departDate, returnDate, passengers });
|
||||||
|
|
||||||
if (departDate) {
|
|
||||||
const daysOffset = dateToDaysFromToday(departDate);
|
|
||||||
params.set('dateIndex', daysOffset.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin) params.set('origin', origin);
|
|
||||||
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('children', passengers.children.toString());
|
|
||||||
params.set('infants', passengers.infants.toString());
|
|
||||||
|
|
||||||
router.push(`/airline/products?${params.toString()}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
const totalPax = passengers.adults + passengers.children + passengers.infants;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -12,6 +11,18 @@ const dispatchInteraction = (eventName: EventName, productId?: string, metadata?
|
|||||||
document.dispatchEvent(e);
|
document.dispatchEvent(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Hotel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roomType: string;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
amenities: string[];
|
||||||
|
refundable: boolean;
|
||||||
|
pricePerNight: number;
|
||||||
|
nights: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AmenityIcon = ({ name }: { name: string }) => {
|
const AmenityIcon = ({ name }: { name: string }) => {
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
wifi: 'Wi-Fi',
|
wifi: 'Wi-Fi',
|
||||||
@@ -28,13 +39,13 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
const titleRef = useHoverTracking({
|
const titleRef = useHoverTracking({
|
||||||
eventName: 'hover_over_title',
|
eventName: 'hover_over_title',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: hotel.name, dateIndex: hotel.dateIndex },
|
metadata: { elementText: hotel.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
const priceRef = useHoverTracking({
|
const priceRef = useHoverTracking({
|
||||||
eventName: 'hover_over_paragraph',
|
eventName: 'hover_over_paragraph',
|
||||||
productId: hotel.id,
|
productId: hotel.id,
|
||||||
metadata: { elementText: 'price', dateIndex: hotel.dateIndex },
|
metadata: { elementText: 'price' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -42,9 +53,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
roomType: hotel.roomType,
|
roomType: hotel.roomType,
|
||||||
price: hotel.pricePerNight,
|
price: hotel.pricePerNight,
|
||||||
nights: hotel.nights,
|
nights: hotel.nights,
|
||||||
dateIndex: hotel.dateIndex,
|
|
||||||
});
|
});
|
||||||
window.location.href = `/hotel/products/${hotel.id}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
interface HotelDetailsProps {
|
|
||||||
product: Hotel;
|
|
||||||
onAddToCart: () => void;
|
|
||||||
addedToCart: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
|
||||||
return (
|
|
||||||
<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 bg-gray-100 rounded-lg aspect-[4/3] flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-gray-400 text-lg font-medium">Hotel Image</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Section - Full height/width usage */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<div className="border-b pb-6 mb-6">
|
|
||||||
<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 className="grid grid-cols-2 gap-8 mb-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-in</h3>
|
|
||||||
<p className="text-lg text-gray-700">{product.checkIn}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-2">Check-out</h3>
|
|
||||||
<p className="text-lg text-gray-700">{product.checkOut}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider mb-3">Amenities</h3>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{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">
|
|
||||||
{a}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
<p className="text-sm text-gray-500 mb-1">Total for {product.nights} night{product.nights > 1 ? 's' : ''}</p>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-4xl font-bold text-gray-900">${product.pricePerNight * product.nights}</span>
|
|
||||||
<span className="text-gray-500">/ {product.nights} nights</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onAddToCart}
|
|
||||||
disabled={addedToCart}
|
|
||||||
className="px-8 py-4 bg-black hover:bg-gray-800 disabled:bg-green-600 text-white rounded-lg text-lg font-medium transition-all min-w-[200px]"
|
|
||||||
>
|
|
||||||
{addedToCart ? 'In Cart' : 'Add to Cart'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
import { Button, Label, Input, DateInput, Dropdown, DropdownCounter } from '@/components/ui';
|
||||||
import { dateToDaysFromToday } from '@/lib/hotel-utils';
|
|
||||||
|
|
||||||
const LocationIcon = () => (
|
const LocationIcon = () => (
|
||||||
<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">
|
||||||
@@ -13,25 +11,14 @@ const LocationIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function HotelHero() {
|
export default function HotelHero() {
|
||||||
const router = useRouter();
|
|
||||||
const [destination, setDestination] = useState('');
|
const [destination, setDestination] = useState('');
|
||||||
const [checkIn, setCheckIn] = useState('');
|
const [checkIn, setCheckIn] = useState('');
|
||||||
|
const [checkOut, setCheckOut] = useState('');
|
||||||
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
const [guests, setGuests] = useState({ adults: 2, rooms: 1 });
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const params = new URLSearchParams();
|
console.log({ destination, checkIn, checkOut, guests });
|
||||||
|
|
||||||
if (checkIn) {
|
|
||||||
const daysOffset = dateToDaysFromToday(checkIn);
|
|
||||||
params.set('dateIndex', daysOffset.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (destination) params.set('destination', destination);
|
|
||||||
params.set('adults', guests.adults.toString());
|
|
||||||
params.set('rooms', guests.rooms.toString());
|
|
||||||
|
|
||||||
router.push(`/hotel/products?${params.toString()}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,16 +26,16 @@ export default function HotelHero() {
|
|||||||
<div className="w-full max-w-4xl px-4">
|
<div className="w-full max-w-4xl px-4">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
Find your perfect room
|
Find your perfect stay
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg">
|
<p className="text-lg">
|
||||||
Search rooms, compare prices, and book with confidence
|
Search hotels, compare prices, and book with confidence
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="search-form">
|
<form onSubmit={handleSearch} className="search-form">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-2">
|
||||||
<Label htmlFor="destination">Where to?</Label>
|
<Label htmlFor="destination">Where to?</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -62,7 +49,7 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="checkIn">Date (1 night stay)</Label>
|
<Label htmlFor="checkIn">Check-in</Label>
|
||||||
<DateInput
|
<DateInput
|
||||||
id="checkIn"
|
id="checkIn"
|
||||||
value={checkIn}
|
value={checkIn}
|
||||||
@@ -72,27 +59,43 @@ export default function HotelHero() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="guests">Guests</Label>
|
<Label htmlFor="checkOut">Check-out</Label>
|
||||||
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}`}>
|
<DateInput
|
||||||
|
id="checkOut"
|
||||||
|
value={checkOut}
|
||||||
|
onChange={(e) => setCheckOut(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
|
<Label htmlFor="guests">Guests & Rooms</Label>
|
||||||
|
<Dropdown label={`${guests.adults} ${guests.adults === 1 ? 'adult' : 'adults'}, ${guests.rooms} ${guests.rooms === 1 ? 'room' : 'rooms'}`}>
|
||||||
<DropdownCounter
|
<DropdownCounter
|
||||||
label="Adults"
|
label="Adults"
|
||||||
value={guests.adults}
|
value={guests.adults}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(v) => setGuests({ ...guests, adults: v })}
|
onChange={(v) => setGuests({ ...guests, adults: v })}
|
||||||
/>
|
/>
|
||||||
|
<DropdownCounter
|
||||||
|
label="Rooms"
|
||||||
|
value={guests.rooms}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => setGuests({ ...guests, rooms: v })}
|
||||||
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-4">
|
||||||
<Button type="submit" fullWidth>
|
<Button type="submit" fullWidth>
|
||||||
Search Rooms
|
Search Hotels
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<p>Over 2 million rooms worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
<p>Over 2 million hotels worldwide · Best price guarantee · Free cancellation on most bookings</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
|
|
||||||
export interface CartItem {
|
|
||||||
id: string;
|
|
||||||
type: 'hotel' | 'airline';
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
dateIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CartContextType {
|
|
||||||
items: CartItem[];
|
|
||||||
addItem: (item: CartItem) => void;
|
|
||||||
removeItem: (id: string) => void;
|
|
||||||
clearCart: () => void;
|
|
||||||
itemCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const CART_KEY = 'phantom_cart';
|
|
||||||
|
|
||||||
export const CartProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [items, setItems] = useState<CartItem[]>([]);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
|
|
||||||
// load cart from sessionStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = sessionStorage.getItem(CART_KEY);
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
setItems(JSON.parse(stored));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[CART_LOAD]', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoaded(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// persist to sessionStorage whenever cart changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loaded) return;
|
|
||||||
sessionStorage.setItem(CART_KEY, JSON.stringify(items));
|
|
||||||
}, [items, loaded]);
|
|
||||||
|
|
||||||
const addItem = (item: CartItem) => {
|
|
||||||
setItems(prev => {
|
|
||||||
// prevent duplicates
|
|
||||||
if (prev.find(i => i.id === item.id)) return prev;
|
|
||||||
return [...prev, item];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = (id: string) => {
|
|
||||||
setItems(prev => prev.filter(i => i.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCart = () => {
|
|
||||||
setItems([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CartContext.Provider value={{ items, addItem, removeItem, clearCart, itemCount: items.length }}>
|
|
||||||
{children}
|
|
||||||
</CartContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCart = () => {
|
|
||||||
const ctx = useContext(CartContext);
|
|
||||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
export interface AirlineProduct {
|
|
||||||
id: string;
|
|
||||||
flight_type: string;
|
|
||||||
date_index: number;
|
|
||||||
metadata: {
|
|
||||||
departure: { time: string; airport: string };
|
|
||||||
arrival: { time: string; airport: string };
|
|
||||||
duration: string;
|
|
||||||
stops: number;
|
|
||||||
cabin_class: string;
|
|
||||||
fare_rule: string;
|
|
||||||
refundable: boolean;
|
|
||||||
total?: number;
|
|
||||||
base_price: number;
|
|
||||||
};
|
|
||||||
availability: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Flight {
|
|
||||||
id: string;
|
|
||||||
flightType: string;
|
|
||||||
departure: { time: string; airport: string };
|
|
||||||
arrival: { time: string; airport: string };
|
|
||||||
duration: string;
|
|
||||||
stops: number;
|
|
||||||
cabinClass: string;
|
|
||||||
fareRule: string;
|
|
||||||
refundable: boolean;
|
|
||||||
basePrice: number;
|
|
||||||
dateIndex: number;
|
|
||||||
availability: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EPOCH = new Date(0);
|
|
||||||
|
|
||||||
export const transformProduct = (p: AirlineProduct): Flight => {
|
|
||||||
const { id, flight_type, date_index, metadata, availability } = p;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
flightType: flight_type,
|
|
||||||
departure: metadata.departure,
|
|
||||||
arrival: metadata.arrival,
|
|
||||||
duration: metadata.duration,
|
|
||||||
stops: metadata.stops,
|
|
||||||
cabinClass: metadata.cabin_class,
|
|
||||||
fareRule: metadata.fare_rule,
|
|
||||||
refundable: metadata.refundable,
|
|
||||||
basePrice: metadata.base_price,
|
|
||||||
dateIndex: date_index,
|
|
||||||
availability,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// convert date string to days from today
|
|
||||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
|
||||||
const target = new Date(dateStr);
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// convert date string to date_index (days since epoch)
|
|
||||||
export const dateToIndex = (dateStr: string): number => {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// get current date_index
|
|
||||||
export const todayIndex = (): number => {
|
|
||||||
const now = new Date();
|
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
export interface HotelProduct {
|
|
||||||
id: string;
|
|
||||||
room_type: string;
|
|
||||||
date_index: number;
|
|
||||||
metadata: {
|
|
||||||
amenities?: string[];
|
|
||||||
total?: number;
|
|
||||||
image_url?: string;
|
|
||||||
base_price?: number;
|
|
||||||
name?: string;
|
|
||||||
refundable?: boolean;
|
|
||||||
};
|
|
||||||
availability: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Hotel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
roomType: string;
|
|
||||||
checkIn: string;
|
|
||||||
checkOut: string;
|
|
||||||
dateIndex: number;
|
|
||||||
amenities: string[];
|
|
||||||
refundable: boolean;
|
|
||||||
pricePerNight: number;
|
|
||||||
nights: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EPOCH = new Date(0);
|
|
||||||
|
|
||||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
|
||||||
const { id, room_type, date_index, metadata } = p;
|
|
||||||
const checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
|
||||||
const nights = 1;
|
|
||||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: metadata?.name || room_type,
|
|
||||||
roomType: room_type,
|
|
||||||
checkIn: checkIn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
||||||
checkOut: checkOut.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
|
||||||
dateIndex: date_index,
|
|
||||||
amenities: metadata?.amenities || [],
|
|
||||||
refundable: metadata?.refundable || false,
|
|
||||||
pricePerNight: metadata?.base_price || 100,
|
|
||||||
nights,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// convert date string to days from today
|
|
||||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
|
||||||
const target = new Date(dateStr);
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// convert date string to date_index (days since epoch)
|
|
||||||
export const dateToIndex = (dateStr: string): number => {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// get current date_index
|
|
||||||
export const todayIndex = (): number => {
|
|
||||||
const now = new Date();
|
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { HotelProduct, Hotel, transformProduct as transformHotel } from './hotel-utils';
|
|
||||||
import { AirlineProduct, Flight, transformProduct as transformFlight } from './airline-utils';
|
|
||||||
|
|
||||||
export type Product = Hotel | Flight;
|
|
||||||
export type ProductRaw = HotelProduct | AirlineProduct;
|
|
||||||
|
|
||||||
export const isHotelProduct = (p: ProductRaw): p is HotelProduct => {
|
|
||||||
return 'room_type' in p;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isAirlineProduct = (p: ProductRaw): p is AirlineProduct => {
|
|
||||||
return 'flight_type' in p;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const transformProduct = (p: ProductRaw): Product => {
|
|
||||||
if (isHotelProduct(p)) {
|
|
||||||
return transformHotel(p);
|
|
||||||
}
|
|
||||||
return transformFlight(p);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getProductType = (p: Product): 'hotel' | 'airline' => {
|
|
||||||
if ('roomType' in p) return 'hotel';
|
|
||||||
return 'airline';
|
|
||||||
};
|
|
||||||
@@ -11,7 +11,6 @@ export function proxy(req: NextRequest) {
|
|||||||
pathname.startsWith('/_next') ||
|
pathname.startsWith('/_next') ||
|
||||||
pathname.startsWith('/static') ||
|
pathname.startsWith('/static') ||
|
||||||
pathname.startsWith('/start-task') ||
|
pathname.startsWith('/start-task') ||
|
||||||
pathname.startsWith('/cart') ||
|
|
||||||
pathname.includes('.')
|
pathname.includes('.')
|
||||||
// TODO: add robots.txt and sitemap.xml if needed here
|
// TODO: add robots.txt and sitemap.xml if needed here
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user