hard007ik's picture
Add GRPO training scaffolding (Module 5 rollout_func pattern) + plot artifacts
caea08d
Raw
History Blame Contribute Delete
7.5 kB
"""System prompt + per-turn user-prompt builder for the JewelryShop env.
Logic mirrors `inference.py`'s `build_user_prompt` so that whatever the model
saw during inference evaluation it also sees during training. Kept as a plain
sync function (no asyncio) so it composes cleanly with TRL's rollout_func.
"""
from __future__ import annotations
import math
import textwrap
from typing import List
SYSTEM_PROMPT = textwrap.dedent(
"""
You are an expert agent running a jewelry shop. The episode runs in 3 phases
and may loop back to MARKET if the warehouse runs out of gold. The episode
reward is the SUM of per-step partial rewards across the whole episode and
is bounded in [0, 1]. Each task weights the phases differently:
- market_timing -> phase 1 = 0.6, phase 2 = 0.2, phase 3 = 0.2
- demand_crafter -> phase 1 = 0.2, phase 2 = 0.6, phase 3 = 0.2
- profit_negotiator -> phase 1 = 0.2, phase 2 = 0.2, phase 3 = 0.6
## Phase 1: MARKET (buy / wait)
Two modes:
- synthetic mode: gold price moves randomly each WAIT step within a round cap.
- real mode: gold price comes from a live source (yfinance: GC=F),
no round cap; WAIT just refreshes the live quote.
Coordination from the warehouse:
- inventory_urgent=True / cannot_wait=True means you MUST buy now;
WAIT will be blocked. Submit "buy X.XX" with an affordable troy-oz qty.
Behavior:
- If you can wait, observe the price trend in gold_price_history before buying.
- Reserve cash for labor (ring=$200, necklace=$300, bracelet=$100).
- Respond: "buy X.XX" (troy oz of gold) or "wait".
## Phase 2: WAREHOUSE (choose product)
You see two demand fields:
- demand : the TRUE per-product demand for THIS episode (ground truth).
- demand_forecast : a NOISY signal you can also lean on for planning.
Products: ring (1oz + $200), necklace (2oz + $300), bracelet (0.5oz + $100).
If you don't have enough gold to craft your choice, the env may BOUNCE you back
to MARKET to buy more (up to max_market_reentries times). After max bounces or
when truly broke, the customer leaves and the episode ends.
Respond: "ring", "necklace", or "bracelet".
## Phase 3: SHOWROOM (negotiate)
The customer makes an offer; if you counter, they raise it ~5% per round,
up to 5 rounds. After 5 rounds with no acceptance, the customer leaves
(no phase-3 reward). Reject also gives 0 phase-3 reward.
Respond: "I accept" or a counter like "How about $X?". NEVER explicitly reject.
CRITICAL: Respond with ONLY the action value. No explanations.
"""
).strip()
def build_user_prompt(step: int, obs, last_reward: float, history: List[str]) -> str:
"""Format a single observation into a user prompt the LLM sees this turn.
Mirrors inference.py:build_user_prompt so the model sees the same input shape
during training and at evaluation time.
"""
history_block = "\n".join(history[-4:]) if history else "None"
if obs.phase == "market":
prices = getattr(obs, "gold_price_history", []) or []
trend = ""
if len(prices) >= 2:
if prices[-1] < prices[-2]:
trend = "FALLING (might keep dropping, consider waiting)"
else:
trend = "RISING (buy now before it gets more expensive)"
if getattr(obs, "cannot_wait", False):
trend = (
"URGENT: inventory needs gold now — you cannot wait; buy at the current "
"live quote with an affordable gold_qty (troy oz)."
)
max_rounds = getattr(obs, "max_market_rounds", None)
rounds_left = (max_rounds - getattr(obs, "market_round", 0)) if max_rounds else None
reserve = 300.0
gold_price = getattr(obs, "gold_price", 0) or 0
cash = getattr(obs, "cash", 0) or 0
if gold_price > 0:
raw_qty = (cash - reserve) / gold_price
suggested_qty = max(math.floor(raw_qty * 100) / 100, 0.01)
else:
suggested_qty = 1.0
rl = "unlimited" if rounds_left is None else str(rounds_left)
phase_hint = (
f"Price: ${gold_price}/oz ({getattr(obs, 'gold_price_source', '') or 'n/a'}). "
f"Price history: {prices}. Trend: {trend}. "
f"Rounds / waits so far: {getattr(obs, 'market_round', 0)}; cap: {rl}. "
f"Gold on hand: {getattr(obs, 'gold_oz', 0)} troy oz "
f"(~{getattr(obs, 'gold_grams', 0):.2f} g). "
f"If buying, suggested qty: {suggested_qty} oz (reserves $300 for labor). "
f"Respond: 'buy {suggested_qty}' or 'wait'"
)
elif obs.phase == "warehouse":
demand = getattr(obs, "demand", {}) or {}
forecast = getattr(obs, "demand_forecast", {}) or {}
best_product = max(demand, key=demand.get) if demand else "ring"
phase_hint = (
f"Demand (episode): ring={demand.get('ring', 0):.0%}, "
f"necklace={demand.get('necklace', 0):.0%}, "
f"bracelet={demand.get('bracelet', 0):.0%}. "
f"Forecast (noisy): ring={forecast.get('ring', 0):.0%}, "
f"necklace={forecast.get('necklace', 0):.0%}, "
f"bracelet={forecast.get('bracelet', 0):.0%}. "
f"Highest demand: {best_product}. "
f"You have {getattr(obs, 'gold_oz', 0)}oz gold and "
f"${getattr(obs, 'cash', 0)} cash. "
f"Respond with EXACTLY: {best_product}"
)
elif obs.phase == "showroom":
cost_basis = getattr(obs, "cost_basis", 0) or 0
current_offer = getattr(obs, "current_offer", 0) or 0
negotiation_round = getattr(obs, "negotiation_round", 0) or 0
margin = ""
if current_offer and cost_basis > 0:
margin_pct = ((current_offer - cost_basis) / cost_basis) * 100
margin = f"Margin: {margin_pct:+.1f}%. "
should_accept = negotiation_round >= 4 or (
current_offer and cost_basis > 0 and current_offer > cost_basis * 1.3
)
if should_accept:
phase_hint = (
f"Cost: ${cost_basis}. Offer: ${current_offer}. {margin}"
f"Round {negotiation_round}/5. "
f"Respond with EXACTLY: I accept"
)
else:
counter_msgs = [
"I need a better price for this quality piece",
"That's too low, this craftsmanship deserves more",
f"How about ${round(cost_basis * 1.4, 2)}?",
f"I can't go below ${round(cost_basis * 1.3, 2)}",
]
msg = counter_msgs[min(negotiation_round, len(counter_msgs) - 1)]
phase_hint = (
f"Cost: ${cost_basis}. Offer: ${current_offer}. {margin}"
f"Round {negotiation_round}/5. "
f"DO NOT ACCEPT. Counter-offer. "
f"Respond with EXACTLY: {msg}"
)
else:
phase_hint = ""
return textwrap.dedent(
f"""
Step: {step} | Phase: {obs.phase} | Last reward: {last_reward:.2f}
Cash: ${getattr(obs, 'cash', 0)} | Gold: {getattr(obs, 'gold_oz', 0)}oz | Rings: {getattr(obs, 'inventory', {})}
Gold Price: ${getattr(obs, 'gold_price', 0)}/oz
Env Message: {getattr(obs, 'message', '')}
{phase_hint}
History: {history_block}
"""
).strip()