mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
4 Commits
32-refine-
...
13-create-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb02cc5d2 | |||
| 26af688d4d | |||
| 8174cd3ef5 | |||
| 7532bed897 |
21
.env.example
21
.env.example
@@ -1,18 +1,5 @@
|
||||
# Network configuration
|
||||
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||
HOSTNAME=localhost
|
||||
|
||||
# Application configuration
|
||||
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
|
||||
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
|
||||
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
|
||||
|
||||
# Backend service
|
||||
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
|
||||
|
||||
# Service ports - used by docker-compose and service communication
|
||||
BACKEND_PORT=5000 # backend server port for kafka ingestion API
|
||||
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
|
||||
KAFKA_PORT=9092 # kafka broker port for event streaming
|
||||
REDIS_PORT=6377 # redis port for worker queue and caching
|
||||
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
|
||||
# PORTS
|
||||
KAFKA_PORT=9092
|
||||
REDIS_PORT=6377
|
||||
|
||||
30
.github/workflows/pytest.yml
vendored
30
.github/workflows/pytest.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: .venv/bin/pytest -v
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,13 +1,2 @@
|
||||
**/.env
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/.ipynb_checkpoints/
|
||||
**/.virtual_documents/
|
||||
**/session_*.svg
|
||||
**/*graph.svg
|
||||
paper/src/bib/auto
|
||||
|
||||
# Airflow logs - exclude DAG run logs
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
**/.venv
|
||||
19
Makefile
19
Makefile
@@ -4,10 +4,6 @@ BUILDDIR := build
|
||||
TEX := main.tex
|
||||
JOBNAME := main
|
||||
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
PYTEST := $(VENV)/bin/pytest
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
@@ -39,18 +35,5 @@ clean:
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
rm -rf paper/$(BUILDDIR)/*
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
install: $(VENV)
|
||||
$(PIP) install -r requirements.txt
|
||||
|
||||
test: $(VENV)
|
||||
$(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
|
||||
|
||||
11
README.md
11
README.md
@@ -1,12 +1 @@
|
||||
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||
|
||||
### PHANTOM
|
||||
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
[](https://sites.research.google/trc/faq/)
|
||||
[](https://phantom-hotel.vercel.app)
|
||||
[](https://phantom-airline.vercel.app)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,113 +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 (
|
||||
PredictPricesStep
|
||||
)
|
||||
from procesing import PipelineContext
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
# Config
|
||||
app = FastAPI(title="PHANTOM Pricing Provider")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
||||
registry = ModelRegistry()
|
||||
|
||||
class PriceResponse(BaseModel):
|
||||
productId: str
|
||||
price: float
|
||||
base_price: float
|
||||
markup: float
|
||||
elasticity: Optional[float] = None
|
||||
model_version: str = 'latest'
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "healthy", "redis": registry.health_check()}
|
||||
|
||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
# fetch pre-computed prices from registry
|
||||
prices_df = registry.get_prices('latest')
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
if prices_df is None:
|
||||
# fallback: no pre-computed prices available
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
# lookup pre-computed price for this product
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if product_price_row.empty:
|
||||
# product not in pre-computed prices, fallback to base
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
||||
|
||||
# get elasticity if available
|
||||
product_elasticity = None
|
||||
if elasticity_df is not None:
|
||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||
if not product_elasticity_row.empty:
|
||||
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
|
||||
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=product_elasticity
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
def list_models(): return registry.list_models()
|
||||
|
||||
@app.post("/models/reload")
|
||||
def reload_models():
|
||||
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
|
||||
return {
|
||||
"elasticity_loaded": bool(elasticity),
|
||||
"n_products": len(elasticity) if elasticity is not None else 0,
|
||||
"pricing_model_loaded": bool(pricing_model),
|
||||
"model_class": pricing_model.__class__.__name__ if pricing_model else None
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))
|
||||
@@ -1,16 +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
|
||||
pypickle
|
||||
pymc
|
||||
@@ -1,363 +0,0 @@
|
||||
# boilerplate code
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
import uvicorn
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
||||
from kafka.admin import NewTopic
|
||||
from kafka.errors import TopicAlreadyExistsError
|
||||
from dotenv import load_dotenv
|
||||
from supabase import create_client, Client
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# kafka producer - lazy init
|
||||
_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:
|
||||
global _producer
|
||||
if _producer is None:
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}' if port else host
|
||||
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
||||
_producer = KafkaProducer(
|
||||
bootstrap_servers=[broker],
|
||||
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
||||
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
||||
acks=1,
|
||||
retries=3,
|
||||
max_in_flight_requests_per_connection=5,
|
||||
request_timeout_ms=30000,
|
||||
api_version_auto_timeout_ms=10000,
|
||||
max_block_ms=5000, # don't block send() for more than 5s
|
||||
)
|
||||
print(f"[KAFKA_INIT] Producer created successfully")
|
||||
return _producer
|
||||
|
||||
class EventPayload(BaseModel):
|
||||
sessionId: str
|
||||
experimentId: Optional[str] = None
|
||||
eventName: str
|
||||
page: str
|
||||
productId: Optional[str] = None
|
||||
metadata: Optional[dict[str, Any]] = None
|
||||
storeMode: str
|
||||
userAgent: 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(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""create kafka topics on startup"""
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
print(f"[STARTUP] Creating Kafka topics on {broker}")
|
||||
admin = KafkaAdminClient(
|
||||
bootstrap_servers=[broker],
|
||||
request_timeout_ms=10000,
|
||||
)
|
||||
|
||||
topics = [
|
||||
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)
|
||||
print(f"[STARTUP] Topics created successfully")
|
||||
admin.close()
|
||||
except TopicAlreadyExistsError:
|
||||
print(f"[STARTUP] Topics already exist, skipping creation")
|
||||
except Exception as e:
|
||||
print(f"[STARTUP] Failed to create topics: {e}")
|
||||
print(f"[STARTUP] Will rely on auto-creation on first message")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
kafka_status = "unknown"
|
||||
try:
|
||||
producer = get_producer()
|
||||
# attempt to get cluster metadata to verify connection
|
||||
producer.bootstrap_connected()
|
||||
kafka_status = "connected"
|
||||
except Exception as e:
|
||||
kafka_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"kafka": kafka_status,
|
||||
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/kafka/ingest")
|
||||
async def ingest_logs(event: EventPayload):
|
||||
try:
|
||||
if not event.ts:
|
||||
event.ts = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
producer = get_producer()
|
||||
future = producer.send(
|
||||
'user-interactions',
|
||||
key=event.sessionId,
|
||||
value=event.model_dump()
|
||||
)
|
||||
# add callback for error logging but don't block
|
||||
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
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")
|
||||
def dump_logs(
|
||||
topic: str = 'user-interactions',
|
||||
last_n: Optional[int] = None,
|
||||
t_start: Optional[str] = None,
|
||||
t_end: Optional[str] = None
|
||||
):
|
||||
"""dump all messages from specified kafka topic
|
||||
|
||||
params:
|
||||
topic: kafka topic to dump (default: user-interactions)
|
||||
last_n: return only last n messages (default: all)
|
||||
t_start: filter by start timestamp iso format
|
||||
t_end: filter by end timestamp iso format
|
||||
"""
|
||||
if topic not in ['user-interactions', 'price-logs']:
|
||||
raise HTTPException(status_code=400, detail="Invalid topic")
|
||||
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
consumer = KafkaConsumer(
|
||||
topic,
|
||||
bootstrap_servers=[broker],
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=5000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
|
||||
consumer.close()
|
||||
|
||||
# apply filters
|
||||
if t_start or t_end:
|
||||
filtered = []
|
||||
for e in events:
|
||||
ts = e.get('ts')
|
||||
if ts:
|
||||
if t_start and ts < t_start:
|
||||
continue
|
||||
if t_end and ts > t_end:
|
||||
continue
|
||||
filtered.append(e)
|
||||
events = filtered
|
||||
|
||||
if last_n and last_n > 0:
|
||||
events = events[-last_n:]
|
||||
|
||||
return {"success": True, "count": len(events), "data": events}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[DUMP_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
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
|
||||
# dateIndex from frontend is days from today, convert to days since epoch
|
||||
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__":
|
||||
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||
@@ -1,6 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
kafka-python==2.0.2
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
supabase==2.9.1
|
||||
@@ -1,37 +1,15 @@
|
||||
services:
|
||||
backend:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
environment:
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_PORT=5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
depends_on:
|
||||
- kafka
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "PHANTOM-redis"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Redis.dockerfile
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${REDIS_PORT:-6378}:6379"
|
||||
volumes:
|
||||
- phantom_redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
zookeeper:
|
||||
container_name: "PHANTOM-zookeeper"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Zookeeper.dockerfile
|
||||
image: confluentinc/cp-zookeeper:latest
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
@@ -39,9 +17,7 @@ services:
|
||||
|
||||
kafka:
|
||||
container_name: "PHANTOM-kafka"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Kafka.dockerfile
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
depends_on:
|
||||
- zookeeper
|
||||
environment:
|
||||
@@ -60,9 +36,7 @@ services:
|
||||
|
||||
redpanda-console:
|
||||
container_name: "PHANTOM-redpanda-console"
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: RedpandaConsole.dockerfile
|
||||
image: docker.redpanda.com/redpandadata/console:latest
|
||||
depends_on:
|
||||
- kafka
|
||||
environment:
|
||||
@@ -71,133 +45,6 @@ services:
|
||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
container_name: "PHANTOM-postgres"
|
||||
image: postgres:13
|
||||
environment:
|
||||
- POSTGRES_USER=airflow
|
||||
- POSTGRES_PASSWORD=airflow
|
||||
- POSTGRES_DB=airflow
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
airflow-init:
|
||||
container_name: "PHANTOM-airflow-init"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- _AIRFLOW_DB_MIGRATE=true
|
||||
- _AIRFLOW_WWW_USER_CREATE=true
|
||||
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
command: version
|
||||
restart: "no"
|
||||
|
||||
airflow-webserver:
|
||||
container_name: "PHANTOM-airflow-webserver"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
ports:
|
||||
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||
command: webserver
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
airflow-scheduler:
|
||||
container_name: "PHANTOM-airflow-scheduler"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
airflow-webserver:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
command: scheduler
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
pricing-provider:
|
||||
container_name: "PHANTOM-pricing-provider"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Provider.dockerfile
|
||||
depends_on:
|
||||
- redis
|
||||
- kafka
|
||||
environment:
|
||||
- PROVIDER_PORT=5001
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- BACKEND_URL=http://localhost:5000
|
||||
ports:
|
||||
- "${PROVIDER_PORT:-5001}:5001"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
phantom_kafka_data:
|
||||
phantom_redis_data:
|
||||
postgres_data:
|
||||
|
||||
@@ -1,30 +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
|
||||
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
# create logs and plugins dirs (airflow expects them)
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
@@ -1,41 +0,0 @@
|
||||
FROM apache/airflow:2.7.3-python3.11
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
supervisor \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER airflow
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
psycopg2-binary \
|
||||
apache-airflow-providers-postgres
|
||||
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
|
||||
# copy all code into image (standalone - no volume mounts needed)
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
|
||||
# copy entrypoint script
|
||||
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||
USER root
|
||||
RUN chmod +x /entrypoint.sh
|
||||
USER airflow
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM confluentinc/cp-kafka:7.5.0
|
||||
|
||||
# Expose Kafka ports
|
||||
# 9092: External client connections
|
||||
# 29092: Internal broker communication
|
||||
# 9999: JMX monitoring port
|
||||
EXPOSE 9092 29092 9999
|
||||
@@ -1,26 +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
|
||||
|
||||
# Copy application code into image
|
||||
COPY lib/ /app/lib/
|
||||
COPY experiments/procesing/ /app/procesing/
|
||||
COPY backend/provider/ /app/provider/
|
||||
|
||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||
|
||||
WORKDIR /app/provider
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
# Expose Redis port
|
||||
EXPOSE 6379
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM docker.redpanda.com/redpandadata/console:latest
|
||||
|
||||
# Expose Redpanda Console web UI port
|
||||
EXPOSE 8080
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM confluentinc/cp-zookeeper:latest
|
||||
|
||||
# Expose Zookeeper client port
|
||||
EXPOSE 2181
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# init db and create admin user on first run
|
||||
airflow db migrate
|
||||
|
||||
# create admin user if not exists
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||
--firstname Admin \
|
||||
--lastname User \
|
||||
--role Admin \
|
||||
--email admin@example.com || true
|
||||
|
||||
# start scheduler in background
|
||||
airflow scheduler &
|
||||
|
||||
# start webserver in foreground (Railway needs one foreground process)
|
||||
exec airflow webserver --port ${PORT:-8080}
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/server/app.py .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Agentic behavior runner for PHANTOM research platform."""
|
||||
@@ -1,47 +0,0 @@
|
||||
from .base import Agent as BaseAgent
|
||||
from browser_use import Browser, Agent, ChatOpenAI
|
||||
from enum import Enum
|
||||
|
||||
class AgentTypes(str, Enum):
|
||||
GENERIC_BROWSER_USE_AGENT = "generic_browser_use_agent"
|
||||
|
||||
def _build_prompt(goal : str, environment_url : str) -> str:
|
||||
#TODO: Improve prompt engineering here and experiment with
|
||||
return f"""You are an autonomous agent tasked with achieving the following goal: {goal}
|
||||
You have access to a web browser to interact with the environment at {environment_url}.
|
||||
Use the browser to navigate, gather information, and perform actions necessary to accomplish your goal.
|
||||
Be thorough and ensure you complete the task fully."""
|
||||
|
||||
class GenericBrowserUseAgent(BaseAgent):
|
||||
def __init__(self,
|
||||
goal: str,
|
||||
url: str = "http://localhost:3000",
|
||||
timeout: int = 300,
|
||||
llm_model: str = "gpt-5-mini",
|
||||
headless: bool = True):
|
||||
super().__init__(goal, url, timeout)
|
||||
self.llm_model = ChatOpenAI(model=llm_model)
|
||||
self.browser = Browser(headless=headless)
|
||||
self.agent = Agent(task=_build_prompt(goal, url),
|
||||
llm=self.llm_model,
|
||||
browser=self.browser)
|
||||
async def act(self) -> str:
|
||||
self.result = await self.agent.run()
|
||||
# https://github.com/browser-use/browser-use/blob/main/browser_use/agent/views.py#L301
|
||||
return self.result.final_result()
|
||||
|
||||
def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
||||
if agent_type == AgentTypes.GENERIC_BROWSER_USE_AGENT:
|
||||
return GenericBrowserUseAgent(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown agent type: {agent_type}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
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."
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||
goal=JTBD,
|
||||
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
||||
timeout=300)
|
||||
R=asyncio.run(agent.act())
|
||||
print(R)
|
||||
@@ -1,19 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
class Agent(ABC):
|
||||
"""Base interface for browser automation agents"""
|
||||
|
||||
def __init__(self, goal: str, url: str = "http://localhost:3000", timeout: int = 300):
|
||||
self.goal = goal
|
||||
self.url = url
|
||||
self.timeout = timeout
|
||||
self.result: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
async def act(self) -> str:
|
||||
"""Execute goal and return result text"""
|
||||
pass
|
||||
|
||||
def final_result(self) -> Optional[str]:
|
||||
return self.result
|
||||
@@ -1,30 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from experiments.agents.agent import get_agent, AgentTypes
|
||||
import os
|
||||
|
||||
|
||||
def test_agent_init():
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="test", url="http://example.com", timeout=100)
|
||||
assert agent.goal == "test"
|
||||
assert agent.url == "http://example.com"
|
||||
assert agent.timeout == 100
|
||||
|
||||
|
||||
def test_invalid_agent():
|
||||
with pytest.raises(ValueError):
|
||||
get_agent("invalid", goal="test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
|
||||
async def test_agent_execution():
|
||||
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="get page title", url="https://example.com", timeout=60)
|
||||
|
||||
result = await agent.act()
|
||||
assert result
|
||||
assert agent.final_result()
|
||||
assert agent.final_result().history[-1].result[-1].is_done == True
|
||||
assert isinstance(result, str)
|
||||
assert "example" in result.lower()
|
||||
assert len(result) > 0
|
||||
@@ -1,210 +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
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
def _get_provider():
|
||||
return CompositeProvider()
|
||||
|
||||
def _make_task_callables(store_mode: str):
|
||||
"""Generate task callables bound to a specific store_mode."""
|
||||
|
||||
def get_context(**kwargs):
|
||||
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
|
||||
|
||||
def fetch_interactions(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchInteractionsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchPriceLogsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
demand_df = ComputeDemandStep(ctx).transform(df)
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
price_df = AggregatePriceLogsStep(ctx).transform(df)
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
ctx = get_context(**kwargs)
|
||||
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price', 'demand': 'demand_score'
|
||||
})
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': store_mode,
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
|
||||
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'store_mode': store_mode,
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
return {
|
||||
'fetch_interactions': fetch_interactions,
|
||||
'fetch_price_logs': fetch_price_logs,
|
||||
'compute_demand': compute_demand,
|
||||
'aggregate_price_logs': aggregate_price_logs,
|
||||
'join_product_features': join_product_features,
|
||||
'apply_surge_pricing': apply_surge_pricing,
|
||||
'publish_results': publish_results,
|
||||
}
|
||||
|
||||
|
||||
def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||
"""Factory: generates a surge pricing DAG for a given store_mode."""
|
||||
callables = _make_task_callables(store_mode)
|
||||
|
||||
dag = DAG(
|
||||
f'surge_pricing_{store_mode}',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description=f'Surge pricing pipeline for {store_mode} store mode',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', store_mode],
|
||||
)
|
||||
|
||||
with dag:
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=callables['fetch_interactions'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=callables['fetch_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=callables['compute_demand'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=callables['aggregate_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=callables['join_product_features'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=callables['apply_surge_pricing'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=callables['publish_results'],
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
|
||||
return dag
|
||||
|
||||
|
||||
# instantiate DAGs for Airflow to discover
|
||||
dag_airline = create_surge_pricing_dag('airline')
|
||||
dag_hotel = create_surge_pricing_dag('hotel')
|
||||
@@ -1,237 +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,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
default_args = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
def get_provider():
|
||||
"""Factory to create composite provider"""
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
return CompositeProvider()
|
||||
|
||||
def get_context(**kwargs):
|
||||
"""Build pipeline context from Airflow config"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return PipelineContext(
|
||||
provider=get_provider(),
|
||||
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||
)
|
||||
|
||||
# atomic task functions (each wraps one sklearn step)
|
||||
def fetch_interactions(**kwargs):
|
||||
"""Task: Fetch interaction data from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchInteractionsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
"""Task: Fetch price logs from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchPriceLogsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
"""Task: Compute demand scores from interactions"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeDemandStep(context)
|
||||
demand_df = step.transform(df)
|
||||
# TODO: clear the xcom
|
||||
|
||||
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
"""Task: Aggregate price logs"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AggregatePriceLogsStep(context)
|
||||
price_df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
"""Task: Join demand and price data"""
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = JoinProductFeaturesStep(context)
|
||||
joined_df = step.transform((demand_df, price_df))
|
||||
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
"""Task: Apply surge pricing rules to generate optimal prices"""
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
# rename demand_score to demand for pricer compatibility
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price',
|
||||
'demand': 'demand_score'
|
||||
})
|
||||
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
"""Task: Publish surge pricing results to registry"""
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
|
||||
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||
|
||||
logging.info(f"Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'surge_pricing_pipeline',
|
||||
default_args=default_args,
|
||||
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', 'simplified'],
|
||||
) as dag:
|
||||
|
||||
# parallel data fetching
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=fetch_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=fetch_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# compute demand from interactions
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=compute_demand,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# aggregate price logs
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=aggregate_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# join demand and prices
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=join_product_features,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# apply surge pricing
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=apply_surge_pricing,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# publish to registry
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=publish_results,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# dependency graph: parallel fetch -> process -> join -> surge -> publish
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
721
experiments/data_export.ipynb
Normal file
721
experiments/data_export.ipynb
Normal file
@@ -0,0 +1,721 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 98,
|
||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 98,
|
||||
"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": 86,
|
||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||
"RangeIndex: 141 entries, 0 to 140\n",
|
||||
"Data columns (total 10 columns):\n",
|
||||
" # Column Non-Null Count Dtype \n",
|
||||
"--- ------ -------------- ----- \n",
|
||||
" 0 sessionId 141 non-null object \n",
|
||||
" 1 eventType 141 non-null object \n",
|
||||
" 2 ts 141 non-null int64 \n",
|
||||
" 3 targetEl 14 non-null object \n",
|
||||
" 4 targetUrl 1 non-null object \n",
|
||||
" 5 metadata_path 141 non-null object \n",
|
||||
" 6 metadata_referrer 6 non-null object \n",
|
||||
" 7 metadata_x 14 non-null float64\n",
|
||||
" 8 metadata_y 14 non-null float64\n",
|
||||
" 9 metadata_scrollY 121 non-null float64\n",
|
||||
"dtypes: float64(3), int64(1), object(6)\n",
|
||||
"memory usage: 11.1+ 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": 87,
|
||||
"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>eventType</th>\n",
|
||||
" <th>ts</th>\n",
|
||||
" <th>targetEl</th>\n",
|
||||
" <th>targetUrl</th>\n",
|
||||
" <th>metadata_path</th>\n",
|
||||
" <th>metadata_referrer</th>\n",
|
||||
" <th>metadata_x</th>\n",
|
||||
" <th>metadata_y</th>\n",
|
||||
" <th>metadata_scrollY</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761226211163</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226218090</td>\n",
|
||||
" <td>MAIN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>815.0</td>\n",
|
||||
" <td>331.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226220890</td>\n",
|
||||
" <td>MAIN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>1129.0</td>\n",
|
||||
" <td>605.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226225801</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>532.0</td>\n",
|
||||
" <td>545.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>4</th>\n",
|
||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761226229364</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>481.0</td>\n",
|
||||
" <td>399.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>5</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761227236426</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>6</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761227239328</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>202.0</td>\n",
|
||||
" <td>351.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>7</th>\n",
|
||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761227244783</td>\n",
|
||||
" <td>A</td>\n",
|
||||
" <td>https://vercel.com/new?utm_source=create-next-...</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>377.0</td>\n",
|
||||
" <td>723.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>8</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>pageview</td>\n",
|
||||
" <td>1761828261783</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>9</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1761828266484</td>\n",
|
||||
" <td>H1</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>527.0</td>\n",
|
||||
" <td>169.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>10</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270314</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>51.666668</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>11</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270328</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>50.000000</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>12</th>\n",
|
||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
||||
" <td>scroll</td>\n",
|
||||
" <td>1761828270336</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>49.166668</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" sessionId eventType ts targetEl \\\n",
|
||||
"0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n",
|
||||
"1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n",
|
||||
"2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n",
|
||||
"3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n",
|
||||
"4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n",
|
||||
"5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n",
|
||||
"6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n",
|
||||
"7 1761227236286-e7mphcvw6t click 1761227244783 A \n",
|
||||
"8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n",
|
||||
"9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n",
|
||||
"10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n",
|
||||
"11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n",
|
||||
"12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n",
|
||||
"\n",
|
||||
" targetUrl metadata_path \\\n",
|
||||
"0 NaN / \n",
|
||||
"1 NaN / \n",
|
||||
"2 NaN / \n",
|
||||
"3 NaN / \n",
|
||||
"4 NaN / \n",
|
||||
"5 NaN / \n",
|
||||
"6 NaN / \n",
|
||||
"7 https://vercel.com/new?utm_source=create-next-... / \n",
|
||||
"8 NaN / \n",
|
||||
"9 NaN / \n",
|
||||
"10 NaN / \n",
|
||||
"11 NaN / \n",
|
||||
"12 NaN / \n",
|
||||
"\n",
|
||||
" metadata_referrer metadata_x metadata_y metadata_scrollY \n",
|
||||
"0 NaN NaN NaN \n",
|
||||
"1 NaN 815.0 331.0 NaN \n",
|
||||
"2 NaN 1129.0 605.0 NaN \n",
|
||||
"3 NaN 532.0 545.0 NaN \n",
|
||||
"4 NaN 481.0 399.0 NaN \n",
|
||||
"5 NaN NaN NaN \n",
|
||||
"6 NaN 202.0 351.0 NaN \n",
|
||||
"7 NaN 377.0 723.0 NaN \n",
|
||||
"8 NaN NaN NaN \n",
|
||||
"9 NaN 527.0 169.0 NaN \n",
|
||||
"10 NaN NaN NaN 51.666668 \n",
|
||||
"11 NaN NaN NaN 50.000000 \n",
|
||||
"12 NaN NaN NaN 49.166668 "
|
||||
]
|
||||
},
|
||||
"execution_count": 87,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df.groupby('sessionId').head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 88,
|
||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['1761225843899-qaiwwwyj2o',\n",
|
||||
" '1761828056433-0gz7aboz86h',\n",
|
||||
" '1761227236286-e7mphcvw6t']"
|
||||
]
|
||||
},
|
||||
"execution_count": 88,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"sessions = list(set(df['sessionId'])); sessions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 89,
|
||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# map sessions to experiments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 101,
|
||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||
" df = df.dropna(subset=['eventType'])\n",
|
||||
" events = df['eventType'].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": 107,
|
||||
"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": 108,
|
||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"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=\"228pt\" height=\"124pt\"\n",
|
||||
" viewBox=\"0.00 0.00 228.00 124.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 119.83)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd404165c70>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1.]\n",
|
||||
" [0. 1.]]\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=\"358pt\" height=\"132pt\"\n",
|
||||
" viewBox=\"0.00 0.00 358.00 132.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 128.41)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-128.41 354.16,-128.41 354.16,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->pageview -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->pageview</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M30.86,-87.29C31.64,-98.6 36.22,-107.16 44.58,-107.16 49.94,-107.16 53.74,-103.65 55.99,-98.15\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"59.33,-99.28 57.99,-88.77 52.48,-97.82 59.33,-99.28\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-111.11\" font-family=\"Times,serif\" font-size=\"14.00\">0.2</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.8</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->pageview -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>click->pageview</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M150.74,-29.52C143.93,-26.96 136.67,-24.68 129.66,-23.33 119.02,-21.28 107.71,-22.06 96.96,-24.24\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"96.33,-20.79 87.47,-26.6 98.02,-27.59 96.33,-20.79\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-27.28\" font-family=\"Times,serif\" font-size=\"14.00\">0.3</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.6</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- scroll -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>scroll</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"314.16\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">scroll</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->scroll -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>click->scroll</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M220.12,-44.58C234.44,-44.58 251.18,-44.58 266.47,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"266.31,-48.08 276.31,-44.58 266.31,-41.08 266.31,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"248.91\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.1</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- scroll->scroll -->\n",
|
||||
"<g id=\"edge6\" class=\"edge\">\n",
|
||||
"<title>scroll->scroll</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M301.93,-78.86C302.06,-89.86 306.13,-98.58 314.16,-98.58 319.18,-98.58 322.66,-95.17 324.59,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"327.99,-90.78 326.15,-80.35 321.08,-89.66 327.99,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd406e21a90>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.25 0.75 0. ]\n",
|
||||
" [0.28571429 0.57142857 0.14285714]\n",
|
||||
" [0. 0.00826446 0.99173554]]\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=\"228pt\" height=\"124pt\"\n",
|
||||
" viewBox=\"0.00 0.00 228.00 124.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 119.83)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
||||
"<!-- pageview -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>pageview</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>click</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- pageview->click -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>pageview->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- click->click -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>click->click</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7fd4041662b0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1.]\n",
|
||||
" [0. 1.]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def explore_session(session_id: str):\n",
|
||||
" subset = df[df['sessionId'] == session_id] # not .where(...)\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))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4d278c2d-406e-4dc0-b219-5f7b236e852b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"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,51 +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,
|
||||
# StateSpace,
|
||||
# BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
from procesing.pipelines import (
|
||||
interaction_extraction_pipeline,
|
||||
price_extraction_pipeline,
|
||||
pricing_pipeline,
|
||||
full_pipeline,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'PipelineContext',
|
||||
'DataProvider',
|
||||
'SupabaseProvider',
|
||||
'BackendAPIProvider',
|
||||
'BaseContextStep',
|
||||
'FetchInteractionsStep',
|
||||
'FetchPriceLogsStep',
|
||||
'FetchExperimentsStep',
|
||||
'JoinExperimentsStep',
|
||||
'CreatePriceBucketsStep',
|
||||
'AugmentEventNamesStep',
|
||||
'ChunkByTimeWindowStep',
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
# 'StateSpace',
|
||||
# 'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'interaction_extraction_pipeline',
|
||||
'price_extraction_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
|
||||
@@ -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
|
||||
@@ -1,172 +0,0 @@
|
||||
from sklearn.pipeline import Pipeline
|
||||
import pandas as pd
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
import os
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
FetchExperimentsStep,
|
||||
JoinExperimentsStep,
|
||||
CreatePriceBucketsStep,
|
||||
AugmentEventNamesStep,
|
||||
ChunkByTimeWindowStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
ComputeDemandStep,
|
||||
JoinProductFeaturesStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
ValidateDataStep,
|
||||
)
|
||||
from procesing.pricers import SimpleSurgePricer
|
||||
|
||||
def interaction_extraction_pipeline(context: PipelineContext):
|
||||
"""Pipeline for extracting and augmenting interaction data"""
|
||||
return Pipeline([
|
||||
('fetch', FetchInteractionsStep(context)),
|
||||
('create_buckets', CreatePriceBucketsStep(context)),
|
||||
('augment_events', AugmentEventNamesStep(context)),
|
||||
])
|
||||
|
||||
|
||||
def price_extraction_pipeline(context: PipelineContext):
|
||||
"""Pipeline for extracting price logs"""
|
||||
return Pipeline([
|
||||
('fetch', FetchPriceLogsStep(context)),
|
||||
])
|
||||
|
||||
|
||||
def product_features_pipeline(context: PipelineContext,
|
||||
interactions_df: pd.DataFrame,
|
||||
price_logs_df: pd.DataFrame):
|
||||
demand_step = ComputeDemandStep(context)
|
||||
price_step = AggregatePriceLogsStep(context)
|
||||
join_step = JoinProductFeaturesStep(context)
|
||||
|
||||
|
||||
demand_data = demand_step.transform(interactions_df)
|
||||
price_data= price_step.transform(price_logs_df)
|
||||
joined_data = join_step.transform((demand_data, price_data))
|
||||
|
||||
return joined_data
|
||||
|
||||
|
||||
|
||||
def pricing_pipeline(context: "PipelineContext",
|
||||
data: pd.DataFrame,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9) -> pd.DataFrame:
|
||||
|
||||
if data.empty or 'productId' not in data.columns:
|
||||
return pd.DataFrame()
|
||||
|
||||
surge_pricer = SimpleSurgePricer()
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
return data
|
||||
|
||||
|
||||
def full_pipeline(context: PipelineContext,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9):
|
||||
"""
|
||||
Complete end-to-end pipeline: data extraction -> demand/price aggregation -> surge pricing
|
||||
|
||||
Args:
|
||||
context: Pipeline context
|
||||
high_threshold: Demand threshold for surge pricing
|
||||
low_threshold: Demand threshold for discounts
|
||||
surge_multiplier: Price multiplier for high demand
|
||||
discount_multiplier: Price multiplier for low demand
|
||||
|
||||
Returns:
|
||||
tuple: (product_features_df, optimal_prices_df)
|
||||
- product_features_df: [productId, demand_score, price]
|
||||
- optimal_prices_df: [productId, current_price, optimal_price, demand_score]
|
||||
"""
|
||||
interaction_pipe = interaction_extraction_pipeline(context)
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
|
||||
interactions_df = interaction_pipe.fit_transform(None)
|
||||
price_logs_df = price_pipe.fit_transform(None)
|
||||
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||
print(product_features_df.to_string())
|
||||
|
||||
# generate optimal prices using surge rules
|
||||
optimal_prices_df = pricing_pipeline(context, product_features_df,
|
||||
high_threshold=high_threshold,
|
||||
low_threshold=low_threshold,
|
||||
surge_multiplier=surge_multiplier,
|
||||
discount_multiplier=discount_multiplier)
|
||||
|
||||
return product_features_df, optimal_prices_df
|
||||
|
||||
|
||||
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||
"""
|
||||
Build labeled session-level feature matrix for ML model training.
|
||||
Pipeline: fetch -> validate -> extract features -> join labels
|
||||
|
||||
Returns:
|
||||
DataFrame with ~25 features per session + is_agent label
|
||||
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||
"""
|
||||
# fetch raw interactions
|
||||
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||
|
||||
# validate data quality (report cached in context)
|
||||
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||
if interactions_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# extract vectorized session features
|
||||
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||
if features_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# join experiment labels (is_agent = ~xp_human_only)
|
||||
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||
|
||||
return labeled_df
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
class ExperimentsProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||
base_path = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/" # os.path.join(os.path.dirname(__file__), "collected_data")
|
||||
if not os.path.isdir(base_path):
|
||||
return pd.DataFrame()
|
||||
|
||||
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||
file_to_read = files.get(topic, files["user-interactions"])
|
||||
frames = []
|
||||
|
||||
for d in os.listdir(base_path):
|
||||
full_path = os.path.join(base_path, d, file_to_read)
|
||||
if not os.path.isfile(full_path):
|
||||
continue
|
||||
try:
|
||||
data = pd.read_json(full_path)
|
||||
payloads = pd.DataFrame([r['payload'] for r in data['value'].to_list()])
|
||||
frames.append(payloads)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not process {full_path}: {e}")
|
||||
|
||||
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
|
||||
# demo: run ML training pipeline
|
||||
context = PipelineContext(provider=ExperimentsProvider(), store_mode='hotel')
|
||||
features = ml_training_pipeline(context)
|
||||
print(f"Feature matrix: {features.shape}")
|
||||
print(features.head())
|
||||
print(features.info())
|
||||
@@ -1,14 +0,0 @@
|
||||
from procesing.pricers.base import PricingFunction
|
||||
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||
|
||||
__all__ = [
|
||||
'PricingFunction',
|
||||
'ElasticityBasedPricer',
|
||||
'StaticPricer',
|
||||
'RandomPricer',
|
||||
'SimpleSurgePricer',
|
||||
'SessionAwarePricer',
|
||||
'ProductSpecificSessionPricer'
|
||||
]
|
||||
@@ -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, *kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
|
||||
Args:
|
||||
historical_data: DataFrame with elasticity, prices, demand signals
|
||||
**kwargs: additional training parameters
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Generate optimal prices given current state.
|
||||
|
||||
Args:
|
||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||
|
||||
Returns:
|
||||
P_{t+1}: price vector in R^n
|
||||
"""
|
||||
pass
|
||||
|
||||
def update(self, observation: Dict[str, Any]):
|
||||
"""
|
||||
Online learning update (optional).
|
||||
|
||||
Args:
|
||||
observation: dict with {state, action, reward, next_state}
|
||||
- state: StateSpace before pricing decision
|
||||
- action: prices shown (P_t)
|
||||
- reward: revenue/conversion signal
|
||||
- next_state: StateSpace after user interaction
|
||||
"""
|
||||
pass # default: no online learning
|
||||
|
||||
def get_params(self) -> Dict[str, Any]:
|
||||
"""Return pricing function parameters for serialization."""
|
||||
return {}
|
||||
|
||||
def set_params(self, params: Dict[str, Any]):
|
||||
"""Load pricing function parameters from dict."""
|
||||
pass
|
||||
@@ -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,91 +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)
|
||||
|
||||
|
||||
class SimpleSurgePricer(PricingFunction):
|
||||
"""
|
||||
Rule-based surge pricer adjusting prices via demand thresholds.
|
||||
Logic: if demand > high_threshold -> surge, if demand < low_threshold -> discount.
|
||||
Simpler and more controllable than curve fitting approaches.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
base_prices: np.ndarray = None,
|
||||
high_threshold: int = 10,
|
||||
low_threshold: int = 2,
|
||||
surge_multiplier: float = 1.2,
|
||||
discount_multiplier: float = 0.9):
|
||||
self.base_prices = base_prices
|
||||
self.high_threshold = high_threshold
|
||||
self.low_threshold = low_threshold
|
||||
self.surge_multiplier = surge_multiplier
|
||||
self.discount_multiplier = discount_multiplier
|
||||
|
||||
def fit(self, market_data : pd.DataFrame):
|
||||
"""Extract base prices from product catalog or historical averages"""
|
||||
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
||||
|
||||
def predict(self) -> np.ndarray:
|
||||
"""
|
||||
Adjust prices based on current demand using surge rules.
|
||||
state_space.demand: demand counts per product
|
||||
state_space.prices: current prices (fallback if base_prices not set)
|
||||
"""
|
||||
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99
|
||||
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices)
|
||||
new_prices = current_prices.copy()
|
||||
|
||||
high_mask = demand >= self.high_threshold
|
||||
new_prices[high_mask] *= self.surge_multiplier
|
||||
|
||||
low_mask = demand <= self.low_threshold
|
||||
new_prices[low_mask] *= self.discount_multiplier
|
||||
|
||||
return new_prices
|
||||
@@ -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,42 +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:
|
||||
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||
if not resp.data:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(resp.data)
|
||||
# normalize type column: hotel has room_type, airline has flight_type
|
||||
if 'room_type' in df.columns:
|
||||
df['product_type'] = df['room_type']
|
||||
elif 'flight_type' in df.columns:
|
||||
df['product_type'] = df['flight_type']
|
||||
return df
|
||||
|
||||
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||
if not experiment_ids:
|
||||
return pd.DataFrame()
|
||||
|
||||
resp = self.supabase.table('experiments').select(
|
||||
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, '
|
||||
'task:tasks(task_name, task_description, task_def_of_done)'
|
||||
).in_('id', experiment_ids).execute()
|
||||
|
||||
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||
@@ -1,39 +0,0 @@
|
||||
from procesing.steps.base import BaseContextStep
|
||||
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||
from procesing.steps.session import (
|
||||
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||
_extract_features_for_session
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'BaseContextStep',
|
||||
'FetchInteractionsStep',
|
||||
'FetchPriceLogsStep',
|
||||
'FetchExperimentsStep',
|
||||
'JoinExperimentsStep',
|
||||
'JoinProductFeaturesStep',
|
||||
'CreatePriceBucketsStep',
|
||||
'AugmentEventNamesStep',
|
||||
'AugmentInteractionsStep',
|
||||
'ChunkByTimeWindowStep',
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'ExtractSessionFeaturesStep',
|
||||
'JoinLabelsStep',
|
||||
'ValidateDataStep',
|
||||
'TemporalFeatureStep',
|
||||
'BehavioralFeatureStep',
|
||||
'ProductFeatureStep',
|
||||
'UserAgentFeatureStep',
|
||||
'_extract_features_for_session',
|
||||
]
|
||||
@@ -1,140 +0,0 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
|
||||
class AugmentInteractionsStep(BaseContextStep):
|
||||
"""
|
||||
Consolidated step: create price buckets, augment event names, join experiments.
|
||||
Input: (interactions_df, price_logs_df)
|
||||
Output: enriched interactions_df
|
||||
"""
|
||||
|
||||
def transform(self, data: tuple):
|
||||
interactions_df, price_logs_df = data
|
||||
|
||||
if interactions_df.empty:
|
||||
return interactions_df
|
||||
|
||||
# Step 1: Create price buckets
|
||||
interactions_df = self._create_price_buckets(interactions_df)
|
||||
|
||||
# Step 2: Augment event names
|
||||
interactions_df = self._augment_event_names(interactions_df)
|
||||
|
||||
# Step 3: Join experiments (optional)
|
||||
if 'experimentId' in interactions_df.columns:
|
||||
interactions_df = self._join_experiments(interactions_df)
|
||||
|
||||
return interactions_df
|
||||
|
||||
def _create_price_buckets(self, df: pd.DataFrame):
|
||||
"""Create price bucket labels from price data"""
|
||||
if 'metadata_price' not in df.columns:
|
||||
df['price_bucket'] = ""
|
||||
return df
|
||||
|
||||
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||
|
||||
if df['metadata_price'].notnull().sum() > 0:
|
||||
try:
|
||||
price_buckets = pd.qcut(
|
||||
df['metadata_price'],
|
||||
q=n_buckets,
|
||||
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||
duplicates='drop'
|
||||
)
|
||||
except ValueError:
|
||||
# fallback for insufficient unique values
|
||||
price_buckets = df['metadata_price'].apply(
|
||||
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||
)
|
||||
else:
|
||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||
|
||||
df['price_bucket'] = price_buckets
|
||||
return df
|
||||
|
||||
def _augment_event_names(self, df: pd.DataFrame):
|
||||
"""Augment event names with product and price bucket schema"""
|
||||
# Create schema: _productId@price_bucket
|
||||
has_product = df.get('productId', pd.Series()).notnull()
|
||||
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||
|
||||
df['metadata_schema'] = np.where(
|
||||
has_product & has_bucket,
|
||||
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||
""
|
||||
)
|
||||
|
||||
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||
return df
|
||||
|
||||
def _join_experiments(self, df: pd.DataFrame):
|
||||
"""Join experiment metadata if experimentId present"""
|
||||
exp_ids = df['experimentId'].dropna().unique().tolist()
|
||||
if not exp_ids:
|
||||
return df
|
||||
|
||||
experiments_df = self.context.provider.fetch_experiments(exp_ids)
|
||||
if experiments_df.empty:
|
||||
return df
|
||||
|
||||
return df.merge(
|
||||
experiments_df,
|
||||
left_on='experimentId',
|
||||
right_on='id',
|
||||
how='left',
|
||||
suffixes=('', '_exp')
|
||||
)
|
||||
|
||||
|
||||
class CreatePriceBucketsStep(BaseContextStep):
|
||||
"""Create price bucket labels from price data"""
|
||||
|
||||
def transform(self, df: pd.DataFrame):
|
||||
if df.empty or 'metadata_price' not in df.columns:
|
||||
df['price_bucket'] = ""
|
||||
return df
|
||||
|
||||
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||
|
||||
if df['metadata_price'].notnull().sum() > 0:
|
||||
try:
|
||||
price_buckets = pd.qcut(
|
||||
df['metadata_price'],
|
||||
q=n_buckets,
|
||||
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||
duplicates='drop'
|
||||
)
|
||||
except ValueError:
|
||||
# fallback for insufficient unique values
|
||||
price_buckets = df['metadata_price'].apply(
|
||||
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||
)
|
||||
else:
|
||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||
|
||||
df['price_bucket'] = price_buckets
|
||||
return df
|
||||
|
||||
|
||||
class AugmentEventNamesStep(BaseContextStep):
|
||||
"""Augment event names with product and price bucket schema"""
|
||||
|
||||
def transform(self, df: pd.DataFrame):
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# Create schema: _productId@price_bucket
|
||||
has_product = df.get('productId', pd.Series()).notnull()
|
||||
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||
|
||||
df['metadata_schema'] = np.where(
|
||||
has_product & has_bucket,
|
||||
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||
""
|
||||
)
|
||||
|
||||
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||
return df
|
||||
@@ -1,32 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from sklearn.base import BaseEstimator, TransformerMixin
|
||||
from procesing.context import PipelineContext
|
||||
from typing import Any
|
||||
|
||||
class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||
"""
|
||||
Base for all pipeline steps.
|
||||
Each step is stateless, context-driven, and performs ONE transformation.
|
||||
"""
|
||||
|
||||
def __init__(self, context: PipelineContext):
|
||||
self.context = context
|
||||
|
||||
def fit(self, X=None, y=None):
|
||||
"""Most steps don't need training"""
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, X) -> Any:
|
||||
"""Transform input using context. Must be implemented by subclass."""
|
||||
pass
|
||||
|
||||
def get_params(self, deep=True):
|
||||
"""sklearn compatibility"""
|
||||
return {'context': self.context}
|
||||
|
||||
def set_params(self, **params):
|
||||
"""sklearn compatibility"""
|
||||
if 'context' in params:
|
||||
self.context = params['context']
|
||||
return self
|
||||
@@ -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,42 +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: DataFrame with columns [productId, price]
|
||||
"""
|
||||
|
||||
def transform(self, price_logs_df: pd.DataFrame):
|
||||
if price_logs_df.empty:
|
||||
return pd.DataFrame(columns=['productId', 'price'])
|
||||
|
||||
df = price_logs_df.copy()
|
||||
ts_col = self.context.config.get('ts_col', 'ts')
|
||||
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||
|
||||
# ensure datetime
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||
|
||||
df = df.sort_values([ts_col, 'productId'])
|
||||
products = self.context.products
|
||||
# get base price from metadata if available 1) read the metadata col as json and get the base_price
|
||||
products['base_price'] = products.apply(
|
||||
lambda row: row['metadata'].get('base_price', 0) if isinstance(row['metadata'], dict) else 0,
|
||||
axis=1
|
||||
)
|
||||
|
||||
unique_products = products['id'].unique()
|
||||
|
||||
df_indexed = df.set_index(ts_col)
|
||||
# we return a df of average price per product over the entire period
|
||||
# TODO: maybe consider different opration to handle price aggregation over time
|
||||
avg_prices = df_indexed.groupby('productId')['price'].mean().reindex(unique_products, fill_value=0).reset_index()
|
||||
avg_prices.columns = ['productId', 'price']
|
||||
# fill 0s with base_price from products
|
||||
base_price_map = products.set_index('id')['base_price'].to_dict()
|
||||
return avg_prices
|
||||
@@ -1,81 +0,0 @@
|
||||
import pandas as pd
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
class FetchInteractionsStep(BaseContextStep):
|
||||
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# Explode metadata JSON column
|
||||
if 'metadata' in df.columns:
|
||||
df = df.join(
|
||||
pd.json_normalize(df.pop('metadata'), sep='.').add_prefix('metadata_')
|
||||
)
|
||||
|
||||
df = df.dropna(subset=['eventName'])
|
||||
# drop all where page has /admin/
|
||||
df = df[~df['page'].str.contains('/admin/', na=False)]
|
||||
|
||||
# filter by store_mode from context
|
||||
if 'storeMode' in df.columns:
|
||||
df = df[df['storeMode'] == self.context.store_mode]
|
||||
|
||||
# Remap dateIndex if present
|
||||
if 'metadata_dateIndex' in df.columns:
|
||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||
|
||||
# Apply time filtering if lookback specified
|
||||
if self.lookback and 'ts' in df.columns:
|
||||
df['ts'] = pd.to_datetime(df['ts'])
|
||||
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||
df = df[df['ts'] >= cutoff]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class FetchPriceLogsStep(BaseContextStep):
|
||||
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# filter by store_mode from context
|
||||
if 'storeMode' in df.columns:
|
||||
df = df[df['storeMode'] == self.context.store_mode]
|
||||
|
||||
# Apply time filtering if lookback specified
|
||||
if self.lookback and 'ts' in df.columns:
|
||||
df['ts'] = pd.to_datetime(df['ts'])
|
||||
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||
df = df[df['ts'] >= cutoff]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class FetchExperimentsStep(BaseContextStep):
|
||||
"""Fetch experiment metadata for given interaction data"""
|
||||
|
||||
def transform(self, interactions_df: pd.DataFrame):
|
||||
if interactions_df.empty or 'experimentId' not in interactions_df.columns:
|
||||
return pd.DataFrame()
|
||||
|
||||
exp_ids = interactions_df['experimentId'].dropna().unique().tolist()
|
||||
if not exp_ids:
|
||||
return pd.DataFrame()
|
||||
|
||||
return self.context.provider.fetch_experiments(exp_ids)
|
||||
@@ -1,58 +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')
|
||||
|
||||
class JoinProductFeaturesStep(BaseContextStep):
|
||||
"""Join product features to interactions"""
|
||||
|
||||
def transform(self, data: tuple):
|
||||
"""
|
||||
Args:
|
||||
data: (interactions_df, products_df)
|
||||
Returns:
|
||||
merged interactions dataframe
|
||||
"""
|
||||
demand_df, price_df = data
|
||||
|
||||
# get base prices from products if available
|
||||
products = self.context.products
|
||||
products['base_price'] = products.apply(
|
||||
lambda row: float(row['metadata'].get('base_price', 0.0)) if isinstance(row['metadata'], dict) else 0,
|
||||
axis=1
|
||||
)
|
||||
products = products[['id', 'base_price']].rename(columns={'id': 'productId'})
|
||||
|
||||
if price_df.empty:
|
||||
return demand_df
|
||||
return demand_df.merge(price_df, on='productId', how='left').merge(products, on='productId', how='left')
|
||||
@@ -1,55 +0,0 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
from procesing.pricers.simple import StaticPricer
|
||||
from procesing.steps.base import BaseContextStep
|
||||
from procesing.pricers import ElasticityBasedPricer
|
||||
|
||||
class State:
|
||||
def __init__(self,
|
||||
last_action : str,
|
||||
last_productId : str,
|
||||
last_price : float,
|
||||
session_features : np.ndarray
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class FitPricingFunctionStep(BaseContextStep):
|
||||
"""
|
||||
Fit pricing function using data.
|
||||
Input: pricing_data
|
||||
Output: fitted pricing function instance
|
||||
"""
|
||||
|
||||
def transform(self, pricing_data: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||
|
||||
pricer = pricing_class(**pricing_params)
|
||||
pricer.fit(pricing_data)
|
||||
|
||||
return pricer
|
||||
|
||||
|
||||
class PredictPricesStep(BaseContextStep):
|
||||
"""
|
||||
Predict optimal prices using fitted pricing function.
|
||||
Input: (pricer, state_space)
|
||||
Output: prices_df [productId, predicted_price]
|
||||
"""
|
||||
|
||||
def transform(self, data: tuple):
|
||||
pricer, state_space = data
|
||||
|
||||
products = self.context.products
|
||||
product_ids = products['id'].values
|
||||
|
||||
predicted_prices = pricer.predict(state_space)
|
||||
|
||||
return pd.DataFrame({
|
||||
'productId': product_ids,
|
||||
'predicted_price': predicted_prices
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
"""
|
||||
Session feature extraction for ML training pipeline.
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
EVENT_CATS = {
|
||||
'page_view': ['page_view'],
|
||||
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||
'cart_add': ['add_item_to_cart'],
|
||||
'purchase': ['purchase', 'checkout_complete'],
|
||||
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||
}
|
||||
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||
|
||||
|
||||
def _get_browser(s: str) -> str:
|
||||
if pd.isna(s): return 'Unknown'
|
||||
for name, pat in BROWSER_PATTERNS:
|
||||
if re.search(pat, s): return name
|
||||
return 'Other'
|
||||
|
||||
|
||||
class TemporalFeatureStep(BaseContextStep):
|
||||
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||
|
||||
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||
super().__init__(context)
|
||||
self.timeout_sec = timeout_sec
|
||||
self.velocity_window = velocity_window
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'ts' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
session_duration_sec=('active_diff', 'sum'),
|
||||
total_interactions=('sessionId', 'count'),
|
||||
avg_time_between_events=('time_diff', 'mean'),
|
||||
std_time_between_events=('time_diff', 'std'),
|
||||
min_time_between_events=('time_diff', 'min'),
|
||||
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||
).reset_index()
|
||||
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||
agg['interaction_velocity'] = np.where(
|
||||
agg['session_duration_sec'] > 0,
|
||||
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||
|
||||
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class BehavioralFeatureStep(BaseContextStep):
|
||||
"""Vectorized event counts and ratios per session."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'eventName' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
for cat, events in EVENT_CATS.items():
|
||||
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||
hover_events=('is_hover', 'sum'),
|
||||
# filter_events=('is_filter', 'sum'),
|
||||
).reset_index()
|
||||
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||
return agg
|
||||
|
||||
|
||||
class ProductFeatureStep(BaseContextStep):
|
||||
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||
|
||||
prod_df = df[df['productId'].notna()]
|
||||
if prod_df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||
|
||||
agg = prod_df.groupby('sessionId').agg(
|
||||
unique_products_viewed=('productId', 'nunique'),
|
||||
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||
max_price_seen=('price_seen', 'max'),
|
||||
).reset_index()
|
||||
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class UserAgentFeatureStep(BaseContextStep):
|
||||
"""Parse userAgent into bot-detection signals."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||
df = X.copy()
|
||||
if df.empty or 'userAgent' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||
|
||||
|
||||
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||
"""
|
||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||
Input: interactions_df
|
||||
Output: session-level feature matrix
|
||||
"""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
if X.empty:
|
||||
return pd.DataFrame()
|
||||
df = X.copy()
|
||||
|
||||
# run all feature steps and merge on sessionId
|
||||
temporal = TemporalFeatureStep(self.context).transform(df)
|
||||
behavioral = BehavioralFeatureStep(self.context).transform(df)
|
||||
product = ProductFeatureStep(self.context).transform(df)
|
||||
ua = UserAgentFeatureStep(self.context).transform(df)
|
||||
|
||||
result = temporal
|
||||
for other in [behavioral, product, ua]:
|
||||
if not other.empty and 'sessionId' in other.columns:
|
||||
result = result.merge(other, on='sessionId', how='left')
|
||||
|
||||
# carry forward experimentId for label joining
|
||||
if 'experimentId' in df.columns:
|
||||
exp_map = df.groupby('sessionId')['experimentId'].first()
|
||||
result = result.merge(exp_map, on='sessionId', how='left')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class JoinLabelsStep(BaseContextStep):
|
||||
"""
|
||||
Join experiment labels to session features.
|
||||
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||
Output: labeled feature matrix with is_agent column
|
||||
"""
|
||||
|
||||
def transform(self, X : tuple) -> pd.DataFrame:
|
||||
data = X;
|
||||
if isinstance(data, tuple):
|
||||
features_df, experiments_df = data
|
||||
else:
|
||||
features_df = data
|
||||
if 'experimentId' not in features_df.columns:
|
||||
return features_df
|
||||
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||
|
||||
if features_df.empty:
|
||||
return features_df
|
||||
if experiments_df.empty:
|
||||
features_df['is_agent'] = np.nan
|
||||
return features_df
|
||||
|
||||
exp = experiments_df.copy()
|
||||
if 'id' in exp.columns:
|
||||
exp = exp.rename(columns={'id': 'experimentId'})
|
||||
if 'xp_human_only' in exp.columns:
|
||||
exp['is_agent'] = ~exp['xp_human_only']
|
||||
|
||||
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||
|
||||
|
||||
class ValidateDataStep(BaseContextStep):
|
||||
"""
|
||||
Data quality checks before training.
|
||||
Input: df
|
||||
Output: df (unchanged, but logs validation report to context)
|
||||
"""
|
||||
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||
if df.empty:
|
||||
report['status'] = 'empty'
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||
if missing:
|
||||
report['status'] = 'invalid'
|
||||
report['missing_cols'] = missing
|
||||
|
||||
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||
if 'experimentId' in df.columns:
|
||||
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
|
||||
# legacy compat - kept for backwards compatibility with existing code
|
||||
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||
"""Single-session feature extraction (legacy interface)."""
|
||||
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||
'session_duration_sec', 'interaction_velocity',
|
||||
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||
if session_df.empty:
|
||||
return defaults
|
||||
|
||||
session_df = session_df.copy()
|
||||
if 'sessionId' not in session_df.columns:
|
||||
session_df['sessionId'] = 'tmp'
|
||||
|
||||
# use a dummy context for the steps
|
||||
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||
ctx = DummyCtx()
|
||||
|
||||
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||
p = ProductFeatureStep(ctx).transform(session_df)
|
||||
|
||||
result = {}
|
||||
for df in [t, b, p]:
|
||||
if not df.empty:
|
||||
for col in df.columns:
|
||||
if col != 'sessionId':
|
||||
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||
|
||||
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||
for old, new in remap.items():
|
||||
if old in result:
|
||||
result[new] = result.pop(old)
|
||||
return result
|
||||
@@ -1,281 +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': 'hotel',
|
||||
'ts': '2025-11-25T21:05:57.967Z'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'partitionID': 0, 'offset': 33, 'timestamp': 1764104757995,
|
||||
'value': {
|
||||
'payload': {
|
||||
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||
'price': 743.49,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'ts': '2025-11-25T21:05:57.993Z'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'partitionID': 0, 'offset': 34, 'timestamp': 1764104758011,
|
||||
'value': {
|
||||
'payload': {
|
||||
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||
'price': 163.87,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'ts': '2025-11-25T21:05:58.009Z'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'partitionID': 0, 'offset': 35, 'timestamp': 1764104758050,
|
||||
'value': {
|
||||
'payload': {
|
||||
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||
'price': 397.46,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'ts': '2025-11-25T21:05:58.049Z'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'partitionID': 0, 'offset': 36, 'timestamp': 1764104768865,
|
||||
'value': {
|
||||
'payload': {
|
||||
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||
'price': 401.66,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'ts': '2025-11-25T21:06:08.864Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_price_logs(mock_price_logs_raw_kafka):
|
||||
"""Processed price logs DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||
# extract payloads and flatten
|
||||
records = [msg['value']['payload'] for msg in mock_price_logs_raw_kafka]
|
||||
df = pd.DataFrame(records)
|
||||
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||
return df
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_experiments():
|
||||
"""Standard experiment metadata fixture matching Supabase schema"""
|
||||
return pd.DataFrame({
|
||||
'id': ['53aefd07-f66a-4d7f-ba8b-7ea1fc562d35', 'bbbbcccc-dddd-eeee-ffff-000011112222'],
|
||||
'created_at': pd.to_datetime(['2025-11-25T20:00:00Z', '2025-11-26T10:00:00Z']),
|
||||
'subject_name': ['Session A', 'Session B'],
|
||||
'xp_human_only': [True, False],
|
||||
'xp_market_mode': ['hotel', 'airline'],
|
||||
'xp_task_id': [None, None]
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider(mock_products, mock_experiments, mock_interactions, mock_price_logs):
|
||||
"""Fully configured mock provider"""
|
||||
return MockProvider(
|
||||
products_df=mock_products,
|
||||
experiments_df=mock_experiments,
|
||||
kafka_data={
|
||||
'user-interactions': mock_interactions,
|
||||
'price-logs': mock_price_logs
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline_context(mock_provider):
|
||||
"""Standard pipeline context for testing"""
|
||||
return PipelineContext(
|
||||
provider=mock_provider,
|
||||
store_mode='hotel',
|
||||
window_size='30s',
|
||||
n_price_buckets=3
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_provider():
|
||||
"""Provider with no data, for edge case testing"""
|
||||
return MockProvider(
|
||||
products_df=pd.DataFrame(columns=['id', 'name', 'base_price']),
|
||||
experiments_df=pd.DataFrame(columns=['id', 'created_at', 'subject_name', 'xp_human_only', 'xp_market_mode', 'xp_task_id']),
|
||||
kafka_data={'user-interactions': pd.DataFrame(), 'price-logs': pd.DataFrame()}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_context(empty_provider):
|
||||
"""Context with empty provider"""
|
||||
return PipelineContext(
|
||||
provider=empty_provider,
|
||||
store_mode='hotel',
|
||||
window_size='30s'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_interactions(mock_interactions):
|
||||
"""Enriched interaction data for session feature extraction tests"""
|
||||
df = mock_interactions.copy()
|
||||
df['userAgent'] = ['Mozilla/5.0 Chrome/120', 'Mozilla/5.0 Chrome/120',
|
||||
'HeadlessChrome/120', 'HeadlessChrome/120', 'HeadlessChrome/120']
|
||||
df['metadata_base_price'] = [None, None, 150.0, 150.0, 200.0]
|
||||
return df
|
||||
@@ -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,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,180 +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:"
|
||||
self.prices_prefix = "prices:"
|
||||
|
||||
def publish_elasticity(self,
|
||||
elasticity_df: pd.DataFrame,
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Store elasticity estimates in registry.
|
||||
|
||||
Args:
|
||||
elasticity_df: df with [productId, elasticity, std_error, n_obs]
|
||||
model_name: identifier for this elasticity snapshot
|
||||
metadata: additional info (timestamp, window_size, etc)
|
||||
"""
|
||||
key = f"{self.elasticity_prefix}{model_name}"
|
||||
|
||||
# serialize dataframe as JSON
|
||||
data_json = elasticity_df.to_json(orient='records')
|
||||
|
||||
# store data
|
||||
self.redis_client.set(key, data_json)
|
||||
|
||||
# store metadata
|
||||
meta = metadata or {}
|
||||
meta.update({
|
||||
'n_products': len(elasticity_df),
|
||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
||||
'model_type': 'elasticity_snapshot'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published elasticity model '{model_name}' with {len(elasticity_df)} products")
|
||||
|
||||
def get_elasticity(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||
"""Retrieve elasticity estimates from registry."""
|
||||
key = f"{self.elasticity_prefix}{model_name}"
|
||||
data_json = self.redis_client.get(key)
|
||||
|
||||
if data_json is None:
|
||||
return None
|
||||
|
||||
# decode bytes to string if needed
|
||||
if isinstance(data_json, bytes):
|
||||
data_json = data_json.decode('utf-8')
|
||||
|
||||
return pd.read_json(data_json, orient='records')
|
||||
|
||||
def publish_pricing_model(self,
|
||||
pricing_function,
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Store a fitted pricing function object.
|
||||
|
||||
Args:
|
||||
pricing_function: fitted PricingFunction instance
|
||||
model_name: identifier
|
||||
metadata: additional info
|
||||
"""
|
||||
key = f"{self.data_prefix}{model_name}"
|
||||
|
||||
# serialize object
|
||||
model_bytes = pickle.dumps(pricing_function)
|
||||
self.redis_client.set(key, model_bytes)
|
||||
|
||||
# store metadata
|
||||
meta = metadata or {}
|
||||
meta.update({
|
||||
'model_class': pricing_function.__class__.__name__,
|
||||
'model_type': 'pricing_function'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published pricing model '{model_name}' ({meta['model_class']})")
|
||||
|
||||
def get_pricing_model(self, model_name: str = 'latest'):
|
||||
"""Retrieve a pricing function from registry."""
|
||||
key = f"{self.data_prefix}{model_name}"
|
||||
model_bytes = self.redis_client.get(key)
|
||||
|
||||
if model_bytes is None:
|
||||
return None
|
||||
|
||||
return pickle.loads(model_bytes)
|
||||
|
||||
def list_models(self) -> Dict[str, Any]:
|
||||
"""List all registered models with metadata."""
|
||||
models = {}
|
||||
|
||||
for key in self.redis_client.scan_iter(f"{self.metadata_prefix}*"):
|
||||
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
||||
model_name = key_str.replace(self.metadata_prefix, '')
|
||||
meta_json = self.redis_client.get(key)
|
||||
|
||||
if meta_json:
|
||||
if isinstance(meta_json, bytes):
|
||||
meta_json = meta_json.decode('utf-8')
|
||||
models[model_name] = json.loads(meta_json)
|
||||
|
||||
return models
|
||||
|
||||
def publish_prices(self,
|
||||
prices_df: pd.DataFrame,
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Store predicted prices in registry.
|
||||
|
||||
Args:
|
||||
prices_df: df with [productId, predicted_price, ...]
|
||||
model_name: identifier for this price snapshot
|
||||
metadata: additional info
|
||||
"""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = prices_df.to_json(orient='records')
|
||||
|
||||
self.redis_client.set(key, data_json)
|
||||
|
||||
meta = metadata or {}
|
||||
meta.update({
|
||||
'n_products': len(prices_df),
|
||||
'model_type': 'predicted_prices'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
||||
|
||||
def get_prices(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||
"""Retrieve predicted prices from registry."""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = self.redis_client.get(key)
|
||||
|
||||
if data_json is None:
|
||||
return None
|
||||
|
||||
if isinstance(data_json, bytes):
|
||||
data_json = data_json.decode('utf-8')
|
||||
|
||||
return pd.read_json(data_json, orient='records')
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if Redis connection is alive."""
|
||||
try:
|
||||
self.redis_client.ping()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
@@ -16,15 +16,11 @@ mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||
add_file() {
|
||||
local filepath="$1"
|
||||
local relpath="${filepath#$PROJECT_ROOT/}"
|
||||
local escaped_path="${relpath//_/\\_}"
|
||||
|
||||
# Add section header and code listing (no language-specific highlighting)
|
||||
echo "\\subsection{${escaped_path}}" >> "$OUTPUT_FILE"
|
||||
echo "\\begin{lstlisting}[caption={${escaped_path}}]" >> "$OUTPUT_FILE"
|
||||
# Convert to ASCII: transliterate what's possible, drop the rest
|
||||
# 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 "\\subsection{${relpath}}" >> "$OUTPUT_FILE"
|
||||
echo "\\begin{lstlisting}[caption={${relpath}}]" >> "$OUTPUT_FILE"
|
||||
cat "$filepath" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
echo "\\end{lstlisting}" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
@@ -22,3 +22,4 @@
|
||||
(TeX-add-symbols
|
||||
'("footnotetextcopyrightpermission" 1)))
|
||||
:latex)
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@phdthesis{,
|
||||
abstract = {Algorithmic pricing is an emerging business practice that uses computational algorithms to determine
|
||||
the prices of products and services based on a number of dynamic factors. The aim of this thesis is to
|
||||
draw attention to the existence of these business practices, and the ethical and social implications that
|
||||
derive from them, and then focus on what could be effective solutions to increase the well-being of
|
||||
the community.
|
||||
In Chapter 2 of the thesis, a general introduction to the topic will be made, starting from its history
|
||||
and its evolution over the years; Chapter 3 will examine the different types of pricing algorithms.
|
||||
Subsequently, in Chapter 4 we will analyze the sectors in which they are most applicable, and the
|
||||
relative advantages and disadvantages they bring with them, with a critical analysis of the trade-offs
|
||||
generated. The effect of algorithmic pricing on competition will be studied, considering how the
|
||||
ability of algorithms to adapt quickly to market conditions can foster anti-competitive practices, such
|
||||
as price discrimination. Later, in Chapter 5, we will look at the issue of price transparency and how
|
||||
the opacity of algorithms can make it difficult for consumers to understand the pricing process and
|
||||
assess whether they are receiving fair treatment.
|
||||
To address these ethical issues, several possible solutions will be brought to light, described in
|
||||
Chapter 6, which will focus on the role of the government, as a regulatory, of the end consumer, who
|
||||
must be encouraged to educate and inform himself about the use of these practices, and of the
|
||||
company, as responsible for making its customers aware and acting in compliance with government
|
||||
laws, for fair and non-discriminatory use.},
|
||||
author = {Fabio Salassa and Paolo Pautassi},
|
||||
school = {Politecnico di Torino},
|
||||
title = {Politecnico di Torino Algorithmic Pricing in the digital age "Ethical considerations on its economic and social implications, and an analysis of possible solutions to overcome its critical issues" Tutor: Candidate},
|
||||
url = {https://webthesis.biblio.polito.it/secure/31375/1/tesi.pdf}
|
||||
}
|
||||
@inproceedings{Mueller2019,
|
||||
author = {Jonas W Mueller and Vasilis Syrgkanis and Matt Taddy},
|
||||
booktitle = {Advances in Neural Information Processing Systems 32 (NeurIPS 2019)},
|
||||
pages = {15442-15452},
|
||||
title = {Low-Rank Bandit Methods for High-Dimensional Dynamic Pricing},
|
||||
url = {https://proceedings.neurips.cc/paper/2019/file/0a3df70393993583a13c0dd6686f3f32-Paper.pdf},
|
||||
year = {2019}
|
||||
}
|
||||
@article{Prez-Ricardo2025,
|
||||
abstract = {The study aims to explore tourists' booking intentions by analyzing the price elasticity of demand in tourist accommodations. This analysis should reveal how changes in price affect booking behavior across different customer segments, using online booking records. A dataset was compiled from 106 hotels in Malaga, Spain, comprising 27,910 online bookings sourced exclusively from hotel websites. To understand the price elasticity of demand, a simple log-log regression was applied, segmenting the data based on key revenue-related variables. Subsequently, a cluster segmentation was performed using the Elbow method and K-means algorithm to identify distinct market segments. The findings highlighted that Family Travelers and Short Stay Travelers segments exhibited elastic demand, indicating higher sensitivity to price fluctuations. In contrast, Early Bookers and Mid-Season Long Stayers demonstrated inelastic demand, with lower responsiveness to changes in tourist accommodation prices. The number of variables analyzed in this study, along with the cluster analysis, represent a novelty and contribute to the existing literature on market segmentation and price elasticity of demand. This integration enriches both fields of research, offering mutual benefits and deeper insights that enhance the understanding of booking intention and pricing strategies.},
|
||||
author = {Elizabeth del Carmen Pérez-Ricardo and Josefa García-Mestanza},
|
||||
doi = {10.1016/j.iedeen.2025.100271},
|
||||
issn = {24448834},
|
||||
issue = {1},
|
||||
journal = {European Research on Management and Business Economics},
|
||||
keywords = {Booking intention,Price elasticity,Tourist segmentation},
|
||||
month = {1},
|
||||
publisher = {European Academy of Management and Business Economics},
|
||||
title = {Exploring booking intentions through price elasticity of demand in tourism accommodations using large-scale data analytics},
|
||||
volume = {31},
|
||||
year = {2025}
|
||||
}
|
||||
@article{ArnoudVdenBoer2015,
|
||||
author = {Arnoud V. den Boer},
|
||||
doi = {10.1016/j.sorms.2015.03.001},
|
||||
issue = {1},
|
||||
journal = {Surveys in Operations Research and Management Science},
|
||||
month = {6},
|
||||
pages = {1-18},
|
||||
title = {Dynamic pricing and learning: Historical origins, current research, and new directions},
|
||||
volume = {20},
|
||||
url = {https://www.sciencedirect.com/science/article/pii/S1876735415000021},
|
||||
year = {2015}
|
||||
}
|
||||
@article{Iliou2021,
|
||||
author = {Christos Iliou and Theodoros Kostoulas and Theodora Tsikrika and Vasilis Katos and Stefanos Vrochidis and Ioannis Kompatsiaris},
|
||||
doi = {10.1145/3447815},
|
||||
issue = {3},
|
||||
journal = {Digital Threats: Research and Practice},
|
||||
pages = {1-26},
|
||||
title = {Detection of Advanced Web Bots by Combining Web Logs with Mouse Behavioural Biometrics},
|
||||
volume = {2},
|
||||
url = {https://dl.acm.org/doi/10.1145/3447815},
|
||||
year = {2021}
|
||||
}
|
||||
@article{Amjad2017,
|
||||
abstract = { In this paper, the question of interest is estimating true demand of a product at a given store location and time period in the retail environment based on a single noisy and potentially censored observation. To address this question, we introduce a %non-parametric framework to make inference from multiple time series. Somewhat surprisingly, we establish that the algorithm introduced for the purpose of "matrix completion" can be used to solve the relevant inference problem. Specifically, using the Universal Singular Value Thresholding (USVT) algorithm [7], we show that our estimator is consistent: the average mean squared error of the estimated average demand with respect to the true average demand goes to 0 as the number of store locations and time intervals increase to $\infty$. We establish naturally appealing properties of the resulting estimator both analytically as well as through a sequence of instructive simulations. Using a real dataset in retail (Walmart), we argue for the practical relevance of our approach. },
|
||||
author = {Muhammad J. Amjad and Devavrat Shah},
|
||||
doi = {10.1145/3154489},
|
||||
issue = {2},
|
||||
journal = {Proceedings of the ACM on Measurement and Analysis of Computing Systems},
|
||||
month = {12},
|
||||
pages = {1-28},
|
||||
publisher = {Association for Computing Machinery (ACM)},
|
||||
title = {Censored Demand Estimation in Retail},
|
||||
volume = {1},
|
||||
url = {https://par.nsf.gov/servlets/purl/10066022},
|
||||
year = {2017}
|
||||
}
|
||||
@article{Calvano2018,
|
||||
author = {Emilio Calvano and Giacomo Calzolari and Vincenzo Denicolo and Sergio Pastorello},
|
||||
doi = {10.2139/ssrn.3304991},
|
||||
journal = {SSRN Electronic Journal},
|
||||
title = {Artificial Intelligence, Algorithmic Pricing and Collusion},
|
||||
url = {https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3304991},
|
||||
year = {2018}
|
||||
}
|
||||
@misc{gha_ffary_day_2025_amazon_perplexit,
|
||||
author = {Shirin Ghaffary and Matt Day},
|
||||
note = {Updated 2025-11-05},
|
||||
title = {Amazon Sues to Stop Perplexity From Using AI Tool to Buy Stuff},
|
||||
url = {https://www.bloomberg.com/news/articles/2025-11-04/amazon-demands-perplexity-stop-ai-agent-from-making-purchases}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Mathematical formalization of agent-induced pricing distortions. Formal definition of potential loss mechanisms $\alpha D$
|
||||
|
||||
We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research.
|
||||
We consider a business across time during which we have an evolving vector $p_t \in \Re^N$ where $N$ is the number of products in our catalogue. our price vector is directly dependent on a demand function $q_t$ which we define as a linear method of a price elasticity matrix $B_t$. This is the same setup that Microsoft created in their research. \autocite{Mueller2019}
|
||||
|
||||
We gether interaction data from users interacting with a sample platform simulating a hotel/airline which generates interaction distributions $I_t = \{(p_t, q_t^\text{obs}, \pi_t)\}_{t=1}^T$
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
\begin{abstract}
|
||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
||||
\end{abstract}
|
||||
|
||||
\maketitle
|
||||
@@ -42,10 +42,10 @@ The primary objective of this thesis is to develop and validate pricing heuristi
|
||||
\input{chapters/06-conclusion}
|
||||
|
||||
|
||||
\printbibliography
|
||||
|
||||
\clearpage
|
||||
\onecolumn
|
||||
\printbibliography
|
||||
\clearpage
|
||||
\appendix
|
||||
\input{../build/concatenated_code}
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@
|
||||
commentstyle=\color{green!60!black},
|
||||
stringstyle=\color{red},
|
||||
showstringspaces=false,
|
||||
captionpos=b,
|
||||
inputencoding=utf8,
|
||||
extendedchars=true,
|
||||
literate={·}{{\textperiodcentered}}1 {−}{{\textminus}}1 {—}{{---}}1 {–}{{--}}1
|
||||
captionpos=b
|
||||
}
|
||||
|
||||
% Use biblatex instead of natbib (acmart default)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
[pytest]
|
||||
pythonpath = experiments
|
||||
testpaths = experiments
|
||||
python_files = test*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -5,10 +5,3 @@ jupyter
|
||||
ipykernel
|
||||
matplotlib
|
||||
graphviz
|
||||
browser-use
|
||||
pytest
|
||||
pytest-asyncio
|
||||
uv
|
||||
scikit-learn
|
||||
supabase
|
||||
pymc
|
||||
|
||||
101
web/README.md
101
web/README.md
@@ -1,97 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
# Phantom Air/Hotels
|
||||
## Getting Started
|
||||
|
||||
Design Discovery Documentation: https://github.com/velocitatem/PHANTOM/wiki/Design-Discovery
|
||||
First, run the development server:
|
||||
|
||||
> This webapp serves two modes `{HOTEL,AIRLINE}` which are given by an env variable
|
||||
|
||||
The webapp should serve under the / route the landing page which for both platforms is very similar. We define a set of components like Hero, Card, Button, Link ... This we can then pass to specific components each mode might demand that makes it behave differently, hotel cards showing hotel rooms from database and airline cards showing flights from database and each fetching prices from the pricing provider with a different HTTP parameter.
|
||||
|
||||
- globally we define a middleware.ts which is our switcher for modes.
|
||||
- /app will have (airline) and (hotel) children which each have a layout.tsx and page.tsx where /app also has a parent layout defining layout.tsx and globals.css for any shared styling to avoid repretition.
|
||||
- /components/ is gonna have ui/ which defines things like Button, Card, DatePicker with generic definitions and any tracking or observation code. We then define feats/airline/ and feats/hotel/ as children of components with specific components like AirlineHero and HotelCard.
|
||||
- in /styles/ we define airline.css and hotel.css to tailor accents and styling for each.
|
||||
|
||||
## How to Run
|
||||
|
||||
```sh
|
||||
# install deps
|
||||
npm install
|
||||
|
||||
# set store mode (hotel or airline)
|
||||
export STORE_MODE=hotel
|
||||
|
||||
# run dev server
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Server runs on `http://localhost:3000`
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## Environment Variables
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
| Variable | Description | Default | Example |
|
||||
|----------|-------------|---------|---------|
|
||||
| `HOSTNAME` | Server hostname | `localhost` | `localhost` |
|
||||
| `STORE_MODE` | Mode switch for platform | `hotel` | `hotel` or `airline` |
|
||||
| `NEXT_PUBLIC_API_BASE` | Public API base URL | `http://localhost:3000` | `http://localhost:3000` |
|
||||
| `NEXT_PUBLIC_APP_ENV` | Application environment | `dev` | `dev`, `prod` |
|
||||
| `NEXT_PUBLIC_HOVER_THRESHOLD` | Hover dwell threshold (ms) | `1200` | `1200` |
|
||||
| `BACKEND_URL` | Backend service URL | `http://localhost:5000` | `http://localhost:5000` |
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Routes
|
||||
## Learn More
|
||||
|
||||
### Public Pages
|
||||
- `/` — Landing page (mode-aware root)
|
||||
- `/hotel` — Hotel mode landing
|
||||
- `/hotel/products` — Hotel catalog
|
||||
- `/airline` — Airline mode landing
|
||||
- `/airline/products` — Flight catalog
|
||||
- `/admin/experiments` — Experiment management UI
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
### API Routes
|
||||
- `GET /api/session` — Fetch or create session, sets httpOnly cookie
|
||||
- `GET /api/pricing?productId=X&sessionId=Y&experimentId=Z` — Get product price from provider
|
||||
- `POST /api/ingest` — Ingest event to Kafka via backend
|
||||
- `GET /api/admin/experiments` — List all experiments
|
||||
- `POST /api/admin/experiments/start` — Start new experiment for session
|
||||
- `POST /api/admin/experiments/stop` — Stop experiment by ID
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## Event Catalog
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
All events are ingested via `POST /api/ingest` and follow the `EventBase` schema. Below are the 17 canonical events:
|
||||
## Deploy on Vercel
|
||||
|
||||
| Event Name | Category | Payload Example |
|
||||
|------------|----------|-----------------|
|
||||
| `session_start` | Session | `{ sessionId, experimentId?, storeMode, ts, page, eventName, userAgent? }` |
|
||||
| `page_view` | Navigation | `{ sessionId, experimentId?, storeMode, ts, page: "/hotel", eventName: "page_view" }` |
|
||||
| `view_item_page` | Discovery | `{ sessionId, storeMode, ts, page: "/hotel/products", productId: "H001", eventName: "view_item_page" }` |
|
||||
| `learn_more_about_item` | Discovery | `{ sessionId, storeMode, ts, page, productId, eventName: "learn_more_about_item" }` |
|
||||
| `add_item_to_cart` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "add_item_to_cart" }` |
|
||||
| `remove_item` | Cart | `{ sessionId, storeMode, ts, page, productId, eventName: "remove_item" }` |
|
||||
| `checkout_start` | Cart | `{ sessionId, storeMode, ts, page, eventName: "checkout_start" }` |
|
||||
| `purchase_complete` | Cart | `{ sessionId, storeMode, ts, page, eventName: "purchase_complete", metadata?: { total: 500 } }` |
|
||||
| `search` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "search", metadata: { query: "paris" } }` |
|
||||
| `filter_for_date` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_date", metadata: { from: "2025-01-15", to: "2025-01-20" } }` |
|
||||
| `filter_for_amenities` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_amenities", metadata: { amenities: ["wifi", "pool"] } }` |
|
||||
| `filter_for_price` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "filter_for_price", metadata: { min: 100, max: 500 } }` |
|
||||
| `sort_change` | Filter/Search | `{ sessionId, storeMode, ts, page, eventName: "sort_change", metadata: { sort: "price_asc" } }` |
|
||||
| `hover_over_title` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_title", metadata: { duration: 1500 } }` |
|
||||
| `hover_over_paragraph` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_paragraph", metadata: { duration: 2000 } }` |
|
||||
| `hover_over_link` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_link", metadata: { href: "/hotel/products" } }` |
|
||||
| `hover_over_button` | Dwell signal | `{ sessionId, storeMode, ts, page, productId?, eventName: "hover_over_button", metadata: { label: "Book Now" } }` |
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Route Groups
|
||||
- `(hotel)` — Hotel mode pages
|
||||
- `(airline)` — Airline mode pages
|
||||
- `api/*` — API routes (session, pricing, ingest, admin)
|
||||
|
||||
### Middleware Flow
|
||||
1. Request arrives at Next.js
|
||||
2. Session middleware checks for `phantom_session_id` cookie
|
||||
3. If missing, `/api/session` mints new session + sets cookie
|
||||
4. Store mode (`STORE_MODE` env) determines rendered page variant
|
||||
5. Client-side components fetch pricing via `/api/pricing`
|
||||
6. User interactions emit events to `/api/ingest` → Kafka
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
242
web/package-lock.json
generated
242
web/package-lock.json
generated
@@ -8,12 +8,10 @@
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "^16.0.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -526,15 +524,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.0.tgz",
|
||||
"integrity": "sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.0.tgz",
|
||||
"integrity": "sha512-/CntqDCnk5w2qIwMiF0a9r6+9qunZzFmU0cBX4T82LOflE72zzH6gnOjCwUXYKOBlQi8OpP/rMj8cBIr18x4TA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -548,9 +546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.0.tgz",
|
||||
"integrity": "sha512-hB4GZnJGKa8m4efvTGNyii6qs76vTNl+3dKHTCAUaksN6KjYy4iEO3Q5ira405NW2PKb3EcqWiRaL9DrYJfMHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -564,9 +562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-E2IHMdE+C1k+nUgndM13/BY/iJY9KGCphCftMh7SXWcaQqExq/pJU/1Hgn8n/tFwSoLoYC/yUghOv97tAsIxqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -580,9 +578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-xzgl7c7BVk4+7PDWldU+On2nlwnGgFqJ1siWp3/8S0KBBLCjonB6zwJYPtl4MUY7YZJrzzumdUpUoquu5zk8vg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -596,9 +594,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.0.tgz",
|
||||
"integrity": "sha512-sdyOg4cbiCw7YUr0F/7ya42oiVBXLD21EYkSwN+PhE4csJH4MSXUsYyslliiiBwkM+KsuQH/y9wuxVz6s7Nstg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -612,9 +610,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.0.tgz",
|
||||
"integrity": "sha512-IAXv3OBYqVaNOgyd3kxR4L3msuhmSy1bcchPHxDOjypG33i2yDWvGBwFD94OuuTjjTt/7cuIKtAmoOOml6kfbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -628,9 +626,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-bmo3ncIJKUS9PWK1JD9pEVv0yuvp1KPuOsyJTHXTv8KDrEmgV/K+U0C75rl9rhIaODcS7JEb6/7eJhdwXI0XmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -644,9 +642,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.0.tgz",
|
||||
"integrity": "sha512-O1cJbT+lZp+cTjYyZGiDwsOjO3UHHzSqobkPNipdlnnuPb1swfcuY6r3p8dsKU4hAIEO4cO67ZCfVVH/M1ETXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -659,97 +657,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
|
||||
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
|
||||
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
|
||||
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
|
||||
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
|
||||
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.43.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
|
||||
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
|
||||
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.81.1",
|
||||
"@supabase/functions-js": "2.81.1",
|
||||
"@supabase/postgrest-js": "2.81.1",
|
||||
"@supabase/realtime-js": "2.81.1",
|
||||
"@supabase/storage-js": "2.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1034,17 +941,12 @@
|
||||
"version": "20.19.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@@ -1065,15 +967,6 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
@@ -1100,15 +993,6 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -1157,6 +1041,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/kafkajs": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
|
||||
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -1447,12 +1340,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.0.tgz",
|
||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.7",
|
||||
"@next/env": "16.0.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -1465,14 +1358,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"@next/swc-darwin-arm64": "16.0.0",
|
||||
"@next/swc-darwin-x64": "16.0.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.0",
|
||||
"@next/swc-linux-arm64-musl": "16.0.0",
|
||||
"@next/swc-linux-x64-gnu": "16.0.0",
|
||||
"@next/swc-linux-x64-musl": "16.0.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.0",
|
||||
"@next/swc-win32-x64-msvc": "16.0.0",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -1721,37 +1614,8 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"next": "^16.0.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"next": "16.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"zod": "^4.1.12"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TaskManager } from '@/components/admin/TaskManager';
|
||||
import { ExperimentForm } from '@/components/admin/ExperimentForm';
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
subject_name: string;
|
||||
xp_human_only: boolean;
|
||||
xp_market_mode: string;
|
||||
created_at: string;
|
||||
task?: {
|
||||
id: string;
|
||||
task_name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ExperimentsAdmin() {
|
||||
const [exps, setExps] = useState<Experiment[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const fetchExps = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments');
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setExps(data.experiments || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchExps();
|
||||
}, []);
|
||||
|
||||
const handleExperimentCreated = async () => {
|
||||
setShowForm(false);
|
||||
setSelectedTaskId(undefined);
|
||||
await fetchExps();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 px-6 py-12 dark:bg-black">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||
Experiment Management
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
configure tasks and run experiments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* left column: task manager */}
|
||||
<div className="lg:col-span-1">
|
||||
<TaskManager
|
||||
onTaskSelect={setSelectedTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* right column: experiment form + list */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Experiments
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{showForm ? 'hide form' : 'new experiment'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ExperimentForm
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSuccess={handleExperimentCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
subject
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
mode
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
human
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
task
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
created
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
link
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{exps.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
no experiments yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
exps.map((exp) => {
|
||||
const baseUrl = exp.xp_market_mode === 'airline'
|
||||
? 'https://phantom-airline.vercel.app'
|
||||
: 'https://phantom-hotel.vercel.app';
|
||||
const link = `${baseUrl}/start-task?uuid=${exp.id}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={exp.id}
|
||||
className="hover:bg-zinc-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{exp.subject_name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
{exp.xp_market_mode || 'none'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{exp.xp_human_only ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
yes
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-500">no</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{exp.task ? exp.task.task_name : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{new Date(exp.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
}}
|
||||
className="text-xs font-medium text-zinc-900 hover:text-zinc-600 dark:text-zinc-100 dark:hover:text-zinc-400"
|
||||
>
|
||||
copy link
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export default function AirlineCheckout() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-sky-50 to-blue-50">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||
Thank you for flying with us
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/airline.css';
|
||||
|
||||
export default function AirlineLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="airline">{children}</div>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import AirlineHero from '@/components/feats/airline/AirlineHero';
|
||||
|
||||
export default function AirlineHome() {
|
||||
return (
|
||||
<main>
|
||||
<AirlineHero />
|
||||
</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 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,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import AirlineCard from '@/components/feats/airline/AirlineCard';
|
||||
import { transformProduct, type Flight, type AirlineProduct } from '@/lib/airline-utils';
|
||||
|
||||
function FlightsList() {
|
||||
const searchParams = useSearchParams();
|
||||
const [flights, setFlights] = useState<Flight[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFlights = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
url.searchParams.set('type', 'airline');
|
||||
|
||||
// forward all relevant search params to the API
|
||||
const params = ['dateIndex', 'origin', 'destination', 'tripType', 'adults', 'children', 'infants'];
|
||||
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: AirlineProduct) => transformProduct(p));
|
||||
setFlights(transformed);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load products');
|
||||
console.error('[FETCH_ERROR]', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFlights();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold mb-6">Available Flights</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">
|
||||
{flights.map((f) => (
|
||||
<AirlineCard key={f.id} flight={f} />
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (id) {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select(`
|
||||
*,
|
||||
task:tasks(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ experiment: data });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select(`
|
||||
*,
|
||||
task:tasks(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ experiments: data || [] });
|
||||
} catch (err: any) {
|
||||
console.error('experiments list error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
const body = await req.json();
|
||||
|
||||
const { subject_name, xp_human_only, xp_market_mode, xp_task_id } = body;
|
||||
|
||||
if (!subject_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'subject_name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.insert([{
|
||||
subject_name,
|
||||
xp_human_only: xp_human_only ?? false,
|
||||
xp_market_mode: xp_market_mode || null,
|
||||
xp_task_id: xp_task_id || null,
|
||||
}])
|
||||
.select(`
|
||||
*,
|
||||
task:tasks(*)
|
||||
`)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ experiment: data });
|
||||
} catch (err: any) {
|
||||
console.error('experiment creation error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createExperiment, getSession } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify session exists
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// generate and create experiment
|
||||
const experimentId = randomUUID();
|
||||
const exp = createExperiment(sessionId, experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp.id,
|
||||
sessionId,
|
||||
status: exp.status,
|
||||
createdAt: exp.createdAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment start error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stopExperimentById, getExperiment } from '@/lib/sessionStore';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { experimentId } = body;
|
||||
|
||||
if (!experimentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experimentId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// verify experiment exists
|
||||
const existing = getExperiment(experimentId);
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experiment not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// stop the experiment
|
||||
const exp = stopExperimentById(experimentId);
|
||||
|
||||
return NextResponse.json({
|
||||
experimentId: exp!.id,
|
||||
status: exp!.status,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('experiment stop error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ tasks: data || [] });
|
||||
} catch (err: any) {
|
||||
console.error('tasks fetch error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createClient(cookieStore);
|
||||
const body = await req.json();
|
||||
|
||||
const { task_name, task_description, task_def_of_done } = body;
|
||||
|
||||
if (!task_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'task_name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.insert([{ task_name, task_description, task_def_of_done }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ task: data });
|
||||
} catch (err: any) {
|
||||
console.error('task creation error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { EventBase } from '@/lib/events';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||
const userAgent = req.headers.get('user-agent') || undefined;
|
||||
|
||||
const event: EventBase = {
|
||||
...body,
|
||||
storeMode,
|
||||
userAgent,
|
||||
ts: body.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api/kafka/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Backend returned ${res.status}`);
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
|
||||
console.log('[ingest]', event);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('[ingest error]', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
interface PricingResponse {
|
||||
price: number;
|
||||
currency: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const productId = searchParams.get('productId');
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const experimentId = searchParams.get('experimentId');
|
||||
const storeMode = process.env.NEXT_PUBLIC_STORE_MODE || process.env.STORE_MODE || 'hotel';
|
||||
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'productId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
let price: number;
|
||||
let basePrice: number | undefined;
|
||||
let markup: number | undefined;
|
||||
let elasticity: number | undefined;
|
||||
|
||||
// call real pricing provider
|
||||
const providerUrl = process.env.PRICING_PROVIDER_URL || 'http://localhost:5001';
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (sessionId) queryParams.append('sessionId', sessionId);
|
||||
if (experimentId) queryParams.append('experimentId', experimentId);
|
||||
|
||||
const providerResponse = await fetch(
|
||||
`${providerUrl}/api/${storeMode}/price/${productId}?${queryParams.toString()}`,
|
||||
{ headers: { 'Accept': 'application/json' }, cache: 'no-store' }
|
||||
);
|
||||
|
||||
if (!providerResponse.ok) {
|
||||
throw new Error(`Provider returned ${providerResponse.status}`);
|
||||
}
|
||||
|
||||
const providerData = await providerResponse.json();
|
||||
price = providerData.price;
|
||||
basePrice = providerData.base_price;
|
||||
markup = providerData.markup;
|
||||
elasticity = providerData.elasticity;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[pricing-provider-error]', err);
|
||||
// fallback to random pricing if provider unavailable
|
||||
const randomBase = 100 + Math.random() * 900;
|
||||
price = Math.round(randomBase * 100) / 100;
|
||||
}
|
||||
|
||||
// log price to kafka for elasticity computation
|
||||
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 = {
|
||||
price,
|
||||
currency: 'EUR',
|
||||
cachedAt: timestamp,
|
||||
};
|
||||
|
||||
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,92 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getSession, createSession, setExperiment } from '@/lib/sessionStore';
|
||||
|
||||
const COOKIE_NAME = 'phantom_session_id';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const existingSession = req.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (existingSession) {
|
||||
const sessionData = getSession(existingSession);
|
||||
return NextResponse.json({
|
||||
sessionId: existingSession,
|
||||
experimentId: sessionData?.experimentId,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
createSession(sessionId);
|
||||
|
||||
const res = NextResponse.json({ sessionId, experimentId: undefined });
|
||||
|
||||
res.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.error('session error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { experimentId } = body;
|
||||
|
||||
if (!experimentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'experimentId is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let sessionId = req.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = randomUUID();
|
||||
createSession(sessionId);
|
||||
}
|
||||
|
||||
setExperiment(sessionId, experimentId);
|
||||
|
||||
const res = NextResponse.json({
|
||||
sessionId,
|
||||
experimentId,
|
||||
success: true
|
||||
});
|
||||
|
||||
if (!req.cookies.get(COOKIE_NAME)) {
|
||||
res.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
console.error('session update error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
web/src/app/api/track/route.ts
Normal file
33
web/src/app/api/track/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendInteractionEvent } from '@/lib/kafka';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
|
||||
|
||||
if (!sessionId || !eventType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId and eventType required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await sendInteractionEvent({
|
||||
sessionId,
|
||||
eventType,
|
||||
targetEl,
|
||||
targetUrl,
|
||||
metadata,
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
console.error('track error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +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 });
|
||||
window.location.href = '/checkout';
|
||||
}}
|
||||
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,19 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 32px;
|
||||
--border-radius: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -23,7 +12,6 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
@@ -31,79 +19,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 2rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.2s ease;
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: #0051d5;
|
||||
}
|
||||
|
||||
.section-spacing {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export default function HotelCheckout() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-4xl font-light text-gray-800 mb-4">
|
||||
Thank you for staying with us
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import '@/styles/hotel.css';
|
||||
|
||||
export default function HotelLayout({ children }: { children: ReactNode }) {
|
||||
return <div data-mode="hotel">{children}</div>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import HotelHero from '@/components/feats/hotel/HotelHero';
|
||||
|
||||
export default function HotelHome() {
|
||||
return (
|
||||
<main>
|
||||
<HotelHero />
|
||||
</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,70 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Navigation } from '@/components/ui';
|
||||
import HotelCard from '@/components/feats/hotel/HotelCard';
|
||||
import { transformProduct, type Hotel, type HotelProduct } from '@/lib/hotel-utils';
|
||||
|
||||
function RoomsList() {
|
||||
const searchParams = useSearchParams();
|
||||
const [rooms, setRooms] = useState<Hotel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const url = new URL('/api/products', window.location.origin);
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HotelProducts() {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<Suspense fallback={<div className="text-center py-8">Loading...</div>}>
|
||||
<RoomsList />
|
||||
</Suspense>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { TrackingProvider } from "@/components/TrackingProvider";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -29,9 +28,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<CartProvider>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
</CartProvider>
|
||||
<TrackingProvider>{children}</TrackingProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
const StartTaskContent = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'redirecting'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const uuid = searchParams.get('uuid');
|
||||
|
||||
if (!uuid) {
|
||||
setError('no experiment UUID provided');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAndStore = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/experiments?id=${uuid}`);
|
||||
if (!res.ok) throw new Error('experiment not found');
|
||||
|
||||
const data = await res.json();
|
||||
const exp = data.experiment;
|
||||
|
||||
if (!exp) throw new Error('invalid experiment UUID');
|
||||
|
||||
localStorage.setItem('phantom_experiment_id', uuid);
|
||||
|
||||
await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ experimentId: uuid }),
|
||||
});
|
||||
|
||||
setStatus('redirecting');
|
||||
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 800);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'failed to start task');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
validateAndStore();
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<div className="text-center">
|
||||
{status === 'loading' && (
|
||||
<div>
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-zinc-200 border-t-zinc-900 dark:border-zinc-800 dark:border-t-zinc-100 mx-auto" />
|
||||
<p className="text-zinc-600 dark:text-zinc-400">validating browser...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'redirecting' && (
|
||||
<div>
|
||||
<div className="mb-4 text-4xl">✓</div>
|
||||
<p className="text-zinc-900 dark:text-zinc-100 font-medium">website loaded</p>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">redirecting to page...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-lg bg-red-50 p-6 dark:bg-red-950">
|
||||
<p className="text-red-900 dark:text-red-100 font-medium">error</p>
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StartTaskPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-black">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<StartTaskContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type ExperimentFormProps = {
|
||||
selectedTaskId?: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const ExperimentForm = ({ selectedTaskId, onSuccess }: ExperimentFormProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
subject_name: '',
|
||||
xp_human_only: false,
|
||||
xp_market_mode: 'hotel' as 'hotel' | 'airline',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/experiments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
xp_task_id: selectedTaskId || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'creation failed');
|
||||
}
|
||||
|
||||
setForm({ subject_name: '', xp_human_only: false, xp_market_mode: 'hotel' });
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Create Experiment
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
subject name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subject_name}
|
||||
onChange={(e) => setForm({ ...form, subject_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="e.g., baseline_dynamic_pricing_v1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
market mode
|
||||
</label>
|
||||
<select
|
||||
value={form.xp_market_mode}
|
||||
onChange={(e) => setForm({ ...form, xp_market_mode: e.target.value as 'hotel' | 'airline' })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
>
|
||||
<option value="hotel">hotel</option>
|
||||
<option value="airline">airline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="human-only"
|
||||
checked={form.xp_human_only}
|
||||
onChange={(e) => setForm({ ...form, xp_human_only: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
/>
|
||||
<label htmlFor="human-only" className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
human participants only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedTaskId && (
|
||||
<div className="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
task selected: <span className="font-mono text-xs">{selectedTaskId.slice(0, 8)}...</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'creating experiment...' : 'create experiment'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,178 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
task_name: string;
|
||||
task_description: string;
|
||||
task_def_of_done: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type TaskManagerProps = {
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
selectedTaskId?: string;
|
||||
};
|
||||
|
||||
export const TaskManager = ({ onTaskSelect, selectedTaskId }: TaskManagerProps) => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
task_name: '',
|
||||
task_description: '',
|
||||
task_def_of_done: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/tasks');
|
||||
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'creation failed');
|
||||
}
|
||||
|
||||
setForm({ task_name: '', task_description: '', task_def_of_done: '' });
|
||||
setShowForm(false);
|
||||
await fetchTasks();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Tasks
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-black dark:hover:bg-zinc-300"
|
||||
>
|
||||
{showForm ? 'cancel' : 'new task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
task name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.task_name}
|
||||
onChange={(e) => setForm({ ...form, task_name: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="e.g., Book cheapest flight to Paris"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.task_description}
|
||||
onChange={(e) => setForm({ ...form, task_description: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="User should find and book the cheapest available flight..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
definition of done
|
||||
</label>
|
||||
<textarea
|
||||
value={form.task_def_of_done}
|
||||
onChange={(e) => setForm({ ...form, task_def_of_done: e.target.value })}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-100"
|
||||
placeholder="Booking is completed and confirmation page is shown"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
|
||||
>
|
||||
{loading ? 'creating...' : 'create task'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
no tasks yet
|
||||
</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => onTaskSelect?.(task.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selectedTaskId === task.id
|
||||
? 'border-zinc-900 bg-zinc-50 dark:border-zinc-100 dark:bg-zinc-900'
|
||||
: 'border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{task.task_name}
|
||||
</h3>
|
||||
{task.task_description && (
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{task.task_description}
|
||||
</p>
|
||||
)}
|
||||
{task.task_def_of_done && (
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
|
||||
done: {task.task_def_of_done}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user