hard007ik commited on
Commit
1d489b2
Β·
verified Β·
1 Parent(s): 63da660

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -1,81 +1,81 @@
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
- ENV ENABLE_WEB_INTERFACE=true
81
- CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
 
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
+ ENV ENABLE_WEB_INTERFACE=true
81
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,154 +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
- ```
 
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 CHANGED
@@ -1,12 +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
- ]
 
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 CHANGED
@@ -1,112 +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
  )
 
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 CHANGED
@@ -1,327 +1,322 @@
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
-
241
- # ── SINGLE EPISODE RUNNER ──────────────────────
242
-
243
- async def run_episode(client: OpenAI, task_name: str, env_name: str, base_url: str) -> float:
244
- """Run a single episode and return the final score."""
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=env_name, model=MODEL_NAME)
252
-
253
- try:
254
- env = JewelryShopEnv(base_url=base_url)
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
- if rewards:
285
- score = rewards[-1]
286
- else:
287
- score = 0.0
288
-
289
- score = min(max(score, 0.0), 1.0)
290
- success = score >= SUCCESS_SCORE_THRESHOLD
291
-
292
- finally:
293
- try:
294
- await env.close()
295
- except Exception as e:
296
- pass
297
- # print(f"[DEBUG] env.close() error: {e}", flush=True)
298
- log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
299
-
300
- return score
301
-
302
-
303
- # ── MAIN ───────────────────────────────────────
304
-
305
- TASKS = [
306
- {"id": "market_timing", "env": "jewelry_shop_benchmark"},
307
- {"id": "demand_crafter", "env": "jewelry_shop_benchmark"},
308
- {"id": "profit_negotiator", "env": "jewelry_shop_benchmark"},
309
- ]
310
-
311
- async def main() -> None:
312
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
313
- # Resolve server URL: evaluator env var β†’ IMAGE_NAME β†’ HF Space β†’ localhost
314
- base_url = os.getenv("ENV_BASE_URL")
315
- if not base_url and IMAGE_NAME:
316
- base_url = f"https://{IMAGE_NAME.replace('/', '-').replace('_', '-')}.hf.space"
317
- if not base_url:
318
- base_url = os.getenv("SPACE_URL", "https://hard007ik-shopmanagereng.hf.space")
319
- # print(f"[CONFIG] base_url={base_url}", flush=True)
320
-
321
- for task in TASKS:
322
- await run_episode(client, task["id"], task["env"], base_url)
323
-
324
-
325
- if __name__ == "__main__":
326
- asyncio.run(main())
327
-
 
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")
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
+
241
+ # ── SINGLE EPISODE RUNNER ──────────────────────
242
+
243
+ async def run_episode(client: OpenAI, task_name: str, env_name: str, base_url: str) -> float:
244
+ """Run a single episode and return the final score."""
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=env_name, model=MODEL_NAME)
252
+
253
+ try:
254
+ env = JewelryShopEnv(base_url=base_url)
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
+ if rewards:
285
+ score = rewards[-1]
286
+ else:
287
+ score = 0.0
288
+
289
+ score = min(max(score, 0.0), 1.0)
290
+ success = score >= SUCCESS_SCORE_THRESHOLD
291
+
292
+ finally:
293
+ try:
294
+ await env.close()
295
+ except Exception as e:
296
+ pass
297
+ # print(f"[DEBUG] env.close() error: {e}", flush=True)
298
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
299
+
300
+ return score
301
+
302
+
303
+ # ── MAIN ───────────────────────────────────────
304
+
305
+ TASKS = [
306
+ {"id": "market_timing", "env": "jewelry_shop_benchmark"},
307
+ {"id": "demand_crafter", "env": "jewelry_shop_benchmark"},
308
+ {"id": "profit_negotiator", "env": "jewelry_shop_benchmark"},
309
+ ]
310
+
311
+ async def main() -> None:
312
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
313
+ base_url = os.getenv("ENV_BASE_URL", "https://hard007ik-shopmanagereng.hf.space")
314
+ # base_url = os.getenv("ENV_BASE_URL", "http://localhost:8000")
315
+
316
+ for task in TASKS:
317
+ await run_episode(client, task["id"], task["env"], base_url)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ asyncio.run(main())
322
+
 
 
 
 
 
models.py CHANGED
@@ -1,88 +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
 
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 CHANGED
@@ -1,37 +1,37 @@
1
- spec_version: 1
2
- name: ShopManagerEng
3
- type: space
4
- runtime: fastapi
5
- app: server.app:app
6
- port: 8000
7
-
8
- tasks:
9
- - id: market_timing
10
- name: "Market Price Analyst"
11
- description: >
12
- Analyze gold price fluctuations across up to 3 market rounds.
13
- Decide whether to buy immediately or wait for a price drop.
14
- Reserve enough cash for crafting labor ($100-$300).
15
- grader:
16
- type: reward_threshold
17
- threshold: 0.3
18
-
19
- - id: demand_crafter
20
- name: "Demand-Based Crafter"
21
- description: >
22
- Check warehouse demand levels for ring, necklace, and bracelet.
23
- Choose the highest-demand product you can afford to craft.
24
- Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
25
- grader:
26
- type: reward_threshold
27
- threshold: 0.2
28
-
29
- - id: profit_negotiator
30
- name: "Profit Maximizer"
31
- description: >
32
- Complete the full pipeline: buy gold at a good price, craft the
33
- best product based on demand, and negotiate with the customer.
34
- Counter-offer to drive up the price before accepting.
35
- grader:
36
- type: reward_threshold
37
- threshold: 0.25
 
1
+ spec_version: 1
2
+ name: ShopManagerEng
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
8
+ tasks:
9
+ - id: market_timing
10
+ name: "Market Price Analyst"
11
+ description: >
12
+ Analyze gold price fluctuations across up to 3 market rounds.
13
+ Decide whether to buy immediately or wait for a price drop.
14
+ Reserve enough cash for crafting labor ($100-$300).
15
+ grader:
16
+ type: reward_threshold
17
+ threshold: 0.3
18
+
19
+ - id: demand_crafter
20
+ name: "Demand-Based Crafter"
21
+ description: >
22
+ Check warehouse demand levels for ring, necklace, and bracelet.
23
+ Choose the highest-demand product you can afford to craft.
24
+ Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
25
+ grader:
26
+ type: reward_threshold
27
+ threshold: 0.2
28
+
29
+ - id: profit_negotiator
30
+ name: "Profit Maximizer"
31
+ description: >
32
+ Complete the full pipeline: buy gold at a good price, craft the
33
+ best product based on demand, and negotiate with the customer.
34
+ Counter-offer to drive up the price before accepting.
35
+ grader:
36
+ type: reward_threshold
37
+ threshold: 0.25
openenv_ShopManagerEng.egg-info/PKG-INFO CHANGED
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.4
2
- Name: openenv-ShopManagerEng
3
- Version: 0.1.0
4
- Summary: Shopmanagereng environment for OpenEnv
5
- Requires-Python: >=3.10
6
- Requires-Dist: openenv-core[core]>=0.2.2
7
- Provides-Extra: dev
8
- Requires-Dist: pytest>=8.0.0; extra == "dev"
9
- Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-ShopManagerEng
3
+ Version: 0.1.0
4
+ Summary: Shopmanagereng environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.2
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
9
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_ShopManagerEng.egg-info/SOURCES.txt CHANGED
@@ -2,6 +2,7 @@ README.md
2
  pyproject.toml
3
  ./__init__.py
4
  ./client.py
 
5
  ./models.py
6
  openenv_ShopManagerEng.egg-info/PKG-INFO
7
  openenv_ShopManagerEng.egg-info/SOURCES.txt
 
2
  pyproject.toml
3
  ./__init__.py
4
  ./client.py
5
+ ./inference.py
6
  ./models.py
7
  openenv_ShopManagerEng.egg-info/PKG-INFO
8
  openenv_ShopManagerEng.egg-info/SOURCES.txt
pyproject.toml CHANGED
@@ -1,45 +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" }
 
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/ShopManagerEng_environment.py CHANGED
@@ -1,510 +1,510 @@
1
- import random
2
- import uuid
3
- from openenv.core.env_server import Environment
4
-
5
- try:
6
- from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
7
- except ImportError:
8
- from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
9
-
10
-
11
- # ─────────────────────────────────────────────
12
- # CONSTANTS
13
- # ─────────────────────────────────────────────
14
-
15
- STARTING_CASH = 1000.0
16
- GOLD_PRICE_MIN = 250.0
17
- GOLD_PRICE_MAX = 450.0
18
- PRICE_FLUCTUATION = 0.10 # Β±10% per market round
19
- MAX_MARKET_ROUNDS = 3 # Rounds the agent can wait in market
20
- MAX_NEGOTIATION = 5 # Showroom counter-offer limit
21
- COUNTER_BUMP = 1.05 # Customer raises offer by 5% each round
22
- OFFER_MIN_RATIO = 0.80 # Customer opens at 80-130% of cost basis
23
- OFFER_MAX_RATIO = 1.30
24
- DEMAND_OFFER_BONUS = 0.20 # High demand adds up to 20% to offer
25
- MAX_PROFIT_MULT = 2.0 # Normalization ceiling for r3
26
-
27
-
28
- # ─────────────────────────────────────────────
29
- # KEYWORD DETECTION (Phase 3 β€” Showroom)
30
- # ─────────────────────────────────────────────
31
-
32
- ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
33
- REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
34
-
35
- def detect_intent(message: str) -> str:
36
- msg = message.lower()
37
- for kw in ACCEPT_KEYWORDS:
38
- if kw in msg:
39
- return "accept"
40
- for kw in REJECT_KEYWORDS:
41
- if kw in msg:
42
- return "reject"
43
- return "counter"
44
-
45
-
46
- # ─────────────────────────────────────────────
47
- # REWARD HELPERS
48
- # ─────────────────────────────────────────────
49
-
50
- def compute_r1(buy_price: float, lowest_price: float) -> float:
51
- """
52
- Phase 1 reward: did the agent buy near the lowest price seen?
53
- 1.0 if bought at the lowest, decreasing as buy price increases.
54
- """
55
- if lowest_price <= 0 or buy_price <= 0:
56
- return 0.0
57
- ratio = lowest_price / buy_price # 1.0 = perfect, <1.0 = overpaid
58
- return round(min(ratio, 1.0) * 0.5, 4)
59
-
60
-
61
- def compute_r2(product_choice: str, demand: dict) -> float:
62
- """
63
- Phase 2 reward: did the agent pick the highest-demand product?
64
- 0.5 if picked the best, proportionally less for worse choices.
65
- """
66
- if not demand or product_choice not in demand:
67
- return 0.0
68
- max_demand = max(demand.values())
69
- if max_demand <= 0:
70
- return 0.0
71
- return round((demand[product_choice] / max_demand) * 0.5, 4)
72
-
73
-
74
- def compute_r3(accepted_price: float, cost_basis: float) -> float:
75
- """
76
- Phase 3 reward: normalized profit margin on sale.
77
- """
78
- if cost_basis <= 0:
79
- return 0.0
80
- profit = accepted_price - cost_basis
81
- if profit <= 0:
82
- return 0.0
83
- max_profit = cost_basis * (MAX_PROFIT_MULT - 1)
84
- return round(min(profit / max_profit, 1.0), 4)
85
-
86
-
87
- def combined_reward(r1: float, r2: float, r3: float) -> float:
88
- """Weighted combination: showroom dominates."""
89
- return round((0.2 * r1) + (0.2 * r2) + (0.6 * r3), 4)
90
-
91
-
92
- # ─────────────────────────────────────────────
93
- # ENVIRONMENT
94
- # ─────────────────────────────────────────────
95
-
96
- class JewelryShopEnvironment(Environment):
97
- SUPPORTS_CONCURRENT_SESSIONS = True
98
-
99
- def __init__(self):
100
- self._state = JewelryState()
101
- self._r1 = 0.0
102
- self._r2 = 0.0
103
-
104
- # ── RESET ──────────────────────────────────
105
-
106
- def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
107
- if seed is not None:
108
- random.seed(seed)
109
-
110
- gold_price = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
111
-
112
- # Randomize demand levels for this episode
113
- demand = {
114
- "ring": round(random.uniform(0.4, 1.0), 2),
115
- "necklace": round(random.uniform(0.2, 0.8), 2),
116
- "bracelet": round(random.uniform(0.1, 0.6), 2),
117
- }
118
-
119
- self._state = JewelryState(
120
- episode_id=episode_id or str(uuid.uuid4()),
121
- step_count=0,
122
- cash=STARTING_CASH,
123
- gold_oz=0.0,
124
- gold_price=gold_price,
125
- gold_price_history=[gold_price],
126
- market_round=0,
127
- demand=demand,
128
- inventory={"ring": 0, "necklace": 0, "bracelet": 0},
129
- phase="market",
130
- product_for_sale=None,
131
- cost_basis=0.0,
132
- negotiation_round=0,
133
- current_offer=0.0,
134
- base_offer=0.0,
135
- lowest_price_seen=gold_price,
136
- )
137
- self._r1 = 0.0
138
- self._r2 = 0.0
139
-
140
- return JewelryObservation(
141
- done=False,
142
- reward=None,
143
- phase="market",
144
- cash=STARTING_CASH,
145
- gold_oz=0.0,
146
- gold_price=gold_price,
147
- gold_price_history=[gold_price],
148
- market_round=0,
149
- max_market_rounds=MAX_MARKET_ROUNDS,
150
- demand=demand,
151
- product_catalog=PRODUCT_CATALOG,
152
- inventory={"ring": 0, "necklace": 0, "bracelet": 0},
153
- product_for_sale=None,
154
- cost_basis=0.0,
155
- current_offer=None,
156
- negotiation_round=0,
157
- message=(
158
- f"Welcome to the Jewelry Shop! Today's gold price is ${gold_price}/oz. "
159
- f"You have ${STARTING_CASH}. You can 'buy' gold or 'wait' for a better price. "
160
- f"Market rounds remaining: {MAX_MARKET_ROUNDS}."
161
- ),
162
- )
163
-
164
- # ── STEP ───────────────────────────────────
165
-
166
- def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
167
- self._state.step_count += 1
168
- phase = self._state.phase
169
-
170
- if phase == "market":
171
- return self._step_market(action)
172
- elif phase == "warehouse":
173
- return self._step_warehouse(action)
174
- elif phase == "showroom":
175
- return self._step_showroom(action)
176
- else:
177
- raise ValueError(f"Unknown phase: {phase}")
178
-
179
- # ── PHASE 1: MARKET ────────────────────────
180
-
181
- def _step_market(self, action: JewelryAction) -> JewelryObservation:
182
- s = self._state
183
- market_action = (action.market_action or "wait").lower().strip()
184
-
185
- if market_action == "buy":
186
- gold_qty = action.gold_qty or 0.0
187
- total_cost = gold_qty * s.gold_price
188
-
189
- if gold_qty <= 0 or total_cost > s.cash:
190
- # Failed transaction β€” stay in market
191
- return JewelryObservation(
192
- done=False,
193
- reward=0.0,
194
- phase="market",
195
- cash=s.cash,
196
- gold_oz=s.gold_oz,
197
- gold_price=s.gold_price,
198
- gold_price_history=list(s.gold_price_history),
199
- market_round=s.market_round,
200
- max_market_rounds=MAX_MARKET_ROUNDS,
201
- demand=s.demand,
202
- product_catalog=PRODUCT_CATALOG,
203
- inventory=s.inventory,
204
- message=(
205
- f"Transaction failed. Tried to buy {gold_qty}oz "
206
- f"(${total_cost:.2f}) but you have ${s.cash:.2f}. "
207
- f"Try a smaller quantity or wait."
208
- ),
209
- )
210
-
211
- # Successful buy
212
- s.cash -= total_cost
213
- s.gold_oz += gold_qty
214
- self._r1 = compute_r1(s.gold_price, s.lowest_price_seen)
215
-
216
- # Advance to warehouse
217
- s.phase = "warehouse"
218
-
219
- return JewelryObservation(
220
- done=False,
221
- reward=self._r1,
222
- phase="warehouse",
223
- cash=s.cash,
224
- gold_oz=s.gold_oz,
225
- gold_price=s.gold_price,
226
- gold_price_history=list(s.gold_price_history),
227
- market_round=s.market_round,
228
- max_market_rounds=MAX_MARKET_ROUNDS,
229
- demand=s.demand,
230
- product_catalog=PRODUCT_CATALOG,
231
- inventory=s.inventory,
232
- message=(
233
- f"Bought {gold_qty}oz of gold at ${s.gold_price}/oz "
234
- f"for ${total_cost:.2f}. Cash remaining: ${s.cash:.2f}. "
235
- f"Now check your warehouse. Which product to craft? "
236
- f"Options: ring (1oz gold + $200), necklace (2oz + $300), bracelet (0.5oz + $100)."
237
- ),
238
- )
239
-
240
- else:
241
- # Agent chose to WAIT β€” advance market round
242
- s.market_round += 1
243
-
244
- if s.market_round >= MAX_MARKET_ROUNDS:
245
- # Forced to buy at current price or skip to warehouse with no gold
246
- s.phase = "warehouse"
247
- self._r1 = 0.0
248
- return JewelryObservation(
249
- done=False,
250
- reward=0.0,
251
- phase="warehouse",
252
- cash=s.cash,
253
- gold_oz=s.gold_oz,
254
- gold_price=s.gold_price,
255
- gold_price_history=list(s.gold_price_history),
256
- market_round=s.market_round,
257
- max_market_rounds=MAX_MARKET_ROUNDS,
258
- demand=s.demand,
259
- product_catalog=PRODUCT_CATALOG,
260
- inventory=s.inventory,
261
- message=(
262
- f"Market closed! You waited too long and didn't buy any gold. "
263
- f"Entering warehouse with {s.gold_oz}oz gold and ${s.cash} cash."
264
- ),
265
- )
266
-
267
- # Price fluctuates Β±10%
268
- change = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
269
- new_price = round(s.gold_price * (1 + change), 2)
270
- new_price = max(new_price, 50.0) # Floor price
271
- s.gold_price = new_price
272
- s.gold_price_history.append(new_price)
273
- s.lowest_price_seen = min(s.lowest_price_seen, new_price)
274
-
275
- trend = "↑" if change > 0 else "↓"
276
- return JewelryObservation(
277
- done=False,
278
- reward=0.0,
279
- phase="market",
280
- cash=s.cash,
281
- gold_oz=s.gold_oz,
282
- gold_price=new_price,
283
- gold_price_history=list(s.gold_price_history),
284
- market_round=s.market_round,
285
- max_market_rounds=MAX_MARKET_ROUNDS,
286
- demand=s.demand,
287
- product_catalog=PRODUCT_CATALOG,
288
- inventory=s.inventory,
289
- message=(
290
- f"You waited. Gold price moved {trend} to ${new_price}/oz. "
291
- f"Price history: {s.gold_price_history}. "
292
- f"Rounds left: {MAX_MARKET_ROUNDS - s.market_round}. "
293
- f"Buy now or wait?"
294
- ),
295
- )
296
-
297
- # ── PHASE 2: WAREHOUSE ─────────────────────
298
-
299
- def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
300
- s = self._state
301
- choice = (action.product_choice or "ring").lower().strip()
302
-
303
- if choice not in PRODUCT_CATALOG:
304
- choice = "ring" # Default fallback
305
-
306
- spec = PRODUCT_CATALOG[choice]
307
- gold_needed = spec["gold_oz"]
308
- labor_cost = spec["labor"]
309
-
310
- has_gold = s.gold_oz >= gold_needed
311
- has_cash = s.cash >= labor_cost
312
-
313
- if not has_gold or not has_cash:
314
- # Cannot craft β€” skip to showroom with nothing
315
- self._r2 = 0.0
316
- s.phase = "showroom"
317
- reason = (
318
- f"not enough gold (need {gold_needed}oz, have {s.gold_oz}oz)"
319
- if not has_gold else
320
- f"not enough cash for labor (need ${labor_cost}, have ${s.cash:.2f})"
321
- )
322
- return JewelryObservation(
323
- done=False,
324
- reward=0.0,
325
- phase="showroom",
326
- cash=s.cash,
327
- gold_oz=s.gold_oz,
328
- gold_price=s.gold_price,
329
- gold_price_history=list(s.gold_price_history),
330
- demand=s.demand,
331
- product_catalog=PRODUCT_CATALOG,
332
- inventory=s.inventory,
333
- product_for_sale=None,
334
- cost_basis=0.0,
335
- message=f"Cannot craft {choice}: {reason}. Entering showroom with nothing.",
336
- )
337
-
338
- # Successful craft
339
- s.cash -= labor_cost
340
- s.gold_oz -= gold_needed
341
- s.inventory[choice] = s.inventory.get(choice, 0) + 1
342
- s.cost_basis = s.gold_price * gold_needed + labor_cost
343
- s.product_for_sale = choice
344
-
345
- self._r2 = compute_r2(choice, s.demand)
346
-
347
- # Generate customer offer based on demand and cost basis
348
- demand_factor = s.demand.get(choice, 0.5)
349
- offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (demand_factor * DEMAND_OFFER_BONUS)
350
- base_offer = round(s.cost_basis * offer_ratio, 2)
351
- s.base_offer = base_offer
352
- s.current_offer = base_offer
353
- s.phase = "showroom"
354
- s.negotiation_round = 0
355
-
356
- return JewelryObservation(
357
- done=False,
358
- reward=self._r2,
359
- phase="showroom",
360
- cash=s.cash,
361
- gold_oz=s.gold_oz,
362
- gold_price=s.gold_price,
363
- gold_price_history=list(s.gold_price_history),
364
- demand=s.demand,
365
- product_catalog=PRODUCT_CATALOG,
366
- inventory=s.inventory,
367
- product_for_sale=choice,
368
- cost_basis=s.cost_basis,
369
- current_offer=s.current_offer,
370
- negotiation_round=0,
371
- message=(
372
- f"Crafted a {choice}! Cost basis: ${s.cost_basis:.2f} "
373
- f"(gold ${s.gold_price * gold_needed:.2f} + labor ${labor_cost}). "
374
- f"Demand for {choice}: {demand_factor:.0%}. "
375
- f"A customer offers ${s.current_offer:.2f}. Accept, counter, or reject?"
376
- ),
377
- )
378
-
379
- # ── PHASE 3: SHOWROOM ──────────────────────
380
-
381
- def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
382
- s = self._state
383
-
384
- # No product β†’ episode ends immediately
385
- if s.product_for_sale is None:
386
- return JewelryObservation(
387
- done=True,
388
- reward=combined_reward(self._r1, self._r2, 0.0),
389
- phase="showroom",
390
- cash=s.cash,
391
- gold_oz=s.gold_oz,
392
- gold_price=s.gold_price,
393
- demand=s.demand,
394
- product_catalog=PRODUCT_CATALOG,
395
- inventory=s.inventory,
396
- product_for_sale=None,
397
- cost_basis=0.0,
398
- message="No products to sell. Episode over.",
399
- )
400
-
401
- message = action.message or ""
402
- intent = detect_intent(message)
403
-
404
- # ── ACCEPT ──
405
- if intent == "accept":
406
- r3 = compute_r3(s.current_offer, s.cost_basis)
407
- final_reward = combined_reward(self._r1, self._r2, r3)
408
- s.cash += s.current_offer
409
- s.inventory[s.product_for_sale] -= 1
410
- product_sold = s.product_for_sale
411
- s.product_for_sale = None
412
-
413
- return JewelryObservation(
414
- done=True,
415
- reward=final_reward,
416
- phase="showroom",
417
- cash=s.cash,
418
- gold_oz=s.gold_oz,
419
- gold_price=s.gold_price,
420
- demand=s.demand,
421
- product_catalog=PRODUCT_CATALOG,
422
- inventory=s.inventory,
423
- product_for_sale=None,
424
- cost_basis=s.cost_basis,
425
- current_offer=s.current_offer,
426
- negotiation_round=s.negotiation_round,
427
- message=(
428
- f"Deal! Sold {product_sold} for ${s.current_offer:.2f}. "
429
- f"Profit: ${s.current_offer - s.cost_basis:.2f}. "
430
- f"Final reward: {final_reward}."
431
- ),
432
- )
433
-
434
- # ── REJECT ──
435
- if intent == "reject":
436
- final_reward = combined_reward(self._r1, self._r2, 0.0)
437
- return JewelryObservation(
438
- done=True,
439
- reward=final_reward,
440
- phase="showroom",
441
- cash=s.cash,
442
- gold_oz=s.gold_oz,
443
- gold_price=s.gold_price,
444
- demand=s.demand,
445
- product_catalog=PRODUCT_CATALOG,
446
- inventory=s.inventory,
447
- product_for_sale=s.product_for_sale,
448
- cost_basis=s.cost_basis,
449
- current_offer=s.current_offer,
450
- negotiation_round=s.negotiation_round,
451
- message=(
452
- f"You rejected the offer. Customer left. "
453
- f"Final reward: {final_reward}."
454
- ),
455
- )
456
-
457
- # ── COUNTER ──
458
- s.negotiation_round += 1
459
-
460
- if s.negotiation_round >= MAX_NEGOTIATION:
461
- final_reward = combined_reward(self._r1, self._r2, 0.0)
462
- return JewelryObservation(
463
- done=True,
464
- reward=final_reward,
465
- phase="showroom",
466
- cash=s.cash,
467
- gold_oz=s.gold_oz,
468
- gold_price=s.gold_price,
469
- demand=s.demand,
470
- product_catalog=PRODUCT_CATALOG,
471
- inventory=s.inventory,
472
- product_for_sale=s.product_for_sale,
473
- cost_basis=s.cost_basis,
474
- current_offer=s.current_offer,
475
- negotiation_round=s.negotiation_round,
476
- message=(
477
- f"Customer left after {MAX_NEGOTIATION} rounds. "
478
- f"Final reward: {final_reward}."
479
- ),
480
- )
481
-
482
- # Customer raises offer by 5%
483
- s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
484
-
485
- return JewelryObservation(
486
- done=False,
487
- reward=0.0,
488
- phase="showroom",
489
- cash=s.cash,
490
- gold_oz=s.gold_oz,
491
- gold_price=s.gold_price,
492
- demand=s.demand,
493
- product_catalog=PRODUCT_CATALOG,
494
- inventory=s.inventory,
495
- product_for_sale=s.product_for_sale,
496
- cost_basis=s.cost_basis,
497
- current_offer=s.current_offer,
498
- negotiation_round=s.negotiation_round,
499
- message=(
500
- f"Customer raises to ${s.current_offer:.2f} "
501
- f"(round {s.negotiation_round}/{MAX_NEGOTIATION}). "
502
- f"Accept, counter, or reject?"
503
- ),
504
- )
505
-
506
- # ── STATE PROPERTY ─────────────────────────
507
-
508
- @property
509
- def state(self) -> JewelryState:
510
  return self._state
 
1
+ import random
2
+ import uuid
3
+ from openenv.core.env_server import Environment
4
+
5
+ try:
6
+ from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
7
+ except ImportError:
8
+ from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
9
+
10
+
11
+ # ─────────────────────────────────────────────
12
+ # CONSTANTS
13
+ # ─────────────────────────────────────────────
14
+
15
+ STARTING_CASH = 1000.0
16
+ GOLD_PRICE_MIN = 250.0
17
+ GOLD_PRICE_MAX = 450.0
18
+ PRICE_FLUCTUATION = 0.10 # Β±10% per market round
19
+ MAX_MARKET_ROUNDS = 3 # Rounds the agent can wait in market
20
+ MAX_NEGOTIATION = 5 # Showroom counter-offer limit
21
+ COUNTER_BUMP = 1.05 # Customer raises offer by 5% each round
22
+ OFFER_MIN_RATIO = 0.80 # Customer opens at 80-130% of cost basis
23
+ OFFER_MAX_RATIO = 1.30
24
+ DEMAND_OFFER_BONUS = 0.20 # High demand adds up to 20% to offer
25
+ MAX_PROFIT_MULT = 2.0 # Normalization ceiling for r3
26
+
27
+
28
+ # ─────────────────────────────────────────────
29
+ # KEYWORD DETECTION (Phase 3 β€” Showroom)
30
+ # ─────────────────────────────────────────────
31
+
32
+ ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
33
+ REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
34
+
35
+ def detect_intent(message: str) -> str:
36
+ msg = message.lower()
37
+ for kw in ACCEPT_KEYWORDS:
38
+ if kw in msg:
39
+ return "accept"
40
+ for kw in REJECT_KEYWORDS:
41
+ if kw in msg:
42
+ return "reject"
43
+ return "counter"
44
+
45
+
46
+ # ─────────────────────────────────────────────
47
+ # REWARD HELPERS
48
+ # ─────────────────────────────────────────────
49
+
50
+ def compute_r1(buy_price: float, lowest_price: float) -> float:
51
+ """
52
+ Phase 1 reward: did the agent buy near the lowest price seen?
53
+ 1.0 if bought at the lowest, decreasing as buy price increases.
54
+ """
55
+ if lowest_price <= 0 or buy_price <= 0:
56
+ return 0.0
57
+ ratio = lowest_price / buy_price # 1.0 = perfect, <1.0 = overpaid
58
+ return round(min(ratio, 1.0) * 0.5, 4)
59
+
60
+
61
+ def compute_r2(product_choice: str, demand: dict) -> float:
62
+ """
63
+ Phase 2 reward: did the agent pick the highest-demand product?
64
+ 0.5 if picked the best, proportionally less for worse choices.
65
+ """
66
+ if not demand or product_choice not in demand:
67
+ return 0.0
68
+ max_demand = max(demand.values())
69
+ if max_demand <= 0:
70
+ return 0.0
71
+ return round((demand[product_choice] / max_demand) * 0.5, 4)
72
+
73
+
74
+ def compute_r3(accepted_price: float, cost_basis: float) -> float:
75
+ """
76
+ Phase 3 reward: normalized profit margin on sale.
77
+ """
78
+ if cost_basis <= 0:
79
+ return 0.0
80
+ profit = accepted_price - cost_basis
81
+ if profit <= 0:
82
+ return 0.0
83
+ max_profit = cost_basis * (MAX_PROFIT_MULT - 1)
84
+ return round(min(profit / max_profit, 1.0), 4)
85
+
86
+
87
+ def combined_reward(r1: float, r2: float, r3: float) -> float:
88
+ """Weighted combination: showroom dominates."""
89
+ return round((0.2 * r1) + (0.2 * r2) + (0.6 * r3), 4)
90
+
91
+
92
+ # ─────────────────────────────────────────────
93
+ # ENVIRONMENT
94
+ # ─────────────────────────────────────────────
95
+
96
+ class JewelryShopEnvironment(Environment):
97
+ SUPPORTS_CONCURRENT_SESSIONS = True
98
+
99
+ def __init__(self):
100
+ self._state = JewelryState()
101
+ self._r1 = 0.0
102
+ self._r2 = 0.0
103
+
104
+ # ── RESET ──────────────────────────────────
105
+
106
+ def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
107
+ if seed is not None:
108
+ random.seed(seed)
109
+
110
+ gold_price = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
111
+
112
+ # Randomize demand levels for this episode
113
+ demand = {
114
+ "ring": round(random.uniform(0.4, 1.0), 2),
115
+ "necklace": round(random.uniform(0.2, 0.8), 2),
116
+ "bracelet": round(random.uniform(0.1, 0.6), 2),
117
+ }
118
+
119
+ self._state = JewelryState(
120
+ episode_id=episode_id or str(uuid.uuid4()),
121
+ step_count=0,
122
+ cash=STARTING_CASH,
123
+ gold_oz=0.0,
124
+ gold_price=gold_price,
125
+ gold_price_history=[gold_price],
126
+ market_round=0,
127
+ demand=demand,
128
+ inventory={"ring": 0, "necklace": 0, "bracelet": 0},
129
+ phase="market",
130
+ product_for_sale=None,
131
+ cost_basis=0.0,
132
+ negotiation_round=0,
133
+ current_offer=0.0,
134
+ base_offer=0.0,
135
+ lowest_price_seen=gold_price,
136
+ )
137
+ self._r1 = 0.0
138
+ self._r2 = 0.0
139
+
140
+ return JewelryObservation(
141
+ done=False,
142
+ reward=None,
143
+ phase="market",
144
+ cash=STARTING_CASH,
145
+ gold_oz=0.0,
146
+ gold_price=gold_price,
147
+ gold_price_history=[gold_price],
148
+ market_round=0,
149
+ max_market_rounds=MAX_MARKET_ROUNDS,
150
+ demand=demand,
151
+ product_catalog=PRODUCT_CATALOG,
152
+ inventory={"ring": 0, "necklace": 0, "bracelet": 0},
153
+ product_for_sale=None,
154
+ cost_basis=0.0,
155
+ current_offer=None,
156
+ negotiation_round=0,
157
+ message=(
158
+ f"Welcome to the Jewelry Shop! Today's gold price is ${gold_price}/oz. "
159
+ f"You have ${STARTING_CASH}. You can 'buy' gold or 'wait' for a better price. "
160
+ f"Market rounds remaining: {MAX_MARKET_ROUNDS}."
161
+ ),
162
+ )
163
+
164
+ # ── STEP ───────────────────────────────────
165
+
166
+ def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
167
+ self._state.step_count += 1
168
+ phase = self._state.phase
169
+
170
+ if phase == "market":
171
+ return self._step_market(action)
172
+ elif phase == "warehouse":
173
+ return self._step_warehouse(action)
174
+ elif phase == "showroom":
175
+ return self._step_showroom(action)
176
+ else:
177
+ raise ValueError(f"Unknown phase: {phase}")
178
+
179
+ # ── PHASE 1: MARKET ────────────────────────
180
+
181
+ def _step_market(self, action: JewelryAction) -> JewelryObservation:
182
+ s = self._state
183
+ market_action = (action.market_action or "wait").lower().strip()
184
+
185
+ if market_action == "buy":
186
+ gold_qty = action.gold_qty or 0.0
187
+ total_cost = gold_qty * s.gold_price
188
+
189
+ if gold_qty <= 0 or total_cost > s.cash:
190
+ # Failed transaction β€” stay in market
191
+ return JewelryObservation(
192
+ done=False,
193
+ reward=0.0,
194
+ phase="market",
195
+ cash=s.cash,
196
+ gold_oz=s.gold_oz,
197
+ gold_price=s.gold_price,
198
+ gold_price_history=list(s.gold_price_history),
199
+ market_round=s.market_round,
200
+ max_market_rounds=MAX_MARKET_ROUNDS,
201
+ demand=s.demand,
202
+ product_catalog=PRODUCT_CATALOG,
203
+ inventory=s.inventory,
204
+ message=(
205
+ f"Transaction failed. Tried to buy {gold_qty}oz "
206
+ f"(${total_cost:.2f}) but you have ${s.cash:.2f}. "
207
+ f"Try a smaller quantity or wait."
208
+ ),
209
+ )
210
+
211
+ # Successful buy
212
+ s.cash -= total_cost
213
+ s.gold_oz += gold_qty
214
+ self._r1 = compute_r1(s.gold_price, s.lowest_price_seen)
215
+
216
+ # Advance to warehouse
217
+ s.phase = "warehouse"
218
+
219
+ return JewelryObservation(
220
+ done=False,
221
+ reward=self._r1,
222
+ phase="warehouse",
223
+ cash=s.cash,
224
+ gold_oz=s.gold_oz,
225
+ gold_price=s.gold_price,
226
+ gold_price_history=list(s.gold_price_history),
227
+ market_round=s.market_round,
228
+ max_market_rounds=MAX_MARKET_ROUNDS,
229
+ demand=s.demand,
230
+ product_catalog=PRODUCT_CATALOG,
231
+ inventory=s.inventory,
232
+ message=(
233
+ f"Bought {gold_qty}oz of gold at ${s.gold_price}/oz "
234
+ f"for ${total_cost:.2f}. Cash remaining: ${s.cash:.2f}. "
235
+ f"Now check your warehouse. Which product to craft? "
236
+ f"Options: ring (1oz gold + $200), necklace (2oz + $300), bracelet (0.5oz + $100)."
237
+ ),
238
+ )
239
+
240
+ else:
241
+ # Agent chose to WAIT β€” advance market round
242
+ s.market_round += 1
243
+
244
+ if s.market_round >= MAX_MARKET_ROUNDS:
245
+ # Forced to buy at current price or skip to warehouse with no gold
246
+ s.phase = "warehouse"
247
+ self._r1 = 0.0
248
+ return JewelryObservation(
249
+ done=False,
250
+ reward=0.0,
251
+ phase="warehouse",
252
+ cash=s.cash,
253
+ gold_oz=s.gold_oz,
254
+ gold_price=s.gold_price,
255
+ gold_price_history=list(s.gold_price_history),
256
+ market_round=s.market_round,
257
+ max_market_rounds=MAX_MARKET_ROUNDS,
258
+ demand=s.demand,
259
+ product_catalog=PRODUCT_CATALOG,
260
+ inventory=s.inventory,
261
+ message=(
262
+ f"Market closed! You waited too long and didn't buy any gold. "
263
+ f"Entering warehouse with {s.gold_oz}oz gold and ${s.cash} cash."
264
+ ),
265
+ )
266
+
267
+ # Price fluctuates Β±10%
268
+ change = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
269
+ new_price = round(s.gold_price * (1 + change), 2)
270
+ new_price = max(new_price, 50.0) # Floor price
271
+ s.gold_price = new_price
272
+ s.gold_price_history.append(new_price)
273
+ s.lowest_price_seen = min(s.lowest_price_seen, new_price)
274
+
275
+ trend = "↑" if change > 0 else "↓"
276
+ return JewelryObservation(
277
+ done=False,
278
+ reward=0.0,
279
+ phase="market",
280
+ cash=s.cash,
281
+ gold_oz=s.gold_oz,
282
+ gold_price=new_price,
283
+ gold_price_history=list(s.gold_price_history),
284
+ market_round=s.market_round,
285
+ max_market_rounds=MAX_MARKET_ROUNDS,
286
+ demand=s.demand,
287
+ product_catalog=PRODUCT_CATALOG,
288
+ inventory=s.inventory,
289
+ message=(
290
+ f"You waited. Gold price moved {trend} to ${new_price}/oz. "
291
+ f"Price history: {s.gold_price_history}. "
292
+ f"Rounds left: {MAX_MARKET_ROUNDS - s.market_round}. "
293
+ f"Buy now or wait?"
294
+ ),
295
+ )
296
+
297
+ # ── PHASE 2: WAREHOUSE ─────────────────────
298
+
299
+ def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
300
+ s = self._state
301
+ choice = (action.product_choice or "ring").lower().strip()
302
+
303
+ if choice not in PRODUCT_CATALOG:
304
+ choice = "ring" # Default fallback
305
+
306
+ spec = PRODUCT_CATALOG[choice]
307
+ gold_needed = spec["gold_oz"]
308
+ labor_cost = spec["labor"]
309
+
310
+ has_gold = s.gold_oz >= gold_needed
311
+ has_cash = s.cash >= labor_cost
312
+
313
+ if not has_gold or not has_cash:
314
+ # Cannot craft β€” skip to showroom with nothing
315
+ self._r2 = 0.0
316
+ s.phase = "showroom"
317
+ reason = (
318
+ f"not enough gold (need {gold_needed}oz, have {s.gold_oz}oz)"
319
+ if not has_gold else
320
+ f"not enough cash for labor (need ${labor_cost}, have ${s.cash:.2f})"
321
+ )
322
+ return JewelryObservation(
323
+ done=False,
324
+ reward=0.0,
325
+ phase="showroom",
326
+ cash=s.cash,
327
+ gold_oz=s.gold_oz,
328
+ gold_price=s.gold_price,
329
+ gold_price_history=list(s.gold_price_history),
330
+ demand=s.demand,
331
+ product_catalog=PRODUCT_CATALOG,
332
+ inventory=s.inventory,
333
+ product_for_sale=None,
334
+ cost_basis=0.0,
335
+ message=f"Cannot craft {choice}: {reason}. Entering showroom with nothing.",
336
+ )
337
+
338
+ # Successful craft
339
+ s.cash -= labor_cost
340
+ s.gold_oz -= gold_needed
341
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
342
+ s.cost_basis = s.gold_price * gold_needed + labor_cost
343
+ s.product_for_sale = choice
344
+
345
+ self._r2 = compute_r2(choice, s.demand)
346
+
347
+ # Generate customer offer based on demand and cost basis
348
+ demand_factor = s.demand.get(choice, 0.5)
349
+ offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (demand_factor * DEMAND_OFFER_BONUS)
350
+ base_offer = round(s.cost_basis * offer_ratio, 2)
351
+ s.base_offer = base_offer
352
+ s.current_offer = base_offer
353
+ s.phase = "showroom"
354
+ s.negotiation_round = 0
355
+
356
+ return JewelryObservation(
357
+ done=False,
358
+ reward=self._r2,
359
+ phase="showroom",
360
+ cash=s.cash,
361
+ gold_oz=s.gold_oz,
362
+ gold_price=s.gold_price,
363
+ gold_price_history=list(s.gold_price_history),
364
+ demand=s.demand,
365
+ product_catalog=PRODUCT_CATALOG,
366
+ inventory=s.inventory,
367
+ product_for_sale=choice,
368
+ cost_basis=s.cost_basis,
369
+ current_offer=s.current_offer,
370
+ negotiation_round=0,
371
+ message=(
372
+ f"Crafted a {choice}! Cost basis: ${s.cost_basis:.2f} "
373
+ f"(gold ${s.gold_price * gold_needed:.2f} + labor ${labor_cost}). "
374
+ f"Demand for {choice}: {demand_factor:.0%}. "
375
+ f"A customer offers ${s.current_offer:.2f}. Accept, counter, or reject?"
376
+ ),
377
+ )
378
+
379
+ # ── PHASE 3: SHOWROOM ──────────────────────
380
+
381
+ def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
382
+ s = self._state
383
+
384
+ # No product β†’ episode ends immediately
385
+ if s.product_for_sale is None:
386
+ return JewelryObservation(
387
+ done=True,
388
+ reward=combined_reward(self._r1, self._r2, 0.0),
389
+ phase="showroom",
390
+ cash=s.cash,
391
+ gold_oz=s.gold_oz,
392
+ gold_price=s.gold_price,
393
+ demand=s.demand,
394
+ product_catalog=PRODUCT_CATALOG,
395
+ inventory=s.inventory,
396
+ product_for_sale=None,
397
+ cost_basis=0.0,
398
+ message="No products to sell. Episode over.",
399
+ )
400
+
401
+ message = action.message or ""
402
+ intent = detect_intent(message)
403
+
404
+ # ── ACCEPT ──
405
+ if intent == "accept":
406
+ r3 = compute_r3(s.current_offer, s.cost_basis)
407
+ final_reward = combined_reward(self._r1, self._r2, r3)
408
+ s.cash += s.current_offer
409
+ s.inventory[s.product_for_sale] -= 1
410
+ product_sold = s.product_for_sale
411
+ s.product_for_sale = None
412
+
413
+ return JewelryObservation(
414
+ done=True,
415
+ reward=final_reward,
416
+ phase="showroom",
417
+ cash=s.cash,
418
+ gold_oz=s.gold_oz,
419
+ gold_price=s.gold_price,
420
+ demand=s.demand,
421
+ product_catalog=PRODUCT_CATALOG,
422
+ inventory=s.inventory,
423
+ product_for_sale=None,
424
+ cost_basis=s.cost_basis,
425
+ current_offer=s.current_offer,
426
+ negotiation_round=s.negotiation_round,
427
+ message=(
428
+ f"Deal! Sold {product_sold} for ${s.current_offer:.2f}. "
429
+ f"Profit: ${s.current_offer - s.cost_basis:.2f}. "
430
+ f"Final reward: {final_reward}."
431
+ ),
432
+ )
433
+
434
+ # ── REJECT ──
435
+ if intent == "reject":
436
+ final_reward = combined_reward(self._r1, self._r2, 0.0)
437
+ return JewelryObservation(
438
+ done=True,
439
+ reward=final_reward,
440
+ phase="showroom",
441
+ cash=s.cash,
442
+ gold_oz=s.gold_oz,
443
+ gold_price=s.gold_price,
444
+ demand=s.demand,
445
+ product_catalog=PRODUCT_CATALOG,
446
+ inventory=s.inventory,
447
+ product_for_sale=s.product_for_sale,
448
+ cost_basis=s.cost_basis,
449
+ current_offer=s.current_offer,
450
+ negotiation_round=s.negotiation_round,
451
+ message=(
452
+ f"You rejected the offer. Customer left. "
453
+ f"Final reward: {final_reward}."
454
+ ),
455
+ )
456
+
457
+ # ── COUNTER ──
458
+ s.negotiation_round += 1
459
+
460
+ if s.negotiation_round >= MAX_NEGOTIATION:
461
+ final_reward = combined_reward(self._r1, self._r2, 0.0)
462
+ return JewelryObservation(
463
+ done=True,
464
+ reward=final_reward,
465
+ phase="showroom",
466
+ cash=s.cash,
467
+ gold_oz=s.gold_oz,
468
+ gold_price=s.gold_price,
469
+ demand=s.demand,
470
+ product_catalog=PRODUCT_CATALOG,
471
+ inventory=s.inventory,
472
+ product_for_sale=s.product_for_sale,
473
+ cost_basis=s.cost_basis,
474
+ current_offer=s.current_offer,
475
+ negotiation_round=s.negotiation_round,
476
+ message=(
477
+ f"Customer left after {MAX_NEGOTIATION} rounds. "
478
+ f"Final reward: {final_reward}."
479
+ ),
480
+ )
481
+
482
+ # Customer raises offer by 5%
483
+ s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
484
+
485
+ return JewelryObservation(
486
+ done=False,
487
+ reward=0.0,
488
+ phase="showroom",
489
+ cash=s.cash,
490
+ gold_oz=s.gold_oz,
491
+ gold_price=s.gold_price,
492
+ demand=s.demand,
493
+ product_catalog=PRODUCT_CATALOG,
494
+ inventory=s.inventory,
495
+ product_for_sale=s.product_for_sale,
496
+ cost_basis=s.cost_basis,
497
+ current_offer=s.current_offer,
498
+ negotiation_round=s.negotiation_round,
499
+ message=(
500
+ f"Customer raises to ${s.current_offer:.2f} "
501
+ f"(round {s.negotiation_round}/{MAX_NEGOTIATION}). "
502
+ f"Accept, counter, or reject?"
503
+ ),
504
+ )
505
+
506
+ # ── STATE PROPERTY ─────────────────────────
507
+
508
+ @property
509
+ def state(self) -> JewelryState:
510
  return self._state
server/__init__.py CHANGED
@@ -1,14 +1,14 @@
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
- try:
10
- from .ShopManagerEng_environment import JewelryShopEnvironment
11
- except ImportError:
12
- from server.ShopManagerEng_environment import JewelryShopEnvironment
13
-
14
- __all__ = ["JewelryShopEnvironment"]
 
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
+ try:
10
+ from .ShopManagerEng_environment import JewelryShopEnvironment
11
+ except ImportError:
12
+ from server.ShopManagerEng_environment import JewelryShopEnvironment
13
+
14
+ __all__ = ["JewelryShopEnvironment"]
server/app.py CHANGED
@@ -1,18 +1,18 @@
1
- from openenv.core.env_server import create_fastapi_app
2
-
3
- try:
4
- from .ShopManagerEng_environment import JewelryShopEnvironment
5
- from ..models import JewelryAction, JewelryObservation
6
- except ImportError:
7
- from server.ShopManagerEng_environment import JewelryShopEnvironment
8
- from models import JewelryAction, JewelryObservation
9
-
10
- import uvicorn
11
-
12
- app = create_fastapi_app(JewelryShopEnvironment, JewelryAction, JewelryObservation)
13
-
14
- def main():
15
- uvicorn.run(app, host="0.0.0.0", port=8000)
16
-
17
- if __name__ == "__main__":
18
  main()
 
1
+ from openenv.core.env_server import create_fastapi_app
2
+
3
+ try:
4
+ from .ShopManagerEng_environment import JewelryShopEnvironment
5
+ from ..models import JewelryAction, JewelryObservation
6
+ except ImportError:
7
+ from server.ShopManagerEng_environment import JewelryShopEnvironment
8
+ from models import JewelryAction, JewelryObservation
9
+
10
+ import uvicorn
11
+
12
+ app = create_fastapi_app(JewelryShopEnvironment, JewelryAction, JewelryObservation)
13
+
14
+ def main():
15
+ uvicorn.run(app, host="0.0.0.0", port=8000)
16
+
17
+ if __name__ == "__main__":
18
  main()
server/requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- openenv[core]>=0.2.0
2
- fastapi>=0.115.0
3
- uvicorn>=0.24.0
4
-
5
-
6
-
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
uv.lock CHANGED
@@ -967,7 +967,7 @@ wheels = [
967
 
968
  [[package]]
969
  name = "huggingface-hub"
970
- version = "1.9.1"
971
  source = { registry = "https://pypi.org/simple" }
972
  dependencies = [
973
  { name = "filelock" },
@@ -980,9 +980,9 @@ dependencies = [
980
  { name = "typer" },
981
  { name = "typing-extensions" },
982
  ]
983
- sdist = { url = "https://files.pythonhosted.org/packages/44/40/68d9b286b125d9318ae95c8f8b206e8672e7244b0eea61ebb4a88037638c/huggingface_hub-1.9.1.tar.gz", hash = "sha256:442af372207cc24dcb089caf507fcd7dbc1217c11d6059a06f6b90afe64e8bd2", size = 750355, upload-time = "2026-04-07T13:47:59.167Z" }
984
  wheels = [
985
- { url = "https://files.pythonhosted.org/packages/3d/af/10a89c54937dccf6c10792770f362d96dd67aedfde108e6e1fd7a0836789/huggingface_hub-1.9.1-py3-none-any.whl", hash = "sha256:8dae771b969b318203727a6c6c5209d25e661f6f0dd010fc09cc4a12cf81c657", size = 637356, upload-time = "2026-04-07T13:47:57.239Z" },
986
  ]
987
 
988
  [[package]]
 
967
 
968
  [[package]]
969
  name = "huggingface-hub"
970
+ version = "1.9.2"
971
  source = { registry = "https://pypi.org/simple" }
972
  dependencies = [
973
  { name = "filelock" },
 
980
  { name = "typer" },
981
  { name = "typing-extensions" },
982
  ]
983
+ sdist = { url = "https://files.pythonhosted.org/packages/cf/65/fb800d327bf25bf31b798dd08935d326d064ecb9b359059fecd91b3a98e8/huggingface_hub-1.9.2.tar.gz", hash = "sha256:8d09d080a186bd950a361bfc04b862dfb04d6a2b41d48e9ba1b37507cfd3f1e1", size = 750284, upload-time = "2026-04-08T08:43:11.127Z" }
984
  wheels = [
985
+ { url = "https://files.pythonhosted.org/packages/57/d4/e33bf0b362810a9b96c5923e38908950d58ecb512db42e3730320c7f4a3a/huggingface_hub-1.9.2-py3-none-any.whl", hash = "sha256:e1e62ce237d4fbeca9f970aeb15176fbd503e04c25577bfd22f44aa7aa2b5243", size = 637349, upload-time = "2026-04-08T08:43:09.114Z" },
986
  ]
987
 
988
  [[package]]