hard007ik commited on
Commit
00fb2d7
·
verified ·
1 Parent(s): 1d489b2

Upload folder using huggingface_hub

Browse files
Files changed (16) hide show
  1. Dockerfile +81 -81
  2. LICENSE +21 -0
  3. README.md +154 -154
  4. __init__.py +16 -12
  5. client.py +168 -111
  6. constants.py +34 -0
  7. inference.py +359 -322
  8. models.py +137 -88
  9. openenv.yaml +38 -37
  10. pyproject.toml +39 -44
  11. server/ShopManagerEng_environment.py +704 -510
  12. server/__init__.py +14 -14
  13. server/app.py +43 -17
  14. server/market_data.py +57 -0
  15. server/sqlite_store.py +208 -0
  16. uv.lock +0 -0
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"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hardik Makwana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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,16 @@
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
+ from .constants import GRAMS_PER_TROY_OZ, troy_oz_to_grams, grams_to_troy_oz
6
+
7
+ __all__ = [
8
+ "JewelryAction",
9
+ "JewelryObservation",
10
+ "JewelryState",
11
+ "JewelryShopEnv",
12
+ "PRODUCT_CATALOG",
13
+ "GRAMS_PER_TROY_OZ",
14
+ "troy_oz_to_grams",
15
+ "grams_to_troy_oz",
16
+ ]
client.py CHANGED
@@ -1,112 +1,169 @@
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 2Warehouse (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
+
4
+ try:
5
+ from .models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
6
+ except ImportError:
7
+ from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
8
+
9
+
10
+ class JewelryShopEnv(EnvClient[JewelryAction, JewelryObservation, JewelryState]):
11
+ """
12
+ Client for the Jewelry Shop RL environment.
13
+
14
+ Usage:
15
+ env = JewelryShopEnv(base_url="http://localhost:8000")
16
+ obs = await env.reset()
17
+
18
+ # Phase 1Market (buy or wait)
19
+ obs = await env.step(JewelryAction(market_action="wait"))
20
+ obs = await env.step(JewelryAction(market_action="buy", gold_qty=2.0))
21
+
22
+ # Phase 2 Warehouse (choose product)
23
+ obs = await env.step(JewelryAction(product_choice="ring"))
24
+
25
+ # Phase 3 — Showroom (negotiate)
26
+ obs = await env.step(JewelryAction(message="How about $600?"))
27
+ obs = await env.step(JewelryAction(message="I accept"))
28
+ """
29
+
30
+ # ── 1. PACK action → dict (sent TO server) ──────────────────────────────
31
+
32
+ def _step_payload(self, action: JewelryAction) -> dict:
33
+ payload = {}
34
+
35
+ if action.market_action is not None:
36
+ payload["market_action"] = action.market_action
37
+
38
+ if action.gold_qty is not None:
39
+ payload["gold_qty"] = action.gold_qty
40
+
41
+ if action.product_choice is not None:
42
+ payload["product_choice"] = action.product_choice
43
+
44
+ if action.message is not None:
45
+ payload["message"] = action.message
46
+
47
+ if action.target_price_usd is not None:
48
+ payload["target_price_usd"] = action.target_price_usd
49
+ if action.ai_confidence_pct is not None:
50
+ payload["ai_confidence_pct"] = action.ai_confidence_pct
51
+ if action.ai_reasoning is not None:
52
+ payload["ai_reasoning"] = action.ai_reasoning
53
+ if action.inventory_urgent is not None:
54
+ payload["inventory_urgent"] = action.inventory_urgent
55
+ if action.need_gold_grams is not None:
56
+ payload["need_gold_grams"] = action.need_gold_grams
57
+ if action.buy_deadline_iso is not None:
58
+ payload["buy_deadline_iso"] = action.buy_deadline_iso
59
+
60
+ return payload
61
+
62
+ # ── 2. UNPACK dict → typed observation (received FROM server) ───────────
63
+
64
+ def _parse_result(self, payload: dict) -> StepResult:
65
+ obs_data = payload.get("observation", {})
66
+
67
+ # Force reward and cumulative_reward to Python floats. JSON over the wire
68
+ # can deliver int (e.g. 0) which would later break formatters / training.
69
+ try:
70
+ _reward = float(payload.get("reward")) if payload.get("reward") is not None else 0.0
71
+ except (TypeError, ValueError):
72
+ _reward = 0.0
73
+ try:
74
+ _cum = float(obs_data.get("cumulative_reward", 0.0))
75
+ except (TypeError, ValueError):
76
+ _cum = 0.0
77
+
78
+ observation = JewelryObservation(
79
+ # Base fields
80
+ done=payload.get("done", False),
81
+ reward=_reward,
82
+
83
+ # Phase info
84
+ phase=obs_data.get("phase", "market"),
85
+
86
+ # Finances & inventory
87
+ cash=obs_data.get("cash", 1000.0),
88
+ gold_oz=obs_data.get("gold_oz", 0.0),
89
+ gold_grams=obs_data.get("gold_grams", 0.0),
90
+
91
+ # Market
92
+ gold_price=obs_data.get("gold_price", 0.0),
93
+ gold_price_history=obs_data.get("gold_price_history", []),
94
+ market_round=obs_data.get("market_round", 0),
95
+ max_market_rounds=obs_data.get("max_market_rounds", 0),
96
+ market_mode=obs_data.get("market_mode", "real"),
97
+ gold_price_source=obs_data.get("gold_price_source", ""),
98
+ inventory_urgent=obs_data.get("inventory_urgent", False),
99
+ need_gold_grams=obs_data.get("need_gold_grams", None),
100
+ buy_deadline_iso=obs_data.get("buy_deadline_iso", None),
101
+ cannot_wait=obs_data.get("cannot_wait", False),
102
+ market_reentries=obs_data.get("market_reentries", 0),
103
+ max_market_reentries=obs_data.get("max_market_reentries", 2),
104
+
105
+ # Warehouse
106
+ demand=obs_data.get("demand", {}),
107
+ demand_forecast=obs_data.get("demand_forecast", {}),
108
+ product_catalog=obs_data.get("product_catalog", PRODUCT_CATALOG),
109
+ inventory=obs_data.get("inventory", {}),
110
+
111
+ # Showroom
112
+ product_for_sale=obs_data.get("product_for_sale", None),
113
+ cost_basis=obs_data.get("cost_basis", 0.0),
114
+ current_offer=obs_data.get("current_offer", None),
115
+ negotiation_round=obs_data.get("negotiation_round", 0),
116
+
117
+ # Per-task grading
118
+ task_id=obs_data.get("task_id", "profit_negotiator"),
119
+ weights=obs_data.get("weights", []),
120
+ cumulative_reward=_cum,
121
+
122
+ # Feedback
123
+ message=obs_data.get("message", ""),
124
+ )
125
+
126
+ return StepResult(
127
+ observation=observation,
128
+ reward=_reward,
129
+ done=payload.get("done", False),
130
+ )
131
+
132
+ # ── 3. UNPACK dict → typed state (server internal state) ────────────────
133
+
134
+ def _parse_state(self, payload: dict) -> JewelryState:
135
+ return JewelryState(
136
+ episode_id=payload.get("episode_id", None),
137
+ step_count=payload.get("step_count", 0),
138
+
139
+ cash=payload.get("cash", 1000.0),
140
+ gold_oz=payload.get("gold_oz", 0.0),
141
+ gold_price=payload.get("gold_price", 0.0),
142
+ gold_price_history=payload.get("gold_price_history", []),
143
+ market_round=payload.get("market_round", 0),
144
+ max_market_rounds=payload.get("max_market_rounds", 0),
145
+ use_fifo_lots=payload.get("use_fifo_lots", False),
146
+ market_mode=payload.get("market_mode", "real"),
147
+ gold_price_source=payload.get("gold_price_source", ""),
148
+
149
+ demand=payload.get("demand", {}),
150
+ demand_forecast=payload.get("demand_forecast", {}),
151
+ inventory=payload.get("inventory", {}),
152
+
153
+ phase=payload.get("phase", "market"),
154
+ product_for_sale=payload.get("product_for_sale", None),
155
+ cost_basis=payload.get("cost_basis", 0.0),
156
+ negotiation_round=payload.get("negotiation_round", 0),
157
+ current_offer=payload.get("current_offer", 0.0),
158
+ base_offer=payload.get("base_offer", 0.0),
159
+ lowest_price_seen=payload.get("lowest_price_seen", 0.0),
160
+ inventory_urgent=payload.get("inventory_urgent", False),
161
+ need_gold_grams=payload.get("need_gold_grams", None),
162
+ buy_deadline_iso=payload.get("buy_deadline_iso", None),
163
+ market_reentries=payload.get("market_reentries", 0),
164
+ max_market_reentries=payload.get("max_market_reentries", 2),
165
+ task_id=payload.get("task_id", "profit_negotiator"),
166
+ weights=payload.get("weights", []),
167
+ cumulative_reward=payload.get("cumulative_reward", 0.0),
168
+ last_phase_emitted_reward=payload.get("last_phase_emitted_reward", 0.0),
169
  )
constants.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared physical and market constants (troy oz ↔ grams, config keys).
3
+ """
4
+ import os
5
+ from typing import Final
6
+
7
+ # Troy ounce (XAU convention in this project) to grams, per user / pricing spec
8
+ GRAMS_PER_TROY_OZ: Final[float] = 31.1035
9
+
10
+
11
+ def troy_oz_to_grams(oz: float) -> float:
12
+ return round(oz * GRAMS_PER_TROY_OZ, 6)
13
+
14
+
15
+ def grams_to_troy_oz(grams: float) -> float:
16
+ if GRAMS_PER_TROY_OZ <= 0:
17
+ return 0.0
18
+ return round(grams / GRAMS_PER_TROY_OZ, 8)
19
+
20
+
21
+ def get_market_mode() -> str:
22
+ """'real' uses live GC=F + DB; 'synthetic' uses legacy random market (for offline tests)."""
23
+ return (os.environ.get("SHOPMANAGER_MARKET_MODE", "real") or "real").lower().strip()
24
+
25
+
26
+ def get_sqlite_path() -> str:
27
+ return os.environ.get("SHOPMANAGER_SQLITE_PATH", "").strip() or ""
28
+
29
+
30
+ def default_sqlite_path() -> str:
31
+ from pathlib import Path
32
+
33
+ here = Path(__file__).resolve().parent
34
+ return str(here / "data" / "shop_manager.db")
inference.py CHANGED
@@ -1,322 +1,359 @@
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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
20
+
21
+ # ── LLM API ─────────────────────────────────────────────────────────────────
22
+ # HuggingFace Inference Router (needs HF_TOKEN in .env)
23
+ API_BASE_URL = "https://router.huggingface.co/v1"
24
+
25
+ # ── MODEL ───────────────────────────────────────────────────────────────────
26
+ # Pick one — comment out the other
27
+ MODEL_NAME = "meta-llama/Llama-3.3-70B-Instruct"
28
+ # MODEL_NAME = "Qwen/Qwen2.5-72B-Instruct"
29
+ # MODEL_NAME = "meta-llama/Llama-3.2-3B-Instruct"
30
+ # MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
31
+ # ─────────────────────────────────────────────────────────────────────────────
32
+ TASK_NAME = os.getenv("JEWELRY_ENV_TASK", "jewelry-shop")
33
+ BENCHMARK = os.getenv("JEWELRY_ENV_BENCHMARK", "jewelry_shop_benchmark")
34
+ MAX_STEPS = 15
35
+ TEMPERATURE = 0.7
36
+ MAX_TOKENS = 150
37
+ SUCCESS_SCORE_THRESHOLD = 0.01
38
+
39
+
40
+ SYSTEM_PROMPT = textwrap.dedent(
41
+ """
42
+ You are an expert agent running a jewelry shop. The episode runs in 3 phases
43
+ and may loop back to MARKET if the warehouse runs out of gold. The episode
44
+ reward is the SUM of per-step partial rewards across the whole episode and
45
+ is bounded in [0, 1]. Each task weights the phases differently:
46
+ - market_timing -> phase 1 = 0.6, phase 2 = 0.2, phase 3 = 0.2
47
+ - demand_crafter -> phase 1 = 0.2, phase 2 = 0.6, phase 3 = 0.2
48
+ - profit_negotiator -> phase 1 = 0.2, phase 2 = 0.2, phase 3 = 0.6
49
+
50
+ ## Phase 1: MARKET (buy / wait)
51
+ Two modes:
52
+ - synthetic mode: gold price moves randomly each WAIT step within a round cap.
53
+ - real mode: gold price comes from a live source (yfinance: GC=F),
54
+ no round cap; WAIT just refreshes the live quote.
55
+ Coordination from the warehouse:
56
+ - inventory_urgent=True / cannot_wait=True means you MUST buy now;
57
+ WAIT will be blocked. Submit "buy X.XX" with an affordable troy-oz qty.
58
+ Behavior:
59
+ - If you can wait, observe the price trend in gold_price_history before buying.
60
+ - Reserve cash for labor (ring=$200, necklace=$300, bracelet=$100).
61
+ - Respond: "buy X.XX" (troy oz of gold) or "wait".
62
+
63
+ ## Phase 2: WAREHOUSE (choose product)
64
+ You see two demand fields:
65
+ - demand : the TRUE per-product demand for THIS episode (ground truth).
66
+ - demand_forecast : a NOISY signal you can also lean on for planning.
67
+ Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
68
+ If you don't have enough gold to craft your choice, the env may BOUNCE you back
69
+ to MARKET to buy more (up to max_market_reentries times). After max bounces or
70
+ when truly broke, the customer leaves and the episode ends.
71
+ Respond: "ring", "necklace", or "bracelet".
72
+
73
+ ## Phase 3: SHOWROOM (negotiate)
74
+ The customer makes an offer; if you counter, they raise it ~5% per round,
75
+ up to 5 rounds. After 5 rounds with no acceptance, the customer leaves
76
+ (no phase-3 reward). Reject also gives 0 phase-3 reward.
77
+ Respond: "I accept" or a counter like "How about $X?". NEVER explicitly reject.
78
+
79
+ CRITICAL: Respond with ONLY the action value. No explanations.
80
+ """
81
+ ).strip()
82
+
83
+
84
+ # ── LOGGING ────────────────────────────────────
85
+
86
+ def log_start(task: str, env: str, model: str) -> None:
87
+ print(f"[START] task={task} env={env} model={model}", flush=True)
88
+
89
+
90
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
91
+ error_val = error if error else "null"
92
+ done_val = str(done).lower()
93
+ print(
94
+ f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
95
+ flush=True,
96
+ )
97
+
98
+
99
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
100
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
101
+ print(f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}", flush=True)
102
+
103
+
104
+ # ── PROMPT BUILDING ────────────────────────────
105
+
106
+ def build_user_prompt(step: int, obs, last_reward: float, history: List[str]) -> str:
107
+ history_block = "\n".join(history[-4:]) if history else "None"
108
+
109
+ if obs.phase == "market":
110
+ prices = obs.gold_price_history
111
+ trend = ""
112
+ if len(prices) >= 2:
113
+ if prices[-1] < prices[-2]:
114
+ trend = "FALLING ↓ (might keep dropping, consider waiting)"
115
+ else:
116
+ trend = "RISING ↑ (buy now before it gets more expensive)"
117
+
118
+ if getattr(obs, "cannot_wait", False):
119
+ trend = "URGENT: inventory needs gold now — you cannot wait; buy at the current live quote with an affordable gold_qty (troy oz)."
120
+
121
+ rounds_left = (obs.max_market_rounds - obs.market_round) if obs.max_market_rounds else None
122
+ # Suggest buy quantity that reserves $300 for labor (max labor cost)
123
+ reserve = 300.0
124
+ if obs.gold_price > 0:
125
+ raw_qty = (obs.cash - reserve) / obs.gold_price
126
+ suggested_qty = math.floor(raw_qty * 100) / 100
127
+ suggested_qty = max(suggested_qty, 0.01)
128
+ else:
129
+ suggested_qty = 1.0
130
+
131
+ _rl = "unlimited" if rounds_left is None else str(rounds_left)
132
+ phase_hint = (
133
+ f"Price: ${getattr(obs, 'gold_price', 0)}/oz ({getattr(obs, 'gold_price_source', '') or 'n/a'}). "
134
+ f"Price history: {prices}. Trend: {trend}. "
135
+ f"Rounds / waits so far: {getattr(obs, 'market_round', 0)}; cap: {_rl}. "
136
+ f"Gold on hand: {getattr(obs, 'gold_oz', 0)} troy oz (~{getattr(obs, 'gold_grams', 0):.2f} g). "
137
+ f"If buying, suggested qty: {suggested_qty} oz (reserves $300 for labor). "
138
+ f"Respond: 'buy {suggested_qty}' or 'wait'"
139
+ )
140
+
141
+ elif obs.phase == "warehouse":
142
+ demand = obs.demand
143
+ forecast = getattr(obs, "demand_forecast", {}) or {}
144
+ best_product = max(demand, key=demand.get) if demand else "ring"
145
+ phase_hint = (
146
+ f"Demand (episode): ring={demand.get('ring', 0):.0%}, "
147
+ f"necklace={demand.get('necklace', 0):.0%}, "
148
+ f"bracelet={demand.get('bracelet', 0):.0%}. "
149
+ f"Forecast (noisy): ring={forecast.get('ring', 0):.0%}, "
150
+ f"necklace={forecast.get('necklace', 0):.0%}, "
151
+ f"bracelet={forecast.get('bracelet', 0):.0%}. "
152
+ f"Highest demand: {best_product}. "
153
+ f"You have {obs.gold_oz}oz gold and ${obs.cash} cash. "
154
+ f"Respond with EXACTLY: {best_product}"
155
+ )
156
+
157
+ elif obs.phase == "showroom":
158
+ margin = ""
159
+ if obs.current_offer and obs.cost_basis > 0:
160
+ margin_pct = ((obs.current_offer - obs.cost_basis) / obs.cost_basis) * 100
161
+ margin = f"Margin: {margin_pct:+.1f}%. "
162
+
163
+ should_accept = False
164
+ if obs.negotiation_round >= 4:
165
+ should_accept = True
166
+ if obs.current_offer and obs.cost_basis > 0 and obs.current_offer > obs.cost_basis * 1.3:
167
+ should_accept = True
168
+
169
+ if should_accept:
170
+ phase_hint = (
171
+ f"Cost: ${obs.cost_basis}. Offer: ${obs.current_offer}. {margin}"
172
+ f"Round {obs.negotiation_round}/5. "
173
+ f"Respond with EXACTLY: I accept"
174
+ )
175
+ else:
176
+ # Vary counter-offers per round
177
+ counter_msgs = [
178
+ "I need a better price for this quality piece",
179
+ "That's too low, this craftsmanship deserves more",
180
+ f"How about ${round(obs.cost_basis * 1.4, 2)}?",
181
+ f"I can't go below ${round(obs.cost_basis * 1.3, 2)}",
182
+ ]
183
+ msg = counter_msgs[min(obs.negotiation_round, len(counter_msgs) - 1)]
184
+ phase_hint = (
185
+ f"Cost: ${obs.cost_basis}. Offer: ${obs.current_offer}. {margin}"
186
+ f"Round {obs.negotiation_round}/5. "
187
+ f"DO NOT ACCEPT. Counter-offer. "
188
+ f"Respond with EXACTLY: {msg}"
189
+ )
190
+ else:
191
+ phase_hint = ""
192
+
193
+ return textwrap.dedent(
194
+ f"""
195
+ Step: {step} | Phase: {obs.phase} | Last reward: {last_reward:.2f}
196
+ Cash: ${obs.cash} | Gold: {obs.gold_oz}oz | Rings: {obs.inventory}
197
+ Gold Price: ${obs.gold_price}/oz
198
+ Env Message: {obs.message}
199
+
200
+ {phase_hint}
201
+
202
+ History: {history_block}
203
+ """
204
+ ).strip()
205
+
206
+
207
+ # ── ACTION PARSING ─────────────────────────────
208
+
209
+ def get_action_from_text(phase: str, text: str) -> tuple[JewelryAction, str]:
210
+ text = text.strip().replace("`", "").strip(' \t\n\r"\'')
211
+
212
+ if phase == "market":
213
+ lower = text.lower()
214
+ if lower.startswith("buy"):
215
+ # Extract quantity from "buy 2.5" or "buy2.5"
216
+ qty_str = lower.replace("buy", "").strip()
217
+ try:
218
+ qty = float(qty_str)
219
+ except ValueError:
220
+ qty = 1.0
221
+ return JewelryAction(market_action="buy", gold_qty=qty), f"buy {qty}"
222
+ elif "wait" in lower:
223
+ return JewelryAction(market_action="wait"), "wait"
224
+ else:
225
+ # Try to parse as a number (assumed buy)
226
+ try:
227
+ qty = float(text)
228
+ return JewelryAction(market_action="buy", gold_qty=qty), f"buy {qty}"
229
+ except ValueError:
230
+ return JewelryAction(market_action="wait"), "wait"
231
+
232
+ elif phase == "warehouse":
233
+ lower = text.lower()
234
+ for product in ["necklace", "bracelet", "ring"]:
235
+ if product in lower:
236
+ return JewelryAction(product_choice=product), product
237
+ return JewelryAction(product_choice="ring"), "ring"
238
+
239
+ elif phase == "showroom":
240
+ return JewelryAction(message=text), text
241
+
242
+ return JewelryAction(), text
243
+
244
+
245
+ def get_model_action(client: OpenAI, step: int, obs, last_reward: float, history: List[str]) -> tuple[JewelryAction, str]:
246
+ user_prompt = build_user_prompt(step, obs, last_reward, history)
247
+ try:
248
+ completion = client.chat.completions.create(
249
+ model=MODEL_NAME,
250
+ messages=[
251
+ {"role": "system", "content": SYSTEM_PROMPT},
252
+ {"role": "user", "content": user_prompt},
253
+ ],
254
+ temperature=TEMPERATURE,
255
+ max_tokens=MAX_TOKENS,
256
+ stream=False,
257
+ )
258
+ text = (completion.choices[0].message.content or "").strip()
259
+ return get_action_from_text(obs.phase, text)
260
+ except Exception as exc:
261
+ # print(f"[DEBUG] Model request failed: {exc}", flush=True)
262
+ # Fallback actions
263
+ if obs.phase == "market":
264
+ return JewelryAction(market_action="buy", gold_qty=1.0), "buy 1.0"
265
+ elif obs.phase == "warehouse":
266
+ return JewelryAction(product_choice="ring"), "ring"
267
+ else:
268
+ return JewelryAction(message="I accept"), "I accept"
269
+
270
+
271
+
272
+ # ── SINGLE EPISODE RUNNER ──────────────────────
273
+
274
+ async def run_episode(client: OpenAI, task_name: str, env_name: str, base_url: str) -> float:
275
+ """Run a single episode and return the final score."""
276
+ history: List[str] = []
277
+ rewards: List[float] = []
278
+ steps_taken = 0
279
+ score = 0.0
280
+ success = False
281
+
282
+ log_start(task=task_name, env=env_name, model=MODEL_NAME)
283
+
284
+ try:
285
+ env = JewelryShopEnv(base_url=base_url)
286
+
287
+ # Pass task_id so the env applies that task's per-phase weights.
288
+ result = await env.reset(task_id=task_name)
289
+ obs = result.observation
290
+ last_reward = 0.0
291
+
292
+ for step in range(1, MAX_STEPS + 1):
293
+ if result.done:
294
+ break
295
+
296
+ action, raw_action_str = get_model_action(client, step, obs, last_reward, history)
297
+ current_phase = obs.phase
298
+
299
+ result = await env.step(action)
300
+ obs = result.observation
301
+
302
+ reward = result.reward or 0.0
303
+ done = result.done
304
+ error = None
305
+
306
+ rewards.append(reward)
307
+ steps_taken = step
308
+ last_reward = reward
309
+
310
+ log_step(step=step, action=raw_action_str.replace('\n', ' '), reward=reward, done=done, error=error)
311
+ history.append(f"Step {step} ({current_phase}): {raw_action_str!r} -> reward {reward:+.2f}")
312
+
313
+ if done:
314
+ break
315
+
316
+ # Trajectory return = env's authoritative cumulative reward (sum of per-step
317
+ # partials, in [0, 1]). Falls back to summing locally if the field is missing.
318
+ score = float(getattr(obs, "cumulative_reward", sum(rewards) if rewards else 0.0))
319
+ score = min(max(score, 0.0), 1.0)
320
+ success = score >= SUCCESS_SCORE_THRESHOLD
321
+
322
+ finally:
323
+ try:
324
+ await env.close()
325
+ except Exception as e:
326
+ pass
327
+ # print(f"[DEBUG] env.close() error: {e}", flush=True)
328
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
329
+
330
+ return score
331
+
332
+
333
+ # ── MAIN ───────────────────────────────────────
334
+
335
+ TASKS = [
336
+ {"id": "market_timing", "env": "jewelry_shop_benchmark"},
337
+ {"id": "demand_crafter", "env": "jewelry_shop_benchmark"},
338
+ {"id": "profit_negotiator", "env": "jewelry_shop_benchmark"},
339
+ ]
340
+
341
+ async def main() -> None:
342
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
343
+
344
+ # ── ENV SERVER URL ──────────────────────────────────────────────────────
345
+ # LOCAL: start server with `uv run --project . server`, then use localhost
346
+ # REMOTE: comment the localhost line and uncomment the HF Space line
347
+ # base_url = "http://localhost:8000"
348
+ base_url = "https://hard007ik-shopmanagereng.hf.space"
349
+ # ───────────────────────────────────────────────────────────────────────
350
+
351
+ # print(f"[CONFIG] base_url={base_url} model={MODEL_NAME}", flush=True)
352
+
353
+ for task in TASKS:
354
+ await run_episode(client, task["id"], task["env"], base_url)
355
+
356
+
357
+ if __name__ == "__main__":
358
+ asyncio.run(main())
359
+
models.py CHANGED
@@ -1,88 +1,137 @@
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 (optional): when logging a BUY to SQLite / invoice, the agent may send
28
+ LLM target + reasoning; when coordinating with the inventory side, it may
29
+ update urgency / need-by fields that were also set on reset.
30
+ """
31
+ market_action: Optional[str] = None # "buy" or "wait"
32
+ gold_qty: Optional[float] = None # How many oz to buy (market phase)
33
+ product_choice: Optional[str] = None # "ring" / "necklace" / "bracelet"
34
+ message: Optional[str] = None # Showroom negotiation text
35
+
36
+ target_price_usd: Optional[float] = None
37
+ ai_confidence_pct: Optional[float] = None
38
+ ai_reasoning: Optional[str] = None
39
+ inventory_urgent: Optional[bool] = None
40
+ need_gold_grams: Optional[float] = None
41
+ buy_deadline_iso: Optional[str] = None
42
+
43
+
44
+ # ─────────────────────────────────────────────
45
+ # OBSERVATION
46
+ # Everything the agent can SEE each step.
47
+ # ─────────────────────────────────────────────
48
+
49
+ class JewelryObservation(Observation):
50
+ # Base fields: done, reward (inherited)
51
+
52
+ phase: str # "market" | "warehouse" | "showroom"
53
+ cash: float # Agent's current cash ($)
54
+ gold_oz: float # Raw gold in inventory (oz)
55
+
56
+ # Market phase
57
+ gold_price: float # Current gold price ($/oz)
58
+ gold_grams: float = 0.0 # Raw gold in inventory (grams) — troy-oz * GRAMS_PER_TROY_OZ
59
+ gold_price_history: List[float] = [] # Last N prices for trend analysis
60
+ market_round: int = 0 # "Wait" count in this episode (for analytics; no cap in real mode)
61
+ max_market_rounds: int = 0 # 0 = no forced round limit (real market); >0 = synthetic only
62
+ market_mode: str = "real" # "real" | "synthetic"
63
+ gold_price_source: str = "" # e.g. yfinance:GC=F
64
+
65
+ # Inventory <-> market coordination (from reset / optional step updates)
66
+ inventory_urgent: bool = False
67
+ need_gold_grams: Optional[float] = None
68
+ buy_deadline_iso: Optional[str] = None
69
+ cannot_wait: bool = False # If urgent, "wait" action is rejected
70
+
71
+ # Inventory -> Market bounce-back (when warehouse cannot craft due to low gold)
72
+ market_reentries: int = 0 # How many times warehouse has sent us back to market
73
+ max_market_reentries: int = 2 # Cap on bounce-backs to avoid infinite loops
74
+
75
+ # Warehouse phase
76
+ demand: Dict[str, float] = {} # "True" per-product demand this episode (0-1)
77
+ demand_forecast: Dict[str, float] = {} # Noisy / model-facing signal (inventory "prediction" slot)
78
+ product_catalog: Dict[str, dict] = {} # Gold/labor costs per product
79
+ inventory: Dict[str, int] = {} # Crafted products in stock
80
+
81
+ # Showroom phase
82
+ product_for_sale: Optional[str] = None # Which product is being sold
83
+ cost_basis: float = 0.0 # Total cost to make the product
84
+ current_offer: Optional[float] = None # Customer's live offer
85
+ negotiation_round: int = 0 # Counter-offer rounds so far
86
+
87
+ # Per-task grading (chosen at reset() from openenv.yaml task_id)
88
+ task_id: str = "profit_negotiator"
89
+ weights: List[float] = [] # [w_market, w_warehouse, w_showroom], sums to 1.0
90
+ cumulative_reward: float = 0.0 # Running sum of per-step rewards in this episode
91
+
92
+ message: str = "" # Human-readable feedback
93
+
94
+
95
+ # ─────────────────────────────────────────────
96
+ # STATE
97
+ # Full internal state (server-side truth).
98
+ # ─────────────────────────────────────────────
99
+
100
+ class JewelryState(State):
101
+ # Base: episode_id, step_count (inherited)
102
+
103
+ cash: float = 1000.0
104
+ gold_oz: float = 0.0
105
+ gold_price: float = 0.0
106
+ gold_price_history: List[float] = []
107
+ market_round: int = 0
108
+ max_market_rounds: int = 0 # 0 = no cap (real); >0 only in synthetic mode
109
+
110
+ demand: Dict[str, float] = {}
111
+ demand_forecast: Dict[str, float] = {}
112
+ inventory: Dict[str, int] = {}
113
+
114
+ phase: str = "market"
115
+ product_for_sale: Optional[str] = None
116
+ cost_basis: float = 0.0
117
+ negotiation_round: int = 0
118
+ current_offer: float = 0.0
119
+ base_offer: float = 0.0 # Hidden from agent
120
+ lowest_price_seen: float = 0.0 # For r1 scoring
121
+
122
+ inventory_urgent: bool = False
123
+ need_gold_grams: Optional[float] = None
124
+ buy_deadline_iso: Optional[str] = None
125
+ use_fifo_lots: bool = False # If True, warehouse cost uses per-gram lots in SQLite
126
+ gold_price_source: str = ""
127
+ market_mode: str = "real"
128
+
129
+ # Inventory -> Market bounce-back loop
130
+ market_reentries: int = 0
131
+ max_market_reentries: int = 2
132
+
133
+ # Per-task grading (selected at reset)
134
+ task_id: str = "profit_negotiator"
135
+ weights: List[float] = [] # [w_market, w_warehouse, w_showroom]
136
+ cumulative_reward: float = 0.0
137
+ last_phase_emitted_reward: float = 0.0 # Reward emitted at the most recent step (debug)
openenv.yaml CHANGED
@@ -1,37 +1,38 @@
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
+ Long-horizon Phase 1: get gold (troy oz) at a reasonable cost using live or synthetic
13
+ market quotes, coordinate with optional inventory-urgent signals, then proceed to
14
+ craft and sell. May require multiple market steps in real mode (no hard round cap);
15
+ synthetic mode supports classic round-budget runs.
16
+ grader:
17
+ type: reward_threshold
18
+ threshold: 0.3
19
+
20
+ - id: demand_crafter
21
+ name: "Demand-Based Crafter (Inventory)"
22
+ description: >
23
+ Phase 2: use demand and demand_forecast, gold stock, and costs to pick which product
24
+ to craft. Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
25
+ FIFO (SQLite) pricing applies in real + DB mode for true metal cost.
26
+ grader:
27
+ type: reward_threshold
28
+ threshold: 0.2
29
+
30
+ - id: profit_negotiator
31
+ name: "Profit Maximizer"
32
+ description: >
33
+ Complete the full pipeline: buy gold at a good price, craft the
34
+ best product based on demand, and negotiate with the customer.
35
+ Counter-offer to drive up the price before accepting.
36
+ grader:
37
+ type: reward_threshold
38
+ threshold: 0.25
pyproject.toml CHANGED
@@ -1,45 +1,40 @@
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
+ "yfinance>=0.2.40",
22
+ "python-dotenv>=1.0.0",
23
+ "requests>=2.28.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0.0",
29
+ "pytest-cov>=4.0.0",
30
+ ]
31
+
32
+ [project.scripts]
33
+ # Server entry point - enables running via: uv run --project . server
34
+ # or: python -m ShopManagerEng.server.app
35
+ server = "ShopManagerEng.server.app:main"
36
+
37
+ [tool.setuptools]
38
+ include-package-data = true
39
+ packages = ["ShopManagerEng", "ShopManagerEng.server"]
 
 
 
 
 
