| import random |
| import uuid |
| from openenv.core.env_server import Environment |
|
|
| try: |
| from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG |
| except ImportError: |
| from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG |
|
|
|
|
| |
| |
| |
|
|
| STARTING_CASH = 1000.0 |
| GOLD_PRICE_MIN = 250.0 |
| GOLD_PRICE_MAX = 450.0 |
| PRICE_FLUCTUATION = 0.10 |
| MAX_MARKET_ROUNDS = 3 |
| MAX_NEGOTIATION = 5 |
| COUNTER_BUMP = 1.05 |
| OFFER_MIN_RATIO = 0.80 |
| OFFER_MAX_RATIO = 1.30 |
| DEMAND_OFFER_BONUS = 0.20 |
| MAX_PROFIT_MULT = 2.0 |
|
|
|
|
| |
| |
| |
|
|
| ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"] |
| REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"] |
|
|
| def detect_intent(message: str) -> str: |
| msg = message.lower() |
| for kw in ACCEPT_KEYWORDS: |
| if kw in msg: |
| return "accept" |
| for kw in REJECT_KEYWORDS: |
| if kw in msg: |
| return "reject" |
| return "counter" |
|
|
|
|
| |
| |
| |
|
|
| def compute_r1(buy_price: float, lowest_price: float) -> float: |
| """ |
| Phase 1 reward: did the agent buy near the lowest price seen? |
| 1.0 if bought at the lowest, decreasing as buy price increases. |
| """ |
| if lowest_price <= 0 or buy_price <= 0: |
| return 0.0 |
| ratio = lowest_price / buy_price |
| return round(min(ratio, 1.0) * 0.5, 4) |
|
|
|
|
| def compute_r2(product_choice: str, demand: dict) -> float: |
| """ |
| Phase 2 reward: did the agent pick the highest-demand product? |
| 0.5 if picked the best, proportionally less for worse choices. |
| """ |
| if not demand or product_choice not in demand: |
| return 0.0 |
| max_demand = max(demand.values()) |
| if max_demand <= 0: |
| return 0.0 |
| return round((demand[product_choice] / max_demand) * 0.5, 4) |
|
|
|
|
| def compute_r3(accepted_price: float, cost_basis: float) -> float: |
| """ |
| Phase 3 reward: normalized profit margin on sale. |
| """ |
| if cost_basis <= 0: |
| return 0.0 |
| profit = accepted_price - cost_basis |
| if profit <= 0: |
| return 0.0 |
| max_profit = cost_basis * (MAX_PROFIT_MULT - 1) |
| return round(min(profit / max_profit, 1.0), 4) |
|
|
|
|
| def combined_reward(r1: float, r2: float, r3: float) -> float: |
| """Weighted combination: showroom dominates.""" |
| return round((0.2 * r1) + (0.2 * r2) + (0.6 * r3), 4) |
|
|
|
|
| |
| |
| |
|
|
| class JewelryShopEnvironment(Environment): |
| SUPPORTS_CONCURRENT_SESSIONS = True |
|
|
| def __init__(self): |
| self._state = JewelryState() |
| self._r1 = 0.0 |
| self._r2 = 0.0 |
|
|
| |
|
|
| def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation: |
| if seed is not None: |
| random.seed(seed) |
|
|
| gold_price = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2) |
|
|
| |
| demand = { |
| "ring": round(random.uniform(0.4, 1.0), 2), |
| "necklace": round(random.uniform(0.2, 0.8), 2), |
| "bracelet": round(random.uniform(0.1, 0.6), 2), |
| } |
|
|
| self._state = JewelryState( |
| episode_id=episode_id or str(uuid.uuid4()), |
| step_count=0, |
| cash=STARTING_CASH, |
| gold_oz=0.0, |
| gold_price=gold_price, |
| gold_price_history=[gold_price], |
| market_round=0, |
| demand=demand, |
| inventory={"ring": 0, "necklace": 0, "bracelet": 0}, |
| phase="market", |
| product_for_sale=None, |
| cost_basis=0.0, |
| negotiation_round=0, |
| current_offer=0.0, |
| base_offer=0.0, |
| lowest_price_seen=gold_price, |
| ) |
| self._r1 = 0.0 |
| self._r2 = 0.0 |
|
|
| return JewelryObservation( |
| done=False, |
| reward=None, |
| phase="market", |
| cash=STARTING_CASH, |
| gold_oz=0.0, |
| gold_price=gold_price, |
| gold_price_history=[gold_price], |
| market_round=0, |
| max_market_rounds=MAX_MARKET_ROUNDS, |
| demand=demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory={"ring": 0, "necklace": 0, "bracelet": 0}, |
| product_for_sale=None, |
| cost_basis=0.0, |
| current_offer=None, |
| negotiation_round=0, |
| message=( |
| f"Welcome to the Jewelry Shop! Today's gold price is ${gold_price}/oz. " |
| f"You have ${STARTING_CASH}. You can 'buy' gold or 'wait' for a better price. " |
| f"Market rounds remaining: {MAX_MARKET_ROUNDS}." |
| ), |
| ) |
|
|
| |
|
|
| def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation: |
| self._state.step_count += 1 |
| phase = self._state.phase |
|
|
| if phase == "market": |
| return self._step_market(action) |
| elif phase == "warehouse": |
| return self._step_warehouse(action) |
| elif phase == "showroom": |
| return self._step_showroom(action) |
| else: |
| raise ValueError(f"Unknown phase: {phase}") |
|
|
| |
|
|
| def _step_market(self, action: JewelryAction) -> JewelryObservation: |
| s = self._state |
| market_action = (action.market_action or "wait").lower().strip() |
|
|
| if market_action == "buy": |
| gold_qty = action.gold_qty or 0.0 |
| total_cost = gold_qty * s.gold_price |
|
|
| if gold_qty <= 0 or total_cost > s.cash: |
| |
| return JewelryObservation( |
| done=False, |
| reward=0.0, |
| phase="market", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| gold_price_history=list(s.gold_price_history), |
| market_round=s.market_round, |
| max_market_rounds=MAX_MARKET_ROUNDS, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| message=( |
| f"Transaction failed. Tried to buy {gold_qty}oz " |
| f"(${total_cost:.2f}) but you have ${s.cash:.2f}. " |
| f"Try a smaller quantity or wait." |
| ), |
| ) |
|
|
| |
| s.cash -= total_cost |
| s.gold_oz += gold_qty |
| self._r1 = compute_r1(s.gold_price, s.lowest_price_seen) |
|
|
| |
| s.phase = "warehouse" |
|
|
| return JewelryObservation( |
| done=False, |
| reward=self._r1, |
| phase="warehouse", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| gold_price_history=list(s.gold_price_history), |
| market_round=s.market_round, |
| max_market_rounds=MAX_MARKET_ROUNDS, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| message=( |
| f"Bought {gold_qty}oz of gold at ${s.gold_price}/oz " |
| f"for ${total_cost:.2f}. Cash remaining: ${s.cash:.2f}. " |
| f"Now check your warehouse. Which product to craft? " |
| f"Options: ring (1oz gold + $200), necklace (2oz + $300), bracelet (0.5oz + $100)." |
| ), |
| ) |
|
|
| else: |
| |
| s.market_round += 1 |
|
|
| if s.market_round >= MAX_MARKET_ROUNDS: |
| |
| s.phase = "warehouse" |
| self._r1 = 0.0 |
| return JewelryObservation( |
| done=False, |
| reward=0.0, |
| phase="warehouse", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| gold_price_history=list(s.gold_price_history), |
| market_round=s.market_round, |
| max_market_rounds=MAX_MARKET_ROUNDS, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| message=( |
| f"Market closed! You waited too long and didn't buy any gold. " |
| f"Entering warehouse with {s.gold_oz}oz gold and ${s.cash} cash." |
| ), |
| ) |
|
|
| |
| change = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION) |
| new_price = round(s.gold_price * (1 + change), 2) |
| new_price = max(new_price, 50.0) |
| s.gold_price = new_price |
| s.gold_price_history.append(new_price) |
| s.lowest_price_seen = min(s.lowest_price_seen, new_price) |
|
|
| trend = "β" if change > 0 else "β" |
| return JewelryObservation( |
| done=False, |
| reward=0.0, |
| phase="market", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=new_price, |
| gold_price_history=list(s.gold_price_history), |
| market_round=s.market_round, |
| max_market_rounds=MAX_MARKET_ROUNDS, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| message=( |
| f"You waited. Gold price moved {trend} to ${new_price}/oz. " |
| f"Price history: {s.gold_price_history}. " |
| f"Rounds left: {MAX_MARKET_ROUNDS - s.market_round}. " |
| f"Buy now or wait?" |
| ), |
| ) |
|
|
| |
|
|
| def _step_warehouse(self, action: JewelryAction) -> JewelryObservation: |
| s = self._state |
| choice = (action.product_choice or "ring").lower().strip() |
|
|
| if choice not in PRODUCT_CATALOG: |
| choice = "ring" |
|
|
| spec = PRODUCT_CATALOG[choice] |
| gold_needed = spec["gold_oz"] |
| labor_cost = spec["labor"] |
|
|
| has_gold = s.gold_oz >= gold_needed |
| has_cash = s.cash >= labor_cost |
|
|
| if not has_gold or not has_cash: |
| |
| self._r2 = 0.0 |
| s.phase = "showroom" |
| reason = ( |
| f"not enough gold (need {gold_needed}oz, have {s.gold_oz}oz)" |
| if not has_gold else |
| f"not enough cash for labor (need ${labor_cost}, have ${s.cash:.2f})" |
| ) |
| return JewelryObservation( |
| done=False, |
| reward=0.0, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| gold_price_history=list(s.gold_price_history), |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=None, |
| cost_basis=0.0, |
| message=f"Cannot craft {choice}: {reason}. Entering showroom with nothing.", |
| ) |
|
|
| |
| s.cash -= labor_cost |
| s.gold_oz -= gold_needed |
| s.inventory[choice] = s.inventory.get(choice, 0) + 1 |
| s.cost_basis = s.gold_price * gold_needed + labor_cost |
| s.product_for_sale = choice |
|
|
| self._r2 = compute_r2(choice, s.demand) |
|
|
| |
| demand_factor = s.demand.get(choice, 0.5) |
| offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (demand_factor * DEMAND_OFFER_BONUS) |
| base_offer = round(s.cost_basis * offer_ratio, 2) |
| s.base_offer = base_offer |
| s.current_offer = base_offer |
| s.phase = "showroom" |
| s.negotiation_round = 0 |
|
|
| return JewelryObservation( |
| done=False, |
| reward=self._r2, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| gold_price_history=list(s.gold_price_history), |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=choice, |
| cost_basis=s.cost_basis, |
| current_offer=s.current_offer, |
| negotiation_round=0, |
| message=( |
| f"Crafted a {choice}! Cost basis: ${s.cost_basis:.2f} " |
| f"(gold ${s.gold_price * gold_needed:.2f} + labor ${labor_cost}). " |
| f"Demand for {choice}: {demand_factor:.0%}. " |
| f"A customer offers ${s.current_offer:.2f}. Accept, counter, or reject?" |
| ), |
| ) |
|
|
| |
|
|
| def _step_showroom(self, action: JewelryAction) -> JewelryObservation: |
| s = self._state |
|
|
| |
| if s.product_for_sale is None: |
| return JewelryObservation( |
| done=True, |
| reward=combined_reward(self._r1, self._r2, 0.0), |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=None, |
| cost_basis=0.0, |
| message="No products to sell. Episode over.", |
| ) |
|
|
| message = action.message or "" |
| intent = detect_intent(message) |
|
|
| |
| if intent == "accept": |
| r3 = compute_r3(s.current_offer, s.cost_basis) |
| final_reward = combined_reward(self._r1, self._r2, r3) |
| s.cash += s.current_offer |
| s.inventory[s.product_for_sale] -= 1 |
| product_sold = s.product_for_sale |
| s.product_for_sale = None |
|
|
| return JewelryObservation( |
| done=True, |
| reward=final_reward, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=None, |
| cost_basis=s.cost_basis, |
| current_offer=s.current_offer, |
| negotiation_round=s.negotiation_round, |
| message=( |
| f"Deal! Sold {product_sold} for ${s.current_offer:.2f}. " |
| f"Profit: ${s.current_offer - s.cost_basis:.2f}. " |
| f"Final reward: {final_reward}." |
| ), |
| ) |
|
|
| |
| if intent == "reject": |
| final_reward = combined_reward(self._r1, self._r2, 0.0) |
| return JewelryObservation( |
| done=True, |
| reward=final_reward, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=s.product_for_sale, |
| cost_basis=s.cost_basis, |
| current_offer=s.current_offer, |
| negotiation_round=s.negotiation_round, |
| message=( |
| f"You rejected the offer. Customer left. " |
| f"Final reward: {final_reward}." |
| ), |
| ) |
|
|
| |
| s.negotiation_round += 1 |
|
|
| if s.negotiation_round >= MAX_NEGOTIATION: |
| final_reward = combined_reward(self._r1, self._r2, 0.0) |
| return JewelryObservation( |
| done=True, |
| reward=final_reward, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=s.product_for_sale, |
| cost_basis=s.cost_basis, |
| current_offer=s.current_offer, |
| negotiation_round=s.negotiation_round, |
| message=( |
| f"Customer left after {MAX_NEGOTIATION} rounds. " |
| f"Final reward: {final_reward}." |
| ), |
| ) |
|
|
| |
| s.current_offer = round(s.current_offer * COUNTER_BUMP, 2) |
|
|
| return JewelryObservation( |
| done=False, |
| reward=0.0, |
| phase="showroom", |
| cash=s.cash, |
| gold_oz=s.gold_oz, |
| gold_price=s.gold_price, |
| demand=s.demand, |
| product_catalog=PRODUCT_CATALOG, |
| inventory=s.inventory, |
| product_for_sale=s.product_for_sale, |
| cost_basis=s.cost_basis, |
| current_offer=s.current_offer, |
| negotiation_round=s.negotiation_round, |
| message=( |
| f"Customer raises to ${s.current_offer:.2f} " |
| f"(round {s.negotiation_round}/{MAX_NEGOTIATION}). " |
| f"Accept, counter, or reject?" |
| ), |
| ) |
|
|
| |
|
|
| @property |
| def state(self) -> JewelryState: |
| return self._state |