hard007ik commited on
Commit
5c6ca01
Β·
0 Parent(s):

feat: jewelry shop RL environment with market, warehouse, and showroom phases

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ uv.lock
5
+ .env
6
+ *.pyc
README.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Shopmanagereng Environment Server
3
+ emoji: πŸŽ–οΈ
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # Jewelry Shop Manager β€” RL Environment
15
+
16
+ A reinforcement learning environment simulating a **jewelry shop management** pipeline. An AI agent navigates three sequential phases β€” buying raw materials, selecting products to craft based on demand, and negotiating sales β€” to maximize profit.
17
+
18
+ ## Environment Overview
19
+
20
+ ### Phase 1: Market (Buy / Wait)
21
+
22
+ - Gold prices **fluctuate Β±10% each round** (up to 3 rounds).
23
+ - The agent analyzes price trends and decides to **buy** gold or **wait** for a better price.
24
+ - Goal: Buy gold at the lowest possible price while reserving cash for crafting labor.
25
+
26
+ ### Phase 2: Warehouse (Product Selection)
27
+
28
+ - The agent sees **demand levels** for each product type:
29
+
30
+ | Product | Gold (oz) | Labor ($) | Demand Range |
31
+ |-----------|-----------|-----------|--------------|
32
+ | Ring | 1.0 | $200 | 40-100% |
33
+ | Necklace | 2.0 | $300 | 20-80% |
34
+ | Bracelet | 0.5 | $100 | 10-60% |
35
+
36
+ - The agent picks the **highest-demand product** it can afford to craft.
37
+ - Goal: Match production to market demand.
38
+
39
+ ### Phase 3: Showroom (Negotiation)
40
+
41
+ - A customer makes an initial offer based on cost basis and product demand.
42
+ - The agent can **accept**, **counter-offer**, or **reject**.
43
+ - Each counter raises the customer's offer by **5%** (up to 5 rounds).
44
+ - Goal: Sell at maximum profit through smart negotiation.
45
+
46
+ ### Reward Structure
47
+
48
+ | Component | Weight | Description |
49
+ |-----------|--------|-------------|
50
+ | R1 (Market) | 20% | How close to the lowest price did the agent buy? |
51
+ | R2 (Warehouse) | 20% | Did the agent pick the highest-demand product? |
52
+ | R3 (Showroom) | 60% | Normalized profit margin on the sale |
53
+
54
+ **Final Score** = `0.2 Γ— R1 + 0.2 Γ— R2 + 0.6 Γ— R3` (range [0, 1])
55
+
56
+ ## Quick Start
57
+
58
+ ```python
59
+ from ShopManagerEng import JewelryAction, JewelryShopEnv
60
+
61
+ async def run():
62
+ env = JewelryShopEnv(base_url="http://localhost:8000")
63
+
64
+ result = await env.reset()
65
+ print(f"Gold price: ${result.observation.gold_price}/oz")
66
+
67
+ # Phase 1 β€” Market: wait for better price
68
+ result = await env.step(JewelryAction(market_action="wait"))
69
+
70
+ # Phase 1 β€” Market: buy gold
71
+ result = await env.step(JewelryAction(market_action="buy", gold_qty=2.0))
72
+
73
+ # Phase 2 β€” Warehouse: choose product
74
+ result = await env.step(JewelryAction(product_choice="ring"))
75
+
76
+ # Phase 3 β€” Showroom: negotiate
77
+ result = await env.step(JewelryAction(message="How about $600?"))
78
+ result = await env.step(JewelryAction(message="I accept"))
79
+
80
+ print(f"Final reward: {result.reward}, Cash: {result.observation.cash}")
81
+ await env.close()
82
+
83
+ import asyncio
84
+ asyncio.run(run())
85
+ ```
86
+
87
+ ## Action Space
88
+
89
+ ```python
90
+ class JewelryAction:
91
+ market_action: str # "buy" or "wait" (Phase 1)
92
+ gold_qty: float # Ounces to buy (Phase 1)
93
+ product_choice: str # "ring", "necklace", or "bracelet" (Phase 2)
94
+ message: str # Negotiation text (Phase 3)
95
+ ```
96
+
97
+ ## Observation Space
98
+
99
+ ```python
100
+ class JewelryObservation:
101
+ phase: str # "market" | "warehouse" | "showroom"
102
+ cash: float # Current cash balance
103
+ gold_oz: float # Raw gold in inventory
104
+ gold_price: float # Current gold price ($/oz)
105
+ gold_price_history: List[float] # Price trend for analysis
106
+ market_round: int # Current market round
107
+ demand: Dict[str, float] # Demand per product (0-1)
108
+ product_catalog: Dict[str, dict] # Specs per product
109
+ inventory: Dict[str, int] # Crafted products in stock
110
+ product_for_sale: str # Product being sold (showroom)
111
+ cost_basis: float # Total manufacturing cost
112
+ current_offer: float # Customer's current offer
113
+ negotiation_round: int # Counter-offer round
114
+ message: str # Environment feedback
115
+ ```
116
+
117
+ ## Running the Inference Script
118
+
119
+ ```bash
120
+ # Terminal 1: Start the server
121
+ cd ShopManagerEng
122
+ uv run server
123
+
124
+ # Terminal 2: Run inference (from parent directory or inside ShopManagerEng)
125
+ python inference.py
126
+ ```
127
+
128
+ Required environment variables (set in `.env`):
129
+ - `HF_TOKEN` β€” Hugging Face API token
130
+ - `MODEL_NAME` β€” LLM model (default: `meta-llama/Llama-3.3-70B-Instruct`)
131
+
132
+ ## Deploying to Hugging Face Spaces
133
+
134
+ ```bash
135
+ openenv push
136
+ ```
137
+
138
+ ## Project Structure
139
+
140
+ ```
141
+ ShopManagerEng/
142
+ β”œβ”€β”€ __init__.py # Module exports
143
+ β”œβ”€β”€ README.md # This file
144
+ β”œβ”€β”€ openenv.yaml # OpenEnv manifest
145
+ β”œβ”€β”€ pyproject.toml # Dependencies
146
+ β”œβ”€β”€ models.py # Action, Observation, State definitions
147
+ β”œβ”€β”€ client.py # JewelryShopEnv client
148
+ β”œβ”€β”€ inference.py # LLM-based agent inference script
149
+ └── server/
150
+ β”œβ”€β”€ __init__.py
151
+ β”œβ”€β”€ ShopManagerEng_environment.py # Core environment logic
152
+ β”œβ”€β”€ app.py # FastAPI application
153
+ └── Dockerfile # Container image
154
+ ```
__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shopmanagereng Environment."""
2
+
3
+ from .client import JewelryShopEnv
4
+ from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
5
+
6
+ __all__ = [
7
+ "JewelryAction",
8
+ "JewelryObservation",
9
+ "JewelryState",
10
+ "JewelryShopEnv",
11
+ "PRODUCT_CATALOG",
12
+ ]
client.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openenv.core.env_client import EnvClient
2
+ from openenv.core.client_types import StepResult
3
+ from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
4
+
5
+
6
+ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState]):
7
+ """
8
+ Client for the Jewelry Shop RL environment.
9
+
10
+ Usage:
11
+ env = JewelryShopEnv(base_url="http://localhost:8000")
12
+ obs = await env.reset()
13
+
14
+ # Phase 1 β€” Market (buy or wait)
15
+ obs = await env.step(JewelryAction(market_action="wait"))
16
+ obs = await env.step(JewelryAction(market_action="buy", gold_qty=2.0))
17
+
18
+ # Phase 2 β€” Warehouse (choose product)
19
+ obs = await env.step(JewelryAction(product_choice="ring"))
20
+
21
+ # Phase 3 β€” Showroom (negotiate)
22
+ obs = await env.step(JewelryAction(message="How about $600?"))
23
+ obs = await env.step(JewelryAction(message="I accept"))
24
+ """
25
+
26
+ # ── 1. PACK action β†’ dict (sent TO server) ──────────────────────────────
27
+
28
+ def _step_payload(self, action: JewelryAction) -> dict:
29
+ payload = {}
30
+
31
+ if action.market_action is not None:
32
+ payload["market_action"] = action.market_action
33
+
34
+ if action.gold_qty is not None:
35
+ payload["gold_qty"] = action.gold_qty
36
+
37
+ if action.product_choice is not None:
38
+ payload["product_choice"] = action.product_choice
39
+
40
+ if action.message is not None:
41
+ payload["message"] = action.message
42
+
43
+ return payload
44
+
45
+ # ── 2. UNPACK dict β†’ typed observation (received FROM server) ───────────
46
+
47
+ def _parse_result(self, payload: dict) -> StepResult:
48
+ obs_data = payload.get("observation", {})
49
+
50
+ observation = JewelryObservation(
51
+ # Base fields
52
+ done=payload.get("done", False),
53
+ reward=payload.get("reward", None),
54
+
55
+ # Phase info
56
+ phase=obs_data.get("phase", "market"),
57
+
58
+ # Finances & inventory
59
+ cash=obs_data.get("cash", 1000.0),
60
+ gold_oz=obs_data.get("gold_oz", 0.0),
61
+
62
+ # Market
63
+ gold_price=obs_data.get("gold_price", 0.0),
64
+ gold_price_history=obs_data.get("gold_price_history", []),
65
+ market_round=obs_data.get("market_round", 0),
66
+ max_market_rounds=obs_data.get("max_market_rounds", 3),
67
+
68
+ # Warehouse
69
+ demand=obs_data.get("demand", {}),
70
+ product_catalog=obs_data.get("product_catalog", PRODUCT_CATALOG),
71
+ inventory=obs_data.get("inventory", {}),
72
+
73
+ # Showroom
74
+ product_for_sale=obs_data.get("product_for_sale", None),
75
+ cost_basis=obs_data.get("cost_basis", 0.0),
76
+ current_offer=obs_data.get("current_offer", None),
77
+ negotiation_round=obs_data.get("negotiation_round", 0),
78
+
79
+ # Feedback
80
+ message=obs_data.get("message", ""),
81
+ )
82
+
83
+ return StepResult(
84
+ observation=observation,
85
+ reward=payload.get("reward", None),
86
+ done=payload.get("done", False),
87
+ )
88
+
89
+ # ── 3. UNPACK dict β†’ typed state (server internal state) ────────────────
90
+
91
+ def _parse_state(self, payload: dict) -> JewelryState:
92
+ return JewelryState(
93
+ episode_id=payload.get("episode_id", None),
94
+ step_count=payload.get("step_count", 0),
95
+
96
+ cash=payload.get("cash", 1000.0),
97
+ gold_oz=payload.get("gold_oz", 0.0),
98
+ gold_price=payload.get("gold_price", 0.0),
99
+ gold_price_history=payload.get("gold_price_history", []),
100
+ market_round=payload.get("market_round", 0),
101
+
102
+ demand=payload.get("demand", {}),
103
+ inventory=payload.get("inventory", {}),
104
+
105
+ phase=payload.get("phase", "market"),
106
+ product_for_sale=payload.get("product_for_sale", None),
107
+ cost_basis=payload.get("cost_basis", 0.0),
108
+ negotiation_round=payload.get("negotiation_round", 0),
109
+ current_offer=payload.get("current_offer", 0.0),
110
+ base_offer=payload.get("base_offer", 0.0),
111
+ lowest_price_seen=payload.get("lowest_price_seen", 0.0),
112
+ )
inference.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import math
3
+ import os
4
+ import sys
5
+ import textwrap
6
+ from typing import List, Optional
7
+
8
+ from dotenv import load_dotenv
9
+ from openai import OpenAI
10
+
11
+ # Add parent directory to path so ShopManagerEng is importable as a package
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from ShopManagerEng.client import JewelryShopEnv
15
+ from ShopManagerEng.models import JewelryAction
16
+
17
+ load_dotenv()
18
+
19
+ IMAGE_NAME = os.getenv("IMAGE_NAME")
20
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
21
+
22
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
23
+ # MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
24
+ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
25
+ TASK_NAME = os.getenv("JEWELRY_ENV_TASK", "jewelry-shop")
26
+ BENCHMARK = os.getenv("JEWELRY_ENV_BENCHMARK", "jewelry_shop_benchmark")
27
+ MAX_STEPS = 15
28
+ TEMPERATURE = 0.7
29
+ MAX_TOKENS = 150
30
+ SUCCESS_SCORE_THRESHOLD = 0.01
31
+
32
+
33
+ SYSTEM_PROMPT = textwrap.dedent(
34
+ """
35
+ You are an expert agent running a jewelry shop. Maximize profit across 3 phases.
36
+
37
+ ## Phase 1: MARKET (buy/wait)
38
+ Gold prices fluctuate Β±10% each round (up to 3 rounds).
39
+ - Analyze the price trend from the history.
40
+ - If the price DROPPED from the previous round, it might drop further β†’ consider waiting.
41
+ - If the price ROSE or you're on the last round β†’ buy now.
42
+ - Reserve enough cash for labor ($100-$300 depending on product).
43
+ - Respond: "buy X.XX" (to buy X.XX oz of gold) or "wait" (to see next price).
44
+
45
+ ## Phase 2: WAREHOUSE (choose product)
46
+ You see demand levels for each product. Pick the HIGHEST demand product
47
+ that you can afford to craft (enough gold + cash for labor).
48
+ Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
49
+ - Respond: "ring", "necklace", or "bracelet"
50
+
51
+ ## Phase 3: SHOWROOM (negotiate)
52
+ A customer offers a price. Your goal is to sell at maximum profit.
53
+ - Counter-offer to drive the price up (customer raises 5% each round, max 5 rounds).
54
+ - Accept when the offer is good (round >= 3 or offer > 1.3Γ— cost).
55
+ - NEVER reject.
56
+ - Respond: "I accept" or a counter like "How about $X?"
57
+
58
+ CRITICAL: Respond with ONLY the action value. No explanations.
59
+ """
60
+ ).strip()
61
+
62
+
63
+ # ── LOGGING ────────────────────────────────────
64
+
65
+ def log_start(task: str, env: str, model: str) -> None:
66
+ print(f"[START] task={task} env={env} model={model}", flush=True)
67
+
68
+
69
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
70
+ error_val = error if error else "null"
71
+ done_val = str(done).lower()
72
+ print(
73
+ f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
74
+ flush=True,
75
+ )
76
+
77
+
78
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
79
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
80
+ print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
81
+
82
+
83
+ # ── PROMPT BUILDING ────────────────────────────
84
+
85
+ def build_user_prompt(step: int, obs, last_reward: float, history: List[str]) -> str:
86
+ history_block = "\n".join(history[-4:]) if history else "None"
87
+
88
+ if obs.phase == "market":
89
+ prices = obs.gold_price_history
90
+ trend = ""
91
+ if len(prices) >= 2:
92
+ if prices[-1] < prices[-2]:
93
+ trend = "FALLING ↓ (might keep dropping, consider waiting)"
94
+ else:
95
+ trend = "RISING ↑ (buy now before it gets more expensive)"
96
+
97
+ rounds_left = obs.max_market_rounds - obs.market_round
98
+ # Suggest buy quantity that reserves $300 for labor (max labor cost)
99
+ reserve = 300.0
100
+ if obs.gold_price > 0:
101
+ raw_qty = (obs.cash - reserve) / obs.gold_price
102
+ suggested_qty = math.floor(raw_qty * 100) / 100
103
+ suggested_qty = max(suggested_qty, 0.01)
104
+ else:
105
+ suggested_qty = 1.0
106
+
107
+ phase_hint = (
108
+ f"Price history: {prices}. Trend: {trend}. "
109
+ f"Rounds left: {rounds_left}. "
110
+ f"If buying, suggested qty: {suggested_qty} oz (reserves $300 for labor). "
111
+ f"Respond: 'buy {suggested_qty}' or 'wait'"
112
+ )
113
+
114
+ elif obs.phase == "warehouse":
115
+ demand = obs.demand
116
+ best_product = max(demand, key=demand.get) if demand else "ring"
117
+ phase_hint = (
118
+ f"Demand: ring={demand.get('ring', 0):.0%}, "
119
+ f"necklace={demand.get('necklace', 0):.0%}, "
120
+ f"bracelet={demand.get('bracelet', 0):.0%}. "
121
+ f"Highest demand: {best_product}. "
122
+ f"You have {obs.gold_oz}oz gold and ${obs.cash} cash. "
123
+ f"Respond with EXACTLY: {best_product}"
124
+ )
125
+
126
+ elif obs.phase == "showroom":
127
+ margin = ""
128
+ if obs.current_offer and obs.cost_basis > 0:
129
+ margin_pct = ((obs.current_offer - obs.cost_basis) / obs.cost_basis) * 100
130
+ margin = f"Margin: {margin_pct:+.1f}%. "
131
+
132
+ should_accept = False
133
+ if obs.negotiation_round >= 4:
134
+ should_accept = True
135
+ if obs.current_offer and obs.cost_basis > 0 and obs.current_offer > obs.cost_basis * 1.3:
136
+ should_accept = True
137
+
138
+ if should_accept:
139
+ phase_hint = (
140
+ f"Cost: ${obs.cost_basis}. Offer: ${obs.current_offer}. {margin}"
141
+ f"Round {obs.negotiation_round}/5. "
142
+ f"Respond with EXACTLY: I accept"
143
+ )
144
+ else:
145
+ # Vary counter-offers per round
146
+ counter_msgs = [
147
+ "I need a better price for this quality piece",
148
+ "That's too low, this craftsmanship deserves more",
149
+ f"How about ${round(obs.cost_basis * 1.4, 2)}?",
150
+ f"I can't go below ${round(obs.cost_basis * 1.3, 2)}",
151
+ ]
152
+ msg = counter_msgs[min(obs.negotiation_round, len(counter_msgs) - 1)]
153
+ phase_hint = (
154
+ f"Cost: ${obs.cost_basis}. Offer: ${obs.current_offer}. {margin}"
155
+ f"Round {obs.negotiation_round}/5. "
156
+ f"DO NOT ACCEPT. Counter-offer. "
157
+ f"Respond with EXACTLY: {msg}"
158
+ )
159
+ else:
160
+ phase_hint = ""
161
+
162
+ return textwrap.dedent(
163
+ f"""
164
+ Step: {step} | Phase: {obs.phase} | Last reward: {last_reward:.2f}
165
+ Cash: ${obs.cash} | Gold: {obs.gold_oz}oz | Rings: {obs.inventory}
166
+ Gold Price: ${obs.gold_price}/oz
167
+ Env Message: {obs.message}
168
+
169
+ {phase_hint}
170
+
171
+ History: {history_block}
172
+ """
173
+ ).strip()
174
+
175
+
176
+ # ── ACTION PARSING ─────────────────────────────
177
+
178
+ def get_action_from_text(phase: str, text: str) -> tuple[JewelryAction, str]:
179
+ text = text.strip().replace("`", "").strip(' \t\n\r"\'')
180
+
181
+ if phase == "market":
182
+ lower = text.lower()
183
+ if lower.startswith("buy"):
184
+ # Extract quantity from "buy 2.5" or "buy2.5"
185
+ qty_str = lower.replace("buy", "").strip()
186
+ try:
187
+ qty = float(qty_str)
188
+ except ValueError:
189
+ qty = 1.0
190
+ return JewelryAction(market_action="buy", gold_qty=qty), f"buy {qty}"
191
+ elif "wait" in lower:
192
+ return JewelryAction(market_action="wait"), "wait"
193
+ else:
194
+ # Try to parse as a number (assumed buy)
195
+ try:
196
+ qty = float(text)
197
+ return JewelryAction(market_action="buy", gold_qty=qty), f"buy {qty}"
198
+ except ValueError:
199
+ return JewelryAction(market_action="wait"), "wait"
200
+
201
+ elif phase == "warehouse":
202
+ lower = text.lower()
203
+ for product in ["necklace", "bracelet", "ring"]:
204
+ if product in lower:
205
+ return JewelryAction(product_choice=product), product
206
+ return JewelryAction(product_choice="ring"), "ring"
207
+
208
+ elif phase == "showroom":
209
+ return JewelryAction(message=text), text
210
+
211
+ return JewelryAction(), text
212
+
213
+
214
+ def get_model_action(client: OpenAI, step: int, obs, last_reward: float, history: List[str]) -> tuple[JewelryAction, str]:
215
+ user_prompt = build_user_prompt(step, obs, last_reward, history)
216
+ try:
217
+ completion = client.chat.completions.create(
218
+ model=MODEL_NAME,
219
+ messages=[
220
+ {"role": "system", "content": SYSTEM_PROMPT},
221
+ {"role": "user", "content": user_prompt},
222
+ ],
223
+ temperature=TEMPERATURE,
224
+ max_tokens=MAX_TOKENS,
225
+ stream=False,
226
+ )
227
+ text = (completion.choices[0].message.content or "").strip()
228
+ return get_action_from_text(obs.phase, text)
229
+ except Exception as exc:
230
+ print(f"[DEBUG] Model request failed: {exc}", flush=True)
231
+ # Fallback actions
232
+ if obs.phase == "market":
233
+ return JewelryAction(market_action="buy", gold_qty=1.0), "buy 1.0"
234
+ elif obs.phase == "warehouse":
235
+ return JewelryAction(product_choice="ring"), "ring"
236
+ else:
237
+ return JewelryAction(message="I accept"), "I accept"
238
+
239
+
240
+ # ── MAIN ───────────────────────────────────────
241
+
242
+ async def main() -> None:
243
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
244
+
245
+ history: List[str] = []
246
+ rewards: List[float] = []
247
+ steps_taken = 0
248
+ score = 0.0
249
+ success = False
250
+
251
+ log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
252
+
253
+ try:
254
+ env = JewelryShopEnv(base_url="http://localhost:8000")
255
+
256
+ result = await env.reset()
257
+ obs = result.observation
258
+ last_reward = 0.0
259
+
260
+ for step in range(1, MAX_STEPS + 1):
261
+ if result.done:
262
+ break
263
+
264
+ action, raw_action_str = get_model_action(client, step, obs, last_reward, history)
265
+ current_phase = obs.phase
266
+
267
+ result = await env.step(action)
268
+ obs = result.observation
269
+
270
+ reward = result.reward or 0.0
271
+ done = result.done
272
+ error = None
273
+
274
+ rewards.append(reward)
275
+ steps_taken = step
276
+ last_reward = reward
277
+
278
+ log_step(step=step, action=raw_action_str.replace('\n', ' '), reward=reward, done=done, error=error)
279
+ history.append(f"Step {step} ({current_phase}): {raw_action_str!r} -> reward {reward:+.2f}")
280
+
281
+ if done:
282
+ break
283
+
284
+ # Score = final combined reward from the environment
285
+ if rewards:
286
+ score = rewards[-1]
287
+ else:
288
+ score = 0.0
289
+
290
+ score = min(max(score, 0.0), 1.0)
291
+ success = score >= SUCCESS_SCORE_THRESHOLD
292
+
293
+ finally:
294
+ try:
295
+ await env.close()
296
+ except Exception as e:
297
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
298
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Dict, List
2
+ from openenv.core.env_server import Action, Observation, State
3
+
4
+
5
+ # ─────────────────────────────────────────────
6
+ # PRODUCT CATALOG (shared constant)
7
+ # ─────────────────────────────────────────────
8
+
9
+ PRODUCT_CATALOG = {
10
+ "ring": {"gold_oz": 1.0, "labor": 200.0, "base_demand": 0.8},
11
+ "necklace": {"gold_oz": 2.0, "labor": 300.0, "base_demand": 0.5},
12
+ "bracelet": {"gold_oz": 0.5, "labor": 100.0, "base_demand": 0.3},
13
+ }
14
+
15
+
16
+ # ─────────────────────────────────────────────
17
+ # ACTION
18
+ # One unified action covers all 3 phases.
19
+ # ─────────────────────────────────────────────
20
+
21
+ class JewelryAction(Action):
22
+ """
23
+ Phase 1 (market) β†’ market_action ("buy"/"wait") + gold_qty (oz to buy)
24
+ Phase 2 (warehouse) β†’ product_choice ("ring"/"necklace"/"bracelet")
25
+ Phase 3 (showroom) β†’ message (accept / counter / reject)
26
+ """
27
+ market_action: Optional[str] = None # "buy" or "wait"
28
+ gold_qty: Optional[float] = None # How many oz to buy (market phase)
29
+ product_choice: Optional[str] = None # "ring" / "necklace" / "bracelet"
30
+ message: Optional[str] = None # Showroom negotiation text
31
+
32
+
33
+ # ─────────────────────────────────────────────
34
+ # OBSERVATION
35
+ # Everything the agent can SEE each step.
36
+ # ─────────────────────────────────────────────
37
+
38
+ class JewelryObservation(Observation):
39
+ # Base fields: done, reward (inherited)
40
+
41
+ phase: str # "market" | "warehouse" | "showroom"
42
+ cash: float # Agent's current cash ($)
43
+ gold_oz: float # Raw gold in inventory (oz)
44
+
45
+ # Market phase
46
+ gold_price: float # Current gold price ($/oz)
47
+ gold_price_history: List[float] = [] # Last N prices for trend analysis
48
+ market_round: int = 0 # Current round in market (0-indexed)
49
+ max_market_rounds: int = 3 # Max rounds before forced decision
50
+
51
+ # Warehouse phase
52
+ demand: Dict[str, float] = {} # Demand level per product (0-1)
53
+ product_catalog: Dict[str, dict] = {} # Gold/labor costs per product
54
+ inventory: Dict[str, int] = {} # Crafted products in stock
55
+
56
+ # Showroom phase
57
+ product_for_sale: Optional[str] = None # Which product is being sold
58
+ cost_basis: float = 0.0 # Total cost to make the product
59
+ current_offer: Optional[float] = None # Customer's live offer
60
+ negotiation_round: int = 0 # Counter-offer rounds so far
61
+
62
+ message: str = "" # Human-readable feedback
63
+
64
+
65
+ # ─────────────────────────────────────────────
66
+ # STATE
67
+ # Full internal state (server-side truth).
68
+ # ─────────────────────────────────────────────
69
+
70
+ class JewelryState(State):
71
+ # Base: episode_id, step_count (inherited)
72
+
73
+ cash: float = 1000.0
74
+ gold_oz: float = 0.0
75
+ gold_price: float = 0.0
76
+ gold_price_history: List[float] = []
77
+ market_round: int = 0
78
+
79
+ demand: Dict[str, float] = {}
80
+ inventory: Dict[str, int] = {}
81
+
82
+ phase: str = "market"
83
+ product_for_sale: Optional[str] = None
84
+ cost_basis: float = 0.0
85
+ negotiation_round: int = 0
86
+ current_offer: float = 0.0
87
+ base_offer: float = 0.0 # Hidden from agent
88
+ lowest_price_seen: float = 0.0 # For r1 scoring
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: ShopManagerEng
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-ShopManagerEng"
13
+ version = "0.1.0"
14
+ description = "Shopmanagereng environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
+ # install from github
19
+ # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
+ "openenv-core[core]>=0.2.2",
21
+ # Environment-specific dependencies
22
+ # Add all dependencies needed for your environment here
23
+ # Examples:
24
+ # "numpy>=1.19.0",
25
+ # "torch>=2.0.0",
26
+ # "gymnasium>=0.29.0",
27
+ # "openspiel>=1.0.0",
28
+ # "smolagents>=1.22.0,<2",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m ShopManagerEng.server.app
40
+ server = "ShopManagerEng.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["ShopManagerEng", "ShopManagerEng.server"]
45
+ package-dir = { "ShopManagerEng" = ".", "ShopManagerEng.server" = "server" }
server/Dockerfile ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=ShopManagerEng
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
server/ShopManagerEng_environment.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import uuid
3
+ from openenv.core.env_server import Environment
4
+ from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
5
+
6
+
7
+ # ─────────────────────────────────────────────
8
+ # CONSTANTS
9
+ # ─────────────────────────────────────────────
10
+
11
+ STARTING_CASH = 1000.0
12
+ GOLD_PRICE_MIN = 250.0
13
+ GOLD_PRICE_MAX = 450.0
14
+ PRICE_FLUCTUATION = 0.10 # Β±10% per market round
15
+ MAX_MARKET_ROUNDS = 3 # Rounds the agent can wait in market
16
+ MAX_NEGOTIATION = 5 # Showroom counter-offer limit
17
+ COUNTER_BUMP = 1.05 # Customer raises offer by 5% each round
18
+ OFFER_MIN_RATIO = 0.80 # Customer opens at 80-130% of cost basis
19
+ OFFER_MAX_RATIO = 1.30
20
+ DEMAND_OFFER_BONUS = 0.20 # High demand adds up to 20% to offer
21
+ MAX_PROFIT_MULT = 2.0 # Normalization ceiling for r3
22
+
23
+
24
+ # ─────────────────────────────────────────────
25
+ # KEYWORD DETECTION (Phase 3 β€” Showroom)
26
+ # ─────────────────────────────────────────────
27
+
28
+ ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
29
+ REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
30
+
31
+ def detect_intent(message: str) -> str:
32
+ msg = message.lower()
33
+ for kw in ACCEPT_KEYWORDS:
34
+ if kw in msg:
35
+ return "accept"
36
+ for kw in REJECT_KEYWORDS:
37
+ if kw in msg:
38
+ return "reject"
39
+ return "counter"
40
+
41
+
42
+ # ─────────────────────────────────────────────
43
+ # REWARD HELPERS
44
+ # ─────────────────────────────────────────────
45
+
46
+ def compute_r1(buy_price: float, lowest_price: float) -> float:
47
+ """
48
+ Phase 1 reward: did the agent buy near the lowest price seen?
49
+ 1.0 if bought at the lowest, decreasing as buy price increases.
50
+ """
51
+ if lowest_price <= 0 or buy_price <= 0:
52
+ return 0.0
53
+ ratio = lowest_price / buy_price # 1.0 = perfect, <1.0 = overpaid
54
+ return round(min(ratio, 1.0) * 0.5, 4)
55
+
56
+
57
+ def compute_r2(product_choice: str, demand: dict) -> float:
58
+ """
59
+ Phase 2 reward: did the agent pick the highest-demand product?
60
+ 0.5 if picked the best, proportionally less for worse choices.
61
+ """
62
+ if not demand or product_choice not in demand:
63
+ return 0.0
64
+ max_demand = max(demand.values())
65
+ if max_demand <= 0:
66
+ return 0.0
67
+ return round((demand[product_choice] / max_demand) * 0.5, 4)
68
+
69
+
70
+ def compute_r3(accepted_price: float, cost_basis: float) -> float:
71
+ """
72
+ Phase 3 reward: normalized profit margin on sale.
73
+ """
74
+ if cost_basis <= 0:
75
+ return 0.0
76
+ profit = accepted_price - cost_basis
77
+ if profit <= 0:
78
+ return 0.0
79
+ max_profit = cost_basis * (MAX_PROFIT_MULT - 1)
80
+ return round(min(profit / max_profit, 1.0), 4)
81
+
82
+
83
+ def combined_reward(r1: float, r2: float, r3: float) -> float:
84
+ """Weighted combination: showroom dominates."""
85
+ return round((0.2 * r1) + (0.2 * r2) + (0.6 * r3), 4)
86
+
87
+
88
+ # ─────────────────────────────────────────────
89
+ # ENVIRONMENT
90
+ # ─────────────────────────────────────────────
91
+
92
+ class JewelryShopEnvironment(Environment):
93
+ SUPPORTS_CONCURRENT_SESSIONS = True
94
+
95
+ def __init__(self):
96
+ self._state = JewelryState()
97
+ self._r1 = 0.0
98
+ self._r2 = 0.0
99
+
100
+ # ── RESET ──────────────────────────────────
101
+
102
+ def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
103
+ if seed is not None:
104
+ random.seed(seed)
105
+
106
+ gold_price = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
107
+
108
+ # Randomize demand levels for this episode
109
+ demand = {
110
+ "ring": round(random.uniform(0.4, 1.0), 2),
111
+ "necklace": round(random.uniform(0.2, 0.8), 2),
112
+ "bracelet": round(random.uniform(0.1, 0.6), 2),
113
+ }
114
+
115
+ self._state = JewelryState(
116
+ episode_id=episode_id or str(uuid.uuid4()),
117
+ step_count=0,
118
+ cash=STARTING_CASH,
119
+ gold_oz=0.0,
120
+ gold_price=gold_price,
121
+ gold_price_history=[gold_price],
122
+ market_round=0,
123
+ demand=demand,
124
+ inventory={"ring": 0, "necklace": 0, "bracelet": 0},
125
+ phase="market",
126
+ product_for_sale=None,
127
+ cost_basis=0.0,
128
+ negotiation_round=0,
129
+ current_offer=0.0,
130
+ base_offer=0.0,
131
+ lowest_price_seen=gold_price,
132
+ )
133
+ self._r1 = 0.0
134
+ self._r2 = 0.0
135
+
136
+ return JewelryObservation(
137
+ done=False,
138
+ reward=None,
139
+ phase="market",
140
+ cash=STARTING_CASH,
141
+ gold_oz=0.0,
142
+ gold_price=gold_price,
143
+ gold_price_history=[gold_price],
144
+ market_round=0,
145
+ max_market_rounds=MAX_MARKET_ROUNDS,
146
+ demand=demand,
147
+ product_catalog=PRODUCT_CATALOG,
148
+ inventory={"ring": 0, "necklace": 0, "bracelet": 0},
149
+ product_for_sale=None,
150
+ cost_basis=0.0,
151
+ current_offer=None,
152
+ negotiation_round=0,
153
+ message=(
154
+ f"Welcome to the Jewelry Shop! Today's gold price is ${gold_price}/oz. "
155
+ f"You have ${STARTING_CASH}. You can 'buy' gold or 'wait' for a better price. "
156
+ f"Market rounds remaining: {MAX_MARKET_ROUNDS}."
157
+ ),
158
+ )
159
+
160
+ # ── STEP ───────────────────────────────────
161
+
162
+ def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
163
+ self._state.step_count += 1
164
+ phase = self._state.phase
165
+
166
+ if phase == "market":
167
+ return self._step_market(action)
168
+ elif phase == "warehouse":
169
+ return self._step_warehouse(action)
170
+ elif phase == "showroom":
171
+ return self._step_showroom(action)
172
+ else:
173
+ raise ValueError(f"Unknown phase: {phase}")
174
+
175
+ # ── PHASE 1: MARKET ────────────────────────
176
+
177
+ def _step_market(self, action: JewelryAction) -> JewelryObservation:
178
+ s = self._state
179
+ market_action = (action.market_action or "wait").lower().strip()
180
+
181
+ if market_action == "buy":
182
+ gold_qty = action.gold_qty or 0.0
183
+ total_cost = gold_qty * s.gold_price
184
+
185
+ if gold_qty <= 0 or total_cost > s.cash:
186
+ # Failed transaction β€” stay in market
187
+ return JewelryObservation(
188
+ done=False,
189
+ reward=0.0,
190
+ phase="market",
191
+ cash=s.cash,
192
+ gold_oz=s.gold_oz,
193
+ gold_price=s.gold_price,
194
+ gold_price_history=list(s.gold_price_history),
195
+ market_round=s.market_round,
196
+ max_market_rounds=MAX_MARKET_ROUNDS,
197
+ demand=s.demand,
198
+ product_catalog=PRODUCT_CATALOG,
199
+ inventory=s.inventory,
200
+ message=(
201
+ f"Transaction failed. Tried to buy {gold_qty}oz "
202
+ f"(${total_cost:.2f}) but you have ${s.cash:.2f}. "
203
+ f"Try a smaller quantity or wait."
204
+ ),
205
+ )
206
+
207
+ # Successful buy
208
+ s.cash -= total_cost
209
+ s.gold_oz += gold_qty
210
+ self._r1 = compute_r1(s.gold_price, s.lowest_price_seen)
211
+
212
+ # Advance to warehouse
213
+ s.phase = "warehouse"
214
+
215
+ return JewelryObservation(
216
+ done=False,
217
+ reward=self._r1,
218
+ phase="warehouse",
219
+ cash=s.cash,
220
+ gold_oz=s.gold_oz,
221
+ gold_price=s.gold_price,
222
+ gold_price_history=list(s.gold_price_history),
223
+ market_round=s.market_round,
224
+ max_market_rounds=MAX_MARKET_ROUNDS,
225
+ demand=s.demand,
226
+ product_catalog=PRODUCT_CATALOG,
227
+ inventory=s.inventory,
228
+ message=(
229
+ f"Bought {gold_qty}oz of gold at ${s.gold_price}/oz "
230
+ f"for ${total_cost:.2f}. Cash remaining: ${s.cash:.2f}. "
231
+ f"Now check your warehouse. Which product to craft? "
232
+ f"Options: ring (1oz gold + $200), necklace (2oz + $300), bracelet (0.5oz + $100)."
233
+ ),
234
+ )
235
+
236
+ else:
237
+ # Agent chose to WAIT β€” advance market round
238
+ s.market_round += 1
239
+
240
+ if s.market_round >= MAX_MARKET_ROUNDS:
241
+ # Forced to buy at current price or skip to warehouse with no gold
242
+ s.phase = "warehouse"
243
+ self._r1 = 0.0
244
+ return JewelryObservation(
245
+ done=False,
246
+ reward=0.0,
247
+ phase="warehouse",
248
+ cash=s.cash,
249
+ gold_oz=s.gold_oz,
250
+ gold_price=s.gold_price,
251
+ gold_price_history=list(s.gold_price_history),
252
+ market_round=s.market_round,
253
+ max_market_rounds=MAX_MARKET_ROUNDS,
254
+ demand=s.demand,
255
+ product_catalog=PRODUCT_CATALOG,
256
+ inventory=s.inventory,
257
+ message=(
258
+ f"Market closed! You waited too long and didn't buy any gold. "
259
+ f"Entering warehouse with {s.gold_oz}oz gold and ${s.cash} cash."
260
+ ),
261
+ )
262
+
263
+ # Price fluctuates Β±10%
264
+ change = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
265
+ new_price = round(s.gold_price * (1 + change), 2)
266
+ new_price = max(new_price, 50.0) # Floor price
267
+ s.gold_price = new_price
268
+ s.gold_price_history.append(new_price)
269
+ s.lowest_price_seen = min(s.lowest_price_seen, new_price)
270
+
271
+ trend = "↑" if change > 0 else "↓"
272
+ return JewelryObservation(
273
+ done=False,
274
+ reward=0.0,
275
+ phase="market",
276
+ cash=s.cash,
277
+ gold_oz=s.gold_oz,
278
+ gold_price=new_price,
279
+ gold_price_history=list(s.gold_price_history),
280
+ market_round=s.market_round,
281
+ max_market_rounds=MAX_MARKET_ROUNDS,
282
+ demand=s.demand,
283
+ product_catalog=PRODUCT_CATALOG,
284
+ inventory=s.inventory,
285
+ message=(
286
+ f"You waited. Gold price moved {trend} to ${new_price}/oz. "
287
+ f"Price history: {s.gold_price_history}. "
288
+ f"Rounds left: {MAX_MARKET_ROUNDS - s.market_round}. "
289
+ f"Buy now or wait?"
290
+ ),
291
+ )
292
+
293
+ # ── PHASE 2: WAREHOUSE ─────────────────────
294
+
295
+ def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
296
+ s = self._state
297
+ choice = (action.product_choice or "ring").lower().strip()
298
+
299
+ if choice not in PRODUCT_CATALOG:
300
+ choice = "ring" # Default fallback
301
+
302
+ spec = PRODUCT_CATALOG[choice]
303
+ gold_needed = spec["gold_oz"]
304
+ labor_cost = spec["labor"]
305
+
306
+ has_gold = s.gold_oz >= gold_needed
307
+ has_cash = s.cash >= labor_cost
308
+
309
+ if not has_gold or not has_cash:
310
+ # Cannot craft β€” skip to showroom with nothing
311
+ self._r2 = 0.0
312
+ s.phase = "showroom"
313
+ reason = (
314
+ f"not enough gold (need {gold_needed}oz, have {s.gold_oz}oz)"
315
+ if not has_gold else
316
+ f"not enough cash for labor (need ${labor_cost}, have ${s.cash:.2f})"
317
+ )
318
+ return JewelryObservation(
319
+ done=False,
320
+ reward=0.0,
321
+ phase="showroom",
322
+ cash=s.cash,
323
+ gold_oz=s.gold_oz,
324
+ gold_price=s.gold_price,
325
+ gold_price_history=list(s.gold_price_history),
326
+ demand=s.demand,
327
+ product_catalog=PRODUCT_CATALOG,
328
+ inventory=s.inventory,
329
+ product_for_sale=None,
330
+ cost_basis=0.0,
331
+ message=f"Cannot craft {choice}: {reason}. Entering showroom with nothing.",
332
+ )
333
+
334
+ # Successful craft
335
+ s.cash -= labor_cost
336
+ s.gold_oz -= gold_needed
337
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
338
+ s.cost_basis = s.gold_price * gold_needed + labor_cost
339
+ s.product_for_sale = choice
340
+
341
+ self._r2 = compute_r2(choice, s.demand)
342
+
343
+ # Generate customer offer based on demand and cost basis
344
+ demand_factor = s.demand.get(choice, 0.5)
345
+ offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (demand_factor * DEMAND_OFFER_BONUS)
346
+ base_offer = round(s.cost_basis * offer_ratio, 2)
347
+ s.base_offer = base_offer
348
+ s.current_offer = base_offer
349
+ s.phase = "showroom"
350
+ s.negotiation_round = 0
351
+
352
+ return JewelryObservation(
353
+ done=False,
354
+ reward=self._r2,
355
+ phase="showroom",
356
+ cash=s.cash,
357
+ gold_oz=s.gold_oz,
358
+ gold_price=s.gold_price,
359
+ gold_price_history=list(s.gold_price_history),
360
+ demand=s.demand,
361
+ product_catalog=PRODUCT_CATALOG,
362
+ inventory=s.inventory,
363
+ product_for_sale=choice,
364
+ cost_basis=s.cost_basis,
365
+ current_offer=s.current_offer,
366
+ negotiation_round=0,
367
+ message=(
368
+ f"Crafted a {choice}! Cost basis: ${s.cost_basis:.2f} "
369
+ f"(gold ${s.gold_price * gold_needed:.2f} + labor ${labor_cost}). "
370
+ f"Demand for {choice}: {demand_factor:.0%}. "
371
+ f"A customer offers ${s.current_offer:.2f}. Accept, counter, or reject?"
372
+ ),
373
+ )
374
+
375
+ # ── PHASE 3: SHOWROOM ──────────────────────
376
+
377
+ def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
378
+ s = self._state
379
+
380
+ # No product β†’ episode ends immediately
381
+ if s.product_for_sale is None:
382
+ return JewelryObservation(
383
+ done=True,
384
+ reward=combined_reward(self._r1, self._r2, 0.0),
385
+ phase="showroom",
386
+ cash=s.cash,
387
+ gold_oz=s.gold_oz,
388
+ gold_price=s.gold_price,
389
+ demand=s.demand,
390
+ product_catalog=PRODUCT_CATALOG,
391
+ inventory=s.inventory,
392
+ product_for_sale=None,
393
+ cost_basis=0.0,
394
+ message="No products to sell. Episode over.",
395
+ )
396
+
397
+ message = action.message or ""
398
+ intent = detect_intent(message)
399
+
400
+ # ── ACCEPT ──
401
+ if intent == "accept":
402
+ r3 = compute_r3(s.current_offer, s.cost_basis)
403
+ final_reward = combined_reward(self._r1, self._r2, r3)
404
+ s.cash += s.current_offer
405
+ s.inventory[s.product_for_sale] -= 1
406
+ product_sold = s.product_for_sale
407
+ s.product_for_sale = None
408
+
409
+ return JewelryObservation(
410
+ done=True,
411
+ reward=final_reward,
412
+ phase="showroom",
413
+ cash=s.cash,
414
+ gold_oz=s.gold_oz,
415
+ gold_price=s.gold_price,
416
+ demand=s.demand,
417
+ product_catalog=PRODUCT_CATALOG,
418
+ inventory=s.inventory,
419
+ product_for_sale=None,
420
+ cost_basis=s.cost_basis,
421
+ current_offer=s.current_offer,
422
+ negotiation_round=s.negotiation_round,
423
+ message=(
424
+ f"Deal! Sold {product_sold} for ${s.current_offer:.2f}. "
425
+ f"Profit: ${s.current_offer - s.cost_basis:.2f}. "
426
+ f"Final reward: {final_reward}."
427
+ ),
428
+ )
429
+
430
+ # ── REJECT ──
431
+ if intent == "reject":
432
+ final_reward = combined_reward(self._r1, self._r2, 0.0)
433
+ return JewelryObservation(
434
+ done=True,
435
+ reward=final_reward,
436
+ phase="showroom",
437
+ cash=s.cash,
438
+ gold_oz=s.gold_oz,
439
+ gold_price=s.gold_price,
440
+ demand=s.demand,
441
+ product_catalog=PRODUCT_CATALOG,
442
+ inventory=s.inventory,
443
+ product_for_sale=s.product_for_sale,
444
+ cost_basis=s.cost_basis,
445
+ current_offer=s.current_offer,
446
+ negotiation_round=s.negotiation_round,
447
+ message=(
448
+ f"You rejected the offer. Customer left. "
449
+ f"Final reward: {final_reward}."
450
+ ),
451
+ )
452
+
453
+ # ── COUNTER ──
454
+ s.negotiation_round += 1
455
+
456
+ if s.negotiation_round >= MAX_NEGOTIATION:
457
+ final_reward = combined_reward(self._r1, self._r2, 0.0)
458
+ return JewelryObservation(
459
+ done=True,
460
+ reward=final_reward,
461
+ phase="showroom",
462
+ cash=s.cash,
463
+ gold_oz=s.gold_oz,
464
+ gold_price=s.gold_price,
465
+ demand=s.demand,
466
+ product_catalog=PRODUCT_CATALOG,
467
+ inventory=s.inventory,
468
+ product_for_sale=s.product_for_sale,
469
+ cost_basis=s.cost_basis,
470
+ current_offer=s.current_offer,
471
+ negotiation_round=s.negotiation_round,
472
+ message=(
473
+ f"Customer left after {MAX_NEGOTIATION} rounds. "
474
+ f"Final reward: {final_reward}."
475
+ ),
476
+ )
477
+
478
+ # Customer raises offer by 5%
479
+ s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
480
+
481
+ return JewelryObservation(
482
+ done=False,
483
+ reward=0.0,
484
+ phase="showroom",
485
+ cash=s.cash,
486
+ gold_oz=s.gold_oz,
487
+ gold_price=s.gold_price,
488
+ demand=s.demand,
489
+ product_catalog=PRODUCT_CATALOG,
490
+ inventory=s.inventory,
491
+ product_for_sale=s.product_for_sale,
492
+ cost_basis=s.cost_basis,
493
+ current_offer=s.current_offer,
494
+ negotiation_round=s.negotiation_round,
495
+ message=(
496
+ f"Customer raises to ${s.current_offer:.2f} "
497
+ f"(round {s.negotiation_round}/{MAX_NEGOTIATION}). "
498
+ f"Accept, counter, or reject?"
499
+ ),
500
+ )
501
+
502
+ # ── STATE PROPERTY ─────────────────────────
503
+
504
+ @property
505
+ def state(self) -> JewelryState:
506
+ return self._state
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Shopmanagereng environment server components."""
8
+
9
+ from .ShopManagerEng_environment import JewelryShopEnvironment
10
+
11
+ __all__ = ["JewelryShopEnvironment"]
server/app.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from openenv.core.env_server import create_fastapi_app
2
+ from .ShopManagerEng_environment import JewelryShopEnvironment
3
+ from ..models import JewelryAction, JewelryObservation
4
+ import uvicorn
5
+
6
+ app = create_fastapi_app(JewelryShopEnvironment, JewelryAction, JewelryObservation)
7
+
8
+ def main():
9
+ uvicorn.run(app, host="0.0.0.0", port=8000)
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+