40
  package-dir = { "ShopManagerEng" = ".", "ShopManagerEng.server" = "server" }
server/ShopManagerEng_environment.py CHANGED
@@ -1,510 +1,704 @@
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 typing import Optional
4
+
5
+ from openenv.core.env_server import Environment
6
+
7
+ try:
8
+ from ..constants import get_market_mode, troy_oz_to_grams
9
+ from ..models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
10
+ from .market_data import last_quote_or_fallback, fetch_gold_spot_usd_per_oz
11
+ from . import sqlite_store
12
+ except ImportError:
13
+ # Installed: ShopManagerEng.* — otherwise dev layout: CWD=ShopManagerEng, `import server` (siblings: models, constants)
14
+ from constants import get_market_mode, troy_oz_to_grams
15
+ from models import JewelryAction, JewelryObservation, JewelryState, PRODUCT_CATALOG
16
+ from server.market_data import last_quote_or_fallback, fetch_gold_spot_usd_per_oz
17
+ from server import sqlite_store
18
+
19
+
20
+ # Legacy synthetic market (used when SHOPMANAGER_MARKET_MODE=synthetic)
21
+ STARTING_CASH = 10000.0
22
+ GOLD_PRICE_MIN = 250.0
23
+ GOLD_PRICE_MAX = 450.0
24
+ PRICE_FLUCTUATION = 0.10
25
+ MAX_MARKET_ROUNDS = 3
26
+
27
+ MAX_NEGOTIATION = 5
28
+ COUNTER_BUMP = 1.05
29
+ OFFER_MIN_RATIO = 0.80
30
+ OFFER_MAX_RATIO = 1.30
31
+ DEMAND_OFFER_BONUS = 0.20
32
+ MAX_PROFIT_MULT = 2.0
33
+
34
+ ACCEPT_KEYWORDS = ["accept", "deal", "sold", "agreed", "yes", "take it", "i'll take"]
35
+ REJECT_KEYWORDS = ["reject", "no deal", "refuse", "walk away", "not interested", "no thanks"]
36
+
37
+
38
+ def detect_intent(message: str) -> str:
39
+ msg = message.lower()
40
+ for kw in ACCEPT_KEYWORDS:
41
+ if kw in msg:
42
+ return "accept"
43
+ for kw in REJECT_KEYWORDS:
44
+ if kw in msg:
45
+ return "reject"
46
+ return "counter"
47
+
48
+
49
+ # ─────────────────────────────────────────────
50
+ # REWARD MODEL
51
+ # All r1/r2/r3 are normalized to [0, 1].
52
+ # Each step emits a WEIGHTED PARTIAL reward.
53
+ # Sum of every step's reward over an episode is in [0, 1].
54
+ # ─────────────────────────────────────────────
55
+
56
+ # Per-task phase weights (w_market, w_warehouse, w_showroom). Each row sums to 1.0.
57
+ TASK_WEIGHTS = {
58
+ "market_timing": (0.6, 0.2, 0.2), # Phase 1 dominates
59
+ "demand_crafter": (0.2, 0.6, 0.2), # Phase 2 dominates
60
+ "profit_negotiator": (0.2, 0.2, 0.6), # Phase 3 dominates; phases 1 & 2 weighted equally
61
+ }
62
+ DEFAULT_TASK_ID = "profit_negotiator"
63
+
64
+
65
+ def resolve_weights(task_id: Optional[str]) -> tuple:
66
+ tid = (task_id or DEFAULT_TASK_ID).lower().strip()
67
+ if tid not in TASK_WEIGHTS:
68
+ tid = DEFAULT_TASK_ID
69
+ return TASK_WEIGHTS[tid]
70
+
71
+
72
+ def compute_r1(buy_price: float, lowest_price: float) -> float:
73
+ """Phase 1 score in [0, 1]. 1.0 == bought at lowest seen price."""
74
+ if lowest_price <= 0 or buy_price <= 0:
75
+ return 0.0
76
+ ratio = lowest_price / buy_price
77
+ return round(min(ratio, 1.0), 4)
78
+
79
+
80
+ def compute_r2(product_choice: str, demand: dict) -> float:
81
+ """Phase 2 score in [0, 1]. 1.0 == picked the most-demanded product."""
82
+ if not demand or product_choice not in demand:
83
+ return 0.0
84
+ max_demand = max(demand.values())
85
+ if max_demand <= 0:
86
+ return 0.0
87
+ return round(demand[product_choice] / max_demand, 4)
88
+
89
+
90
+ def compute_r3(accepted_price: float, cost_basis: float) -> float:
91
+ """Phase 3 score in [0, 1]. 1.0 == hit the max profit multiple."""
92
+ if cost_basis <= 0:
93
+ return 0.0
94
+ profit = accepted_price - cost_basis
95
+ if profit <= 0:
96
+ return 0.0
97
+ max_profit = cost_basis * (MAX_PROFIT_MULT - 1)
98
+ return round(min(profit / max_profit, 1.0), 4)
99
+
100
+
101
+ def step_reward(weights: tuple, phase_emitted: str, r_value: float) -> float:
102
+ """
103
+ Convert a normalized phase score (in [0, 1]) into the WEIGHTED partial
104
+ reward emitted at that step. Summing these across an episode is in [0, 1].
105
+ Guaranteed to return a Python float (never int / never None).
106
+ """
107
+ if phase_emitted == "market":
108
+ return float(round(float(weights[0]) * float(r_value), 4))
109
+ if phase_emitted == "warehouse":
110
+ return float(round(float(weights[1]) * float(r_value), 4))
111
+ if phase_emitted == "showroom":
112
+ return float(round(float(weights[2]) * float(r_value), 4))
113
+ return 0.0
114
+
115
+
116
+ def _demand_forecast_from(demand: dict) -> dict:
117
+ """
118
+ Noisy "forecast" for the inventory agent to plan against (same scale as demand).
119
+ Deterministic w.r.t. the RNG in reset(seed=...) on the current episode.
120
+ """
121
+ out: dict = {}
122
+ for k, v in demand.items():
123
+ wiggle = random.uniform(-0.12, 0.12)
124
+ out[k] = round(max(0.0, min(1.0, float(v) + wiggle)), 2)
125
+ return out
126
+
127
+
128
+ class JewelryShopEnvironment(Environment):
129
+ SUPPORTS_CONCURRENT_SESSIONS = True
130
+
131
+ def __init__(self):
132
+ self._state = JewelryState()
133
+ # Normalized per-phase scores in [0, 1] (raw, before weighting)
134
+ self._r1 = 0.0
135
+ self._r2 = 0.0
136
+ self._r3 = 0.0
137
+
138
+ def _emit(self, phase_emitted: str, r_value: float) -> float:
139
+ """
140
+ Convert a normalized phase score into the per-step weighted reward,
141
+ update cumulative bookkeeping, and return the value to attach to obs.
142
+ Guaranteed: returned value AND s.cumulative_reward are Python floats.
143
+ """
144
+ s = self._state
145
+ weights = tuple(s.weights) if s.weights else resolve_weights(s.task_id)
146
+ partial = float(step_reward(weights, phase_emitted, r_value))
147
+ s.cumulative_reward = float(round(float(s.cumulative_reward) + partial, 4))
148
+ s.last_phase_emitted_reward = partial
149
+ return partial
150
+
151
+ def _apply_action_inventory_fields(self, action: JewelryAction) -> None:
152
+ s = self._state
153
+ if action.inventory_urgent is not None:
154
+ s.inventory_urgent = bool(action.inventory_urgent)
155
+ if action.need_gold_grams is not None:
156
+ s.need_gold_grams = action.need_gold_grams
157
+ if action.buy_deadline_iso is not None:
158
+ s.buy_deadline_iso = action.buy_deadline_iso
159
+
160
+ def _mm_line(self) -> str:
161
+ s = self._state
162
+ if s.market_mode == "synthetic" and s.max_market_rounds and s.max_market_rounds > 0:
163
+ return f"Market simulation rounds in this phase: {s.max_market_rounds - s.market_round} (of {s.max_market_rounds})."
164
+ if s.max_market_rounds == 0 or s.max_market_rounds is None:
165
+ return "No round limit: wait to refresh the quote; buy when ready."
166
+ return f"Rounds left: {max(0, s.max_market_rounds - s.market_round)}."
167
+
168
+ def _co_market(
169
+ self,
170
+ *,
171
+ done: bool = False,
172
+ reward: float = 0.0,
173
+ msg: str = "",
174
+ keep_phase: Optional[str] = None,
175
+ ) -> dict:
176
+ s = self._state
177
+ ph = keep_phase or s.phase
178
+ max_r = s.max_market_rounds
179
+ g_oz = s.gold_oz
180
+ # Always emit reward as a Python float so it survives JSON serialization
181
+ # as a JSON number with a decimal point (e.g. 0.0, not 0).
182
+ try:
183
+ reward_f = float(reward) if reward is not None else 0.0
184
+ except (TypeError, ValueError):
185
+ reward_f = 0.0
186
+ return dict(
187
+ done=done,
188
+ reward=reward_f,
189
+ phase=ph,
190
+ cash=s.cash,
191
+ gold_oz=g_oz,
192
+ gold_grams=round(troy_oz_to_grams(g_oz), 4),
193
+ gold_price=s.gold_price,
194
+ gold_price_history=list(s.gold_price_history),
195
+ market_round=s.market_round,
196
+ max_market_rounds=max_r,
197
+ market_mode=s.market_mode,
198
+ gold_price_source=s.gold_price_source,
199
+ inventory_urgent=s.inventory_urgent,
200
+ need_gold_grams=s.need_gold_grams,
201
+ buy_deadline_iso=s.buy_deadline_iso,
202
+ cannot_wait=s.inventory_urgent and ph == "market",
203
+ market_reentries=s.market_reentries,
204
+ max_market_reentries=s.max_market_reentries,
205
+ demand=s.demand,
206
+ demand_forecast=getattr(s, "demand_forecast", {}) or {},
207
+ product_catalog=PRODUCT_CATALOG,
208
+ inventory=s.inventory,
209
+ product_for_sale=None if ph == "market" else s.product_for_sale,
210
+ cost_basis=s.cost_basis if ph != "market" else 0.0,
211
+ current_offer=None if ph == "market" else s.current_offer,
212
+ negotiation_round=s.negotiation_round,
213
+ task_id=s.task_id,
214
+ weights=list(s.weights) if s.weights else list(resolve_weights(s.task_id)),
215
+ cumulative_reward=float(s.cumulative_reward),
216
+ message=msg,
217
+ )
218
+
219
+ def _obs_from(self, o: dict) -> JewelryObservation:
220
+ try:
221
+ _r = float(o.get("reward", 0.0)) if o.get("reward", 0.0) is not None else 0.0
222
+ except (TypeError, ValueError):
223
+ _r = 0.0
224
+ try:
225
+ _cr = float(o.get("cumulative_reward", 0.0))
226
+ except (TypeError, ValueError):
227
+ _cr = 0.0
228
+ return JewelryObservation(
229
+ done=o.get("done", False),
230
+ reward=_r,
231
+ phase=o.get("phase", "market"),
232
+ cash=o.get("cash", 1000.0),
233
+ gold_oz=o.get("gold_oz", 0.0),
234
+ gold_grams=o.get("gold_grams", 0.0),
235
+ gold_price=o.get("gold_price", 0.0),
236
+ gold_price_history=o.get("gold_price_history", []),
237
+ market_round=o.get("market_round", 0),
238
+ max_market_rounds=o.get("max_market_rounds", 0),
239
+ market_mode=o.get("market_mode", "real"),
240
+ gold_price_source=o.get("gold_price_source", ""),
241
+ inventory_urgent=o.get("inventory_urgent", False),
242
+ need_gold_grams=o.get("need_gold_grams", None),
243
+ buy_deadline_iso=o.get("buy_deadline_iso", None),
244
+ cannot_wait=o.get("cannot_wait", False),
245
+ market_reentries=o.get("market_reentries", 0),
246
+ max_market_reentries=o.get("max_market_reentries", 2),
247
+ demand=o.get("demand", {}),
248
+ demand_forecast=o.get("demand_forecast", {}),
249
+ product_catalog=o.get("product_catalog", PRODUCT_CATALOG),
250
+ inventory=o.get("inventory", {}),
251
+ product_for_sale=o.get("product_for_sale", None),
252
+ cost_basis=o.get("cost_basis", 0.0),
253
+ current_offer=o.get("current_offer", None),
254
+ negotiation_round=o.get("negotiation_round", 0),
255
+ task_id=o.get("task_id", DEFAULT_TASK_ID),
256
+ weights=o.get("weights", list(resolve_weights(DEFAULT_TASK_ID))),
257
+ cumulative_reward=_cr,
258
+ message=o.get("message", ""),
259
+ )
260
+
261
+ def reset(self, seed=None, episode_id=None, **kwargs) -> JewelryObservation:
262
+ if seed is not None:
263
+ random.seed(seed)
264
+ eid = episode_id or str(uuid.uuid4())
265
+ try:
266
+ starting_cash = float(kwargs.get("starting_cash", STARTING_CASH))
267
+ except (TypeError, ValueError):
268
+ starting_cash = STARTING_CASH
269
+
270
+ inv_urgent = bool(kwargs.get("inventory_urgent", False))
271
+ need_g = kwargs.get("need_gold_grams", None)
272
+ if need_g is not None:
273
+ try:
274
+ need_g = float(need_g)
275
+ except (TypeError, ValueError):
276
+ need_g = None
277
+ deadline = kwargs.get("buy_deadline_iso", None)
278
+ if deadline is not None and not isinstance(deadline, str):
279
+ deadline = str(deadline) if deadline is not None else None
280
+
281
+ dem = {
282
+ "ring": round(random.uniform(0.4, 1.0), 2),
283
+ "necklace": round(random.uniform(0.2, 0.8), 2),
284
+ "bracelet": round(random.uniform(0.1, 0.6), 2),
285
+ }
286
+ dem_fc = _demand_forecast_from(dem)
287
+ mode = (kwargs.get("market_mode") or get_market_mode()).lower().strip()
288
+
289
+ if mode == "synthetic":
290
+ gp = round(random.uniform(GOLD_PRICE_MIN, GOLD_PRICE_MAX), 2)
291
+ hist = [gp]
292
+ mmode = "synthetic"
293
+ src = "synthetic:random_range"
294
+ maxr = int(kwargs.get("max_market_rounds", MAX_MARKET_ROUNDS))
295
+ use_lots = False
296
+ else:
297
+ mmode = "real"
298
+ maxr = 0
299
+ use_lots = True
300
+ sqlite_store.init_schema()
301
+ try:
302
+ q = fetch_gold_spot_usd_per_oz()
303
+ gp = round(q.usd_per_oz, 2)
304
+ src = q.source
305
+ except Exception:
306
+ gp = 2000.0
307
+ src = "yfinance:error_fallback(2000)"
308
+ hist = [gp]
309
+
310
+ max_r0 = int(maxr) if mode == "synthetic" else 0
311
+ task_id = (kwargs.get("task_id") or DEFAULT_TASK_ID).strip().lower()
312
+ weights = resolve_weights(task_id)
313
+ try:
314
+ max_reentries = int(kwargs.get("max_market_reentries", 2))
315
+ if max_reentries < 0:
316
+ max_reentries = 0
317
+ except (TypeError, ValueError):
318
+ max_reentries = 2
319
+ s = self._state = JewelryState(
320
+ episode_id=eid,
321
+ step_count=0,
322
+ cash=starting_cash,
323
+ gold_oz=0.0,
324
+ gold_price=gp,
325
+ gold_price_history=hist,
326
+ market_round=0,
327
+ max_market_rounds=max_r0,
328
+ demand=dem,
329
+ demand_forecast=dem_fc,
330
+ inventory={"ring": 0, "necklace": 0, "bracelet": 0},
331
+ phase="market",
332
+ product_for_sale=None,
333
+ cost_basis=0.0,
334
+ negotiation_round=0,
335
+ current_offer=0.0,
336
+ base_offer=0.0,
337
+ lowest_price_seen=gp,
338
+ inventory_urgent=inv_urgent,
339
+ need_gold_grams=need_g,
340
+ buy_deadline_iso=deadline,
341
+ use_fifo_lots=use_lots,
342
+ gold_price_source=src,
343
+ market_mode=mmode,
344
+ task_id=task_id,
345
+ weights=list(weights),
346
+ cumulative_reward=0.0,
347
+ last_phase_emitted_reward=0.0,
348
+ market_reentries=0,
349
+ max_market_reentries=max_reentries,
350
+ )
351
+ self._r1 = 0.0
352
+ self._r2 = 0.0
353
+ self._r3 = 0.0
354
+ sstep = s.max_market_rounds if s.max_market_rounds else 0
355
+ o = self._co_market(
356
+ msg=(
357
+ f"Welcome. Task='{task_id}' weights(market,warehouse,showroom)={weights}. "
358
+ f"Gold: ${gp}/oz ({s.gold_price_source}). Cash: ${s.cash:.2f}. "
359
+ f"Inventory need-urgent={inv_urgent}."
360
+ f" {self._mm_line()}"
361
+ ),
362
+ )
363
+ o["max_market_rounds"] = sstep
364
+ return self._obs_from(o)
365
+
366
+ def step(self, action: JewelryAction, timeout_s=None, **kwargs) -> JewelryObservation:
367
+ self._state.step_count += 1
368
+ if self._state.phase == "market":
369
+ self._apply_action_inventory_fields(action)
370
+ if self._state.phase == "market":
371
+ return self._step_market(action)
372
+ if self._state.phase == "warehouse":
373
+ return self._step_warehouse(action)
374
+ if self._state.phase == "showroom":
375
+ return self._step_showroom(action)
376
+ raise ValueError(f"Unknown phase: {self._state.phase}")
377
+
378
+ def _refresh_real_quote(self) -> None:
379
+ s = self._state
380
+ if s.market_mode != "real":
381
+ return
382
+ try:
383
+ q = fetch_gold_spot_usd_per_oz()
384
+ s.gold_price = round(q.usd_per_oz, 2)
385
+ s.gold_price_source = q.source
386
+ except Exception as exc: # noqa: BLE001
387
+ fb = s.gold_price if s.gold_price > 0 else 2000.0
388
+ q2 = last_quote_or_fallback(fb)
389
+ s.gold_price = round(q2.usd_per_oz, 2)
390
+ s.gold_price_source = f"{q2.source}(err:{type(exc).__name__})"
391
+ s.gold_price_history.append(s.gold_price)
392
+ s.lowest_price_seen = min(s.lowest_price_seen, s.gold_price) if s.lowest_price_seen else s.gold_price
393
+
394
+ def _step_market(self, action: JewelryAction) -> JewelryObservation:
395
+ s = self._state
396
+ market_action = (action.market_action or "wait").lower().strip()
397
+
398
+ if s.market_mode == "synthetic":
399
+ return self._step_market_synthetic(action, market_action)
400
+ return self._step_market_real(action, market_action)
401
+
402
+ def _step_market_synthetic(self, action: JewelryAction, market_action: str) -> JewelryObservation:
403
+ s = self._state
404
+ if market_action == "buy":
405
+ return self._exec_buy_synthetic_common(action, market_action)
406
+ s.market_round += 1
407
+ if s.market_round >= (s.max_market_rounds or MAX_MARKET_ROUNDS) and s.max_market_rounds is not None and s.max_market_rounds > 0:
408
+ s.phase = "warehouse"
409
+ self._r1 = 0.0
410
+ o = self._co_market(keep_phase="warehouse", msg="(Synthetic) Market round limit — entering warehouse with no new purchase.")
411
+ return self._obs_from(o)
412
+ ch = random.uniform(-PRICE_FLUCTUATION, PRICE_FLUCTUATION)
413
+ np = round(s.gold_price * (1 + ch), 2)
414
+ s.gold_price = max(np, 50.0)
415
+ s.gold_price_history.append(s.gold_price)
416
+ s.lowest_price_seen = min(s.lowest_price_seen, s.gold_price) if s.lowest_price_seen else s.gold_price
417
+ o = self._co_market(
418
+ msg=f"(Synthetic) New quote ${s.gold_price}/oz. History (last 5): {s.gold_price_history[-5:]!s}. {self._mm_line()}",
419
+ )
420
+ return self._obs_from(o)
421
+
422
+ def _exec_buy_synthetic_common(self, action: JewelryAction, market_action: str) -> JewelryObservation:
423
+ return self._step_market_buy_and_advance(
424
+ action,
425
+ persist_db=False,
426
+ )
427
+
428
+ def _step_market_real(self, action: JewelryAction, market_action: str) -> JewelryObservation:
429
+ s = self._state
430
+ self._refresh_real_quote()
431
+ if market_action != "buy":
432
+ if s.inventory_urgent:
433
+ o = self._co_market(
434
+ msg="Urgent (inventory): you must not wait. Submit market_action=buy with a gold_qty you can afford at the current live quote, or 0.01 if testing.",
435
+ )
436
+ return self._obs_from(o)
437
+ s.market_round += 1
438
+ o = self._co_market(
439
+ msg=f"Quote refreshed. Gold ${s.gold_price}/oz from {s.gold_price_source}. {self._mm_line()} Rounds so far: {s.market_round}.",
440
+ )
441
+ return self._obs_from(o)
442
+ return self._step_market_buy_and_advance(action, persist_db=True)
443
+
444
+ def _step_market_buy_and_advance(self, action: JewelryAction, *, persist_db: bool) -> JewelryObservation:
445
+ s = self._state
446
+ market_action = "buy"
447
+ gold_qty = action.gold_qty
448
+ if gold_qty is None or float(gold_qty) <= 0:
449
+ o = self._co_market(
450
+ msg="Buy failed: set gold_qty to a positive number of troy oz.",
451
+ )
452
+ return self._obs_from(o)
453
+ gold_qty = float(gold_qty)
454
+ price = s.gold_price
455
+ total_cost = gold_qty * price
456
+ if total_cost > s.cash:
457
+ o = self._co_market(
458
+ msg=f"Not enough cash: need ${total_cost:.2f} for {gold_qty}oz @ ${price}, have ${s.cash:.2f}.",
459
+ )
460
+ return self._obs_from(o)
461
+ fund_before = s.cash
462
+ s.cash -= total_cost
463
+ s.gold_oz += gold_qty
464
+ s.phase = "warehouse"
465
+ # The bounce signal was satisfied by this purchase; clear it so the next
466
+ # warehouse failure (if any) can emit a fresh urgency.
467
+ s.inventory_urgent = False
468
+ s.need_gold_grams = None
469
+ # Only score r1 on the FIRST market visit; bounce-back buys are loop-recovery,
470
+ # not "good price hunting", so they shouldn't pay phase-1 reward again.
471
+ if s.market_reentries == 0:
472
+ self._r1 = compute_r1(s.gold_price, s.lowest_price_seen) if s.lowest_price_seen else 0.0
473
+ market_partial = self._emit("market", self._r1)
474
+ else:
475
+ self._r1 = 0.0
476
+ market_partial = self._emit("market", 0.0)
477
+ eid = getattr(s, "episode_id", None) or "unknown"
478
+ if persist_db and s.use_fifo_lots and eid != "unknown":
479
+ try:
480
+ sqlite_store.record_gold_purchase(
481
+ eid,
482
+ "GOLD",
483
+ price,
484
+ gold_qty,
485
+ round(total_cost, 2),
486
+ "BUY",
487
+ action.ai_confidence_pct,
488
+ action.ai_reasoning,
489
+ action.target_price_usd,
490
+ fund_before,
491
+ s.cash,
492
+ )
493
+ except Exception as exc: # noqa: BLE001
494
+ s.gold_price_source = f"{s.gold_price_source} | db_log_failed:{type(exc).__name__}"
495
+ o = self._co_market(
496
+ reward=market_partial,
497
+ keep_phase="warehouse",
498
+ msg=(
499
+ f"Bought {gold_qty} troy oz at ${price}/oz ($ {total_cost:.2f}). "
500
+ f"Cash ${s.cash:.2f}. {self._mm_line()} "
501
+ f"Phase reward(r1={self._r1:.4f} * w_market={s.weights[0]})={market_partial:.4f}. "
502
+ f"Cumulative={s.cumulative_reward:.4f}. Choose a product in the warehouse."
503
+ ),
504
+ )
505
+ return self._obs_from(o)
506
+
507
+ def _can_afford_smallest_buy(self) -> bool:
508
+ """
509
+ Loop guard: are we even theoretically able to buy *some* useful gold?
510
+ We require cash >= price * smallest product's gold need (i.e. enough
511
+ for at least one bracelet's worth of gold). If not, bouncing back to
512
+ market is wasteful and we should stop the loop.
513
+ """
514
+ s = self._state
515
+ if s.gold_price <= 0:
516
+ return False
517
+ cheapest_gold_oz = min(spec["gold_oz"] for spec in PRODUCT_CATALOG.values())
518
+ return s.cash >= s.gold_price * cheapest_gold_oz
519
+
520
+ def _bounce_to_market(self, choice: str, grams_needed: float, reason: str) -> JewelryObservation:
521
+ """
522
+ Inventory -> Market loop: send the agent back to the market phase to
523
+ buy more gold, with urgency flags so the market step won't allow waits.
524
+ Emits 0.0 reward; final episode score still bounded in [0, 1].
525
+ """
526
+ s = self._state
527
+ s.market_reentries += 1
528
+ s.phase = "market"
529
+ s.market_round = 0 # fresh patience counter for this re-entry
530
+ s.inventory_urgent = True
531
+ s.need_gold_grams = round(grams_needed, 4)
532
+ bounce_partial = self._emit("warehouse", 0.0)
533
+ o = self._co_market(reward=bounce_partial, keep_phase="market")
534
+ o["message"] = (
535
+ f"Inventory needs more gold to craft {choice} ({reason}). "
536
+ f"Bouncing back to MARKET (re-entry {s.market_reentries}/{s.max_market_reentries}). "
537
+ f"Need ~{grams_needed:.2f} g. inventory_urgent=True; market_action='wait' will be blocked. "
538
+ f"Cumulative={s.cumulative_reward:.4f}."
539
+ )
540
+ o["product_for_sale"] = None
541
+ o["current_offer"] = None
542
+ o["cost_basis"] = 0.0
543
+ return self._obs_from(o)
544
+
545
+ def _step_warehouse(self, action: JewelryAction) -> JewelryObservation:
546
+ s = self._state
547
+ choice = (action.product_choice or "ring").lower().strip()
548
+ if choice not in PRODUCT_CATALOG:
549
+ choice = "ring"
550
+ spec = PRODUCT_CATALOG[choice]
551
+ gold_needed_oz = spec["gold_oz"]
552
+ labor_cost = spec["labor"]
553
+ grams_needed = troy_oz_to_grams(gold_needed_oz)
554
+
555
+ has_gold_oz = s.gold_oz + 1e-8 >= gold_needed_oz
556
+ if not has_gold_oz:
557
+ # Inventory -> market loop: try to buy more gold if budget + bounces remain.
558
+ if (
559
+ s.market_reentries < s.max_market_reentries
560
+ and self._can_afford_smallest_buy()
561
+ ):
562
+ return self._bounce_to_market(
563
+ choice,
564
+ grams_needed,
565
+ reason=f"have {s.gold_oz:.4f} oz, need {gold_needed_oz:.4f} oz",
566
+ )
567
+ # Out of bounces or no money: customer leaves, episode ends with no sale.
568
+ self._r2 = 0.0
569
+ s.phase = "showroom"
570
+ o = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
571
+ why = "no bounce-backs left" if s.market_reentries >= s.max_market_reentries else "not enough cash to buy any gold"
572
+ o["message"] = (
573
+ f"Cannot craft {choice}: insufficient gold and {why}. "
574
+ f"Customer walks away. Cumulative={s.cumulative_reward:.4f}."
575
+ )
576
+ o["product_for_sale"] = None
577
+ o["current_offer"] = None
578
+ o["cost_basis"] = 0.0
579
+ return self._obs_from(o)
580
+ if s.cash < labor_cost:
581
+ self._r2 = 0.0
582
+ s.phase = "showroom"
583
+ o = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
584
+ o["message"] = (
585
+ f"Cannot craft {choice}: have gold but no cash for labor (${labor_cost:.2f}). "
586
+ f"Cumulative={s.cumulative_reward:.4f}."
587
+ )
588
+ o["product_for_sale"] = None
589
+ o["current_offer"] = None
590
+ o["cost_basis"] = 0.0
591
+ return self._obs_from(o)
592
+
593
+ s.cash -= labor_cost
594
+ eid = getattr(s, "episode_id", None) or "unknown"
595
+ if s.use_fifo_lots and s.market_mode == "real" and eid != "unknown":
596
+ ok, gold_cost, _d = sqlite_store.fifo_consume_grams(eid, grams_needed)
597
+ if not ok:
598
+ s.cash += labor_cost
599
+ self._r2 = 0.0
600
+ s.phase = "showroom"
601
+ o_ = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
602
+ o_["message"] = "FIFO: not enough gold lots in the database for this episode (or oz/gram mismatch)."
603
+ o_["product_for_sale"] = None
604
+ o_["current_offer"] = None
605
+ o_["cost_basis"] = 0.0
606
+ return self._obs_from(o_)
607
+ s.gold_oz -= gold_needed_oz
608
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
609
+ s.product_for_sale = choice
610
+ s.cost_basis = float(gold_cost) + float(labor_cost)
611
+ else:
612
+ s.gold_oz -= gold_needed_oz
613
+ s.inventory[choice] = s.inventory.get(choice, 0) + 1
614
+ s.product_for_sale = choice
615
+ s.cost_basis = s.gold_price * gold_needed_oz + labor_cost
616
+ self._r2 = compute_r2(choice, s.demand)
617
+ warehouse_partial = self._emit("warehouse", self._r2)
618
+ dmf = s.demand.get(choice, 0.5)
619
+ offer_ratio = random.uniform(OFFER_MIN_RATIO, OFFER_MAX_RATIO) + (dmf * DEMAND_OFFER_BONUS)
620
+ s.base_offer = round(s.cost_basis * offer_ratio, 2)
621
+ s.current_offer = s.base_offer
622
+ s.phase = "showroom"
623
+ s.negotiation_round = 0
624
+ o2 = {**self._co_market(keep_phase="showroom")}
625
+ o2["reward"] = warehouse_partial
626
+ o2["product_for_sale"] = choice
627
+ o2["cost_basis"] = s.cost_basis
628
+ o2["current_offer"] = s.current_offer
629
+ _cost_label = (
630
+ "FIFO (SQLite lots) gold + labor"
631
+ if s.use_fifo_lots and s.market_mode == "real" and eid != "unknown"
632
+ else "market gold + labor"
633
+ )
634
+ o2["message"] = (
635
+ f"Crafted {choice}. Cost ({_cost_label}): ${s.cost_basis:.2f}. "
636
+ f"Phase reward(r2={self._r2:.4f} * w_warehouse={s.weights[1]})={warehouse_partial:.4f}. "
637
+ f"Cumulative={s.cumulative_reward:.4f}. Customer offers ${s.current_offer:.2f}."
638
+ )
639
+ return self._obs_from(o2)
640
+
641
+ def _step_showroom(self, action: JewelryAction) -> JewelryObservation:
642
+ s = self._state
643
+ if s.product_for_sale is None:
644
+ self._r3 = 0.0
645
+ showroom_partial = self._emit("showroom", 0.0)
646
+ o3 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
647
+ o3["message"] = (
648
+ "No products to sell. Episode over. "
649
+ f"Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
650
+ f"Cumulative={s.cumulative_reward:.4f}."
651
+ )
652
+ o3["product_for_sale"] = None
653
+ o3["current_offer"] = s.current_offer
654
+ return self._obs_from(o3)
655
+ message = action.message or ""
656
+ intent = detect_intent(message)
657
+ if intent == "accept":
658
+ self._r3 = compute_r3(s.current_offer, s.cost_basis)
659
+ showroom_partial = self._emit("showroom", self._r3)
660
+ s.cash += s.current_offer
661
+ s.inventory[s.product_for_sale] -= 1
662
+ _ps = s.product_for_sale
663
+ s.product_for_sale = None
664
+ o4 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
665
+ o4["message"] = (
666
+ f"Sold {_ps} for ${s.current_offer:.2f}. "
667
+ f"Phase reward(r3={self._r3:.4f} * w_showroom={s.weights[2]})={showroom_partial:.4f}. "
668
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
669
+ )
670
+ o4["product_for_sale"] = None
671
+ o4["current_offer"] = s.current_offer
672
+ return self._obs_from(o4)
673
+ if intent == "reject":
674
+ self._r3 = 0.0
675
+ showroom_partial = self._emit("showroom", 0.0)
676
+ o5 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
677
+ o5["message"] = (
678
+ f"Rejected. Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
679
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
680
+ )
681
+ o5["product_for_sale"] = s.product_for_sale
682
+ o5["current_offer"] = s.current_offer
683
+ return self._obs_from(o5)
684
+ s.negotiation_round += 1
685
+ if s.negotiation_round >= MAX_NEGOTIATION:
686
+ self._r3 = 0.0
687
+ showroom_partial = self._emit("showroom", 0.0)
688
+ o6 = {**self._co_market(done=True, reward=showroom_partial, keep_phase="showroom")}
689
+ o6["message"] = (
690
+ f"Max negotiation rounds reached. "
691
+ f"Phase reward(r3=0 * w_showroom={s.weights[2]})=0.0000. "
692
+ f"Cumulative(final)={s.cumulative_reward:.4f}."
693
+ )
694
+ return self._obs_from(o6)
695
+ s.current_offer = round(s.current_offer * COUNTER_BUMP, 2)
696
+ o7 = {**self._co_market(keep_phase="showroom", reward=0.0, msg="")}
697
+ o7["message"] = f"Customer at ${s.current_offer:.2f} (round {s.negotiation_round})."
698
+ o7["current_offer"] = s.current_offer
699
+ o7["product_for_sale"] = s.product_for_sale
700
+ return self._obs_from(o7)
701
+
702
+ @property
703
+ def state(self) -> JewelryState:
704
+ 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,44 @@
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
+ import os
2
+ from pathlib import Path
3
+
4
+ from openenv.core.env_server import create_fastapi_app
5
+
6
+ try:
7
+ from dotenv import load_dotenv as _load_dotenv
8
+ except ImportError:
9
+ def _load_dotenv(_path: str) -> bool: # type: ignore[misc]
10
+ return False
11
+
12
+
13
+ try:
14
+ from .ShopManagerEng_environment import JewelryShopEnvironment
15
+ from ..models import JewelryAction, JewelryObservation
16
+ except ImportError:
17
+ from server.ShopManagerEng_environment import JewelryShopEnvironment
18
+ from models import JewelryAction, JewelryObservation
19
+
20
+ import uvicorn
21
+
22
+ # Load .env from this package (ShopManagerEng/.env) for FRED/keys when running the server
23
+ _env = Path(__file__).resolve().parent.parent / ".env"
24
+ if _env.is_file():
25
+ _load_dotenv(_env)
26
+
27
+ # RL trainers (TRL GRPO, etc.) open one WebSocket per parallel rollout. With
28
+ # num_generations=8 + per_device_train_batch_size>=8 you can easily need 8-16
29
+ # concurrent envs. Default max is 1, so we bump it. Override via env var
30
+ # SHOPMANAGER_MAX_CONCURRENT_ENVS for hosted Spaces with tighter budgets.
31
+ _MAX_CONCURRENT_ENVS = int(os.environ.get("SHOPMANAGER_MAX_CONCURRENT_ENVS", "16"))
32
+
33
+ app = create_fastapi_app(
34
+ JewelryShopEnvironment,
35
+ JewelryAction,
36
+ JewelryObservation,
37
+ max_concurrent_envs=_MAX_CONCURRENT_ENVS,
38
+ )
39
+
40
+ def main():
41
+ uvicorn.run(app, host="0.0.0.0", port=8000)
42
+
43
+ if __name__ == "__main__":
44
  main()
server/market_data.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Live gold (USD / troy oz) via yfinance, aligned with api_key_test.py (GC=F).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import List, Optional, Tuple
8
+
9
+
10
+ @dataclass
11
+ class GoldPriceQuote:
12
+ usd_per_oz: float
13
+ source: str
14
+
15
+
16
+ def os_gold_symbol() -> str:
17
+ import os
18
+
19
+ return (os.environ.get("SHOPMANAGER_GOLD_SYMBOL", "GC=F") or "GC=F").strip()
20
+
21
+
22
+ def _fetch_yfinance_gold() -> Tuple[float, str, List[float]]:
23
+ import yfinance as yf
24
+
25
+ sym = (os_gold_symbol() or "GC=F").strip() or "GC=F"
26
+ ticker = yf.Ticker(sym)
27
+ hist = ticker.history(period="60d", interval="1d")
28
+ if hist is None or hist.empty:
29
+ raise ValueError("No price history for gold symbol")
30
+ closes = hist["Close"].dropna().astype(float).tolist()
31
+ if not closes:
32
+ raise ValueError("Empty close series for gold")
33
+ return float(closes[-1]), f"yfinance:{sym}", [float(c) for c in closes[-30:]]
34
+
35
+
36
+ def fetch_gold_spot_usd_per_oz() -> GoldPriceQuote:
37
+ usd, src, _ = _fetch_yfinance_gold()
38
+ if usd <= 0:
39
+ raise ValueError("Invalid non-positive gold price")
40
+ return GoldPriceQuote(usd_per_oz=usd, source=src)
41
+
42
+
43
+ def recent_close_history(max_points: int = 30) -> List[float]:
44
+ try:
45
+ _, _, hist = _fetch_yfinance_gold()
46
+ except Exception:
47
+ return []
48
+ if max_points and len(hist) > max_points:
49
+ return hist[-max_points:]
50
+ return list(hist)
51
+
52
+
53
+ def last_quote_or_fallback(fallback: float) -> GoldPriceQuote:
54
+ try:
55
+ return fetch_gold_spot_usd_per_oz()
56
+ except Exception:
57
+ return GoldPriceQuote(usd_per_oz=fallback, source="fallback")
server/sqlite_store.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite persistence: market gold purchase invoices (troy oz) and per-gram lots (FIFO for warehouse).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import sqlite3
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional, Tuple
10
+
11
+ try:
12
+ from ..constants import GRAMS_PER_TROY_OZ, default_sqlite_path, get_sqlite_path
13
+ except ImportError:
14
+ # `python -c` / `import server` from the ShopManagerEng/ folder: `server` is a top module,
15
+ # so `..constants` is invalid. Parent package constants.py lives as a sibling of `server/`.
16
+ from constants import GRAMS_PER_TROY_OZ, default_sqlite_path, get_sqlite_path
17
+
18
+
19
+ def _db_path() -> str:
20
+ p = get_sqlite_path()
21
+ return p if p else default_sqlite_path()
22
+
23
+
24
+ def _connect() -> sqlite3.Connection:
25
+ path = _db_path()
26
+ from pathlib import Path
27
+
28
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
29
+ conn = sqlite3.connect(path, check_same_thread=False, timeout=30.0)
30
+ conn.row_factory = sqlite3.Row
31
+ return conn
32
+
33
+
34
+ def init_schema() -> None:
35
+ with _connect() as c:
36
+ c.executescript(
37
+ """
38
+ CREATE TABLE IF NOT EXISTS gold_purchases (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ episode_id TEXT NOT NULL,
41
+ product_name TEXT NOT NULL,
42
+ buy_price_usd REAL NOT NULL,
43
+ quantity_oz REAL NOT NULL,
44
+ cost_usd REAL NOT NULL,
45
+ ai_decision TEXT NOT NULL,
46
+ ai_confidence_pct REAL,
47
+ ai_reasoning TEXT,
48
+ target_price_usd REAL,
49
+ bought_at TEXT NOT NULL,
50
+ fund_before_usd REAL NOT NULL,
51
+ fund_after_usd REAL NOT NULL
52
+ );
53
+ CREATE INDEX IF NOT EXISTS idx_gold_purchases_episode
54
+ ON gold_purchases (episode_id);
55
+ CREATE TABLE IF NOT EXISTS gold_grams_lots (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ purchase_id INTEGER NOT NULL,
58
+ episode_id TEXT NOT NULL,
59
+ product_name TEXT NOT NULL,
60
+ buy_price_usd_per_gram REAL NOT NULL,
61
+ quantity_grams_total REAL NOT NULL,
62
+ quantity_grams_remaining REAL NOT NULL,
63
+ bought_at TEXT NOT NULL,
64
+ FOREIGN KEY (purchase_id) REFERENCES gold_purchases (id)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_lots_episode_bought
67
+ ON gold_grams_lots (episode_id, bought_at, id);
68
+ """
69
+ )
70
+ c.commit()
71
+
72
+
73
+ @dataclass
74
+ class PurchaseRow:
75
+ id: int
76
+ buy_price_usd: float
77
+ quantity_oz: float
78
+ cost_usd: float
79
+ target_price_usd: Optional[float]
80
+ fund_before_usd: float
81
+ fund_after_usd: float
82
+ bought_at: str
83
+
84
+
85
+ def record_gold_purchase(
86
+ episode_id: str,
87
+ product_name: str,
88
+ buy_price_usd: float,
89
+ quantity_oz: float,
90
+ cost_usd: float,
91
+ ai_decision: str,
92
+ ai_confidence_pct: Optional[float],
93
+ ai_reasoning: Optional[str],
94
+ target_price_usd: Optional[float],
95
+ fund_before_usd: float,
96
+ fund_after_usd: float,
97
+ ) -> Tuple[int, int]:
98
+ """
99
+ Inserts into gold_purchases and gold_grams_lots. Returns (purchase_id, lot_id).
100
+ """
101
+ init_schema()
102
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
103
+ g_total = round(quantity_oz * GRAMS_PER_TROY_OZ, 6)
104
+ ppg = round(buy_price_usd / GRAMS_PER_TROY_OZ, 8) if GRAMS_PER_TROY_OZ > 0 else 0.0
105
+ ai_r = (ai_reasoning or "").strip() or None
106
+ with _connect() as c:
107
+ cur = c.execute(
108
+ """
109
+ INSERT INTO gold_purchases (
110
+ episode_id, product_name, buy_price_usd, quantity_oz, cost_usd,
111
+ ai_decision, ai_confidence_pct, ai_reasoning, target_price_usd,
112
+ bought_at, fund_before_usd, fund_after_usd
113
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
114
+ """,
115
+ (
116
+ episode_id,
117
+ product_name,
118
+ buy_price_usd,
119
+ quantity_oz,
120
+ cost_usd,
121
+ ai_decision,
122
+ ai_confidence_pct,
123
+ ai_r,
124
+ target_price_usd,
125
+ now,
126
+ fund_before_usd,
127
+ fund_after_usd,
128
+ ),
129
+ )
130
+ purchase_id = int(cur.lastrowid)
131
+ cur2 = c.execute(
132
+ """
133
+ INSERT INTO gold_grams_lots (
134
+ purchase_id, episode_id, product_name, buy_price_usd_per_gram,
135
+ quantity_grams_total, quantity_grams_remaining, bought_at
136
+ ) VALUES (?,?,?,?,?,?,?)
137
+ """,
138
+ (
139
+ purchase_id,
140
+ episode_id,
141
+ product_name,
142
+ ppg,
143
+ g_total,
144
+ g_total,
145
+ now,
146
+ ),
147
+ )
148
+ lot_id = int(cur2.lastrowid)
149
+ c.commit()
150
+ return purchase_id, lot_id
151
+
152
+
153
+ def fifo_consume_grams(
154
+ episode_id: str, grams_needed: float
155
+ ) -> Tuple[bool, float, List[dict]]:
156
+ """
157
+ Uses oldest lots first. Returns (ok, total_usd_cost, details).
158
+ """
159
+ if grams_needed <= 0:
160
+ return True, 0.0, []
161
+ init_schema()
162
+ rem = float(grams_needed)
163
+ total_usd = 0.0
164
+ details: List[dict] = []
165
+ with _connect() as c:
166
+ cur = c.execute(
167
+ """
168
+ SELECT id, quantity_grams_remaining, buy_price_usd_per_gram
169
+ FROM gold_grams_lots
170
+ WHERE episode_id = ? AND quantity_grams_remaining > 0.0000001
171
+ ORDER BY bought_at ASC, id ASC
172
+ """,
173
+ (episode_id,),
174
+ )
175
+ rows = cur.fetchall()
176
+ for row in rows:
177
+ if rem <= 1e-9:
178
+ break
179
+ lot_id = int(row["id"])
180
+ qrem = float(row["quantity_grams_remaining"])
181
+ ppg = float(row["buy_price_usd_per_gram"])
182
+ take = min(qrem, rem)
183
+ cost = take * ppg
184
+ new_rem = round(qrem - take, 6)
185
+ c.execute(
186
+ "UPDATE gold_grams_lots SET quantity_grams_remaining = ? WHERE id = ?",
187
+ (new_rem, lot_id),
188
+ )
189
+ rem -= take
190
+ total_usd += cost
191
+ details.append(
192
+ {
193
+ "lot_id": lot_id,
194
+ "grams": take,
195
+ "cost_usd": round(cost, 4),
196
+ }
197
+ )
198
+ c.commit()
199
+ if rem > 1e-5:
200
+ return False, 0.0, []
201
+ return True, round(total_usd, 4), details
202
+
203
+
204
+ def ensure_schema_once() -> None:
205
+ try:
206
+ init_schema()
207
+ except Exception:
208
+ pass
uv.lock CHANGED
The diff for this file is too large to render. See raw diff