Neha Singh commited on
Commit ·
c5f2039
1
Parent(s): d6e8cd0
Add OpenEnv server integration and deployment checks
Browse files- .dockerignore +8 -0
- README.md +41 -0
- client.py +49 -0
- openenv_models.py +90 -0
- product_generator.py +20 -8
- requirements.txt +1 -0
- run_openenv.py +350 -0
- server/app.py +48 -248
- server/shopping_environment.py +414 -0
- task_config.py +39 -0
- tests/test_openenv_requirements.py +161 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
OpenEnv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.log
|
| 6 |
+
.git/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
README.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# 🧠 Personality-Driven Shopping Agent — OpenEnv Environment
|
| 2 |
|
| 3 |
A **real-world shopping simulation** where an AI agent must learn a specific user's shopping personality from persistent memory and make purchase decisions aligned with their style — not just pick the "globally best" product.
|
|
@@ -174,6 +189,32 @@ docker run -p 7860:7860 shopping-agent-env:latest
|
|
| 174 |
curl http://localhost:7860/health
|
| 175 |
```
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
### API Endpoints
|
| 178 |
|
| 179 |
| Endpoint | Method | Description |
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Shopping Agent OpenEnv
|
| 3 |
+
emoji: 🛍️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- reinforcement-learning
|
| 12 |
+
- fastapi
|
| 13 |
+
license: bsd-3-clause
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
# 🧠 Personality-Driven Shopping Agent — OpenEnv Environment
|
| 17 |
|
| 18 |
A **real-world shopping simulation** where an AI agent must learn a specific user's shopping personality from persistent memory and make purchase decisions aligned with their style — not just pick the "globally best" product.
|
|
|
|
| 189 |
curl http://localhost:7860/health
|
| 190 |
```
|
| 191 |
|
| 192 |
+
Verified locally:
|
| 193 |
+
- `docker build -t shopping-agent-env:local .`
|
| 194 |
+
- `docker run -p 7860:7860 shopping-agent-env:local`
|
| 195 |
+
- `GET /health` returns `{"status":"healthy"}`
|
| 196 |
+
|
| 197 |
+
### Hugging Face Space Deployment
|
| 198 |
+
|
| 199 |
+
This repository is ready to run as a Docker-based Hugging Face Space and is tagged for OpenEnv in the README frontmatter above.
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
# Create a Docker Space, then push this repo to it
|
| 203 |
+
git remote add hf https://huggingface.co/spaces/<username>/shopping-agent-openenv
|
| 204 |
+
git push hf main
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
Space settings:
|
| 208 |
+
- SDK: `Docker`
|
| 209 |
+
- App port: `7860`
|
| 210 |
+
- Tag: `openenv`
|
| 211 |
+
|
| 212 |
+
After deployment, verify:
|
| 213 |
+
|
| 214 |
+
```bash
|
| 215 |
+
curl https://<username>-shopping-agent-openenv.hf.space/health
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
### API Endpoints
|
| 219 |
|
| 220 |
| Endpoint | Method | Description |
|
client.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenEnv Client for the Shopping Agent Environment.
|
| 3 |
+
|
| 4 |
+
Connects to the shopping environment server via WebSocket (OpenEnv protocol).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any, Dict
|
| 8 |
+
|
| 9 |
+
from openenv.core.env_client import EnvClient
|
| 10 |
+
from openenv.core.client_types import StepResult
|
| 11 |
+
|
| 12 |
+
from openenv_models import ShoppingAction, ShoppingObservation, ShoppingState
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ShoppingEnvClient(EnvClient[ShoppingAction, ShoppingObservation, ShoppingState]):
|
| 16 |
+
"""
|
| 17 |
+
WebSocket client for the Shopping Agent Environment.
|
| 18 |
+
|
| 19 |
+
Usage (async):
|
| 20 |
+
async with ShoppingEnvClient(base_url="http://localhost:8000") as env:
|
| 21 |
+
result = await env.reset(query="lip balm", product_count=4)
|
| 22 |
+
result = await env.step(ShoppingAction(action_type="view_item", item_ids=["p1"]))
|
| 23 |
+
|
| 24 |
+
Usage (sync):
|
| 25 |
+
with ShoppingEnvClient(base_url="http://localhost:8000").sync() as env:
|
| 26 |
+
result = env.reset(query="earbuds")
|
| 27 |
+
result = env.step(ShoppingAction(action_type="buy", item_ids=["p1"]))
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def _step_payload(self, action: ShoppingAction) -> Dict[str, Any]:
|
| 31 |
+
"""Convert ShoppingAction to the JSON payload expected by the server."""
|
| 32 |
+
return action.model_dump(exclude_none=True)
|
| 33 |
+
|
| 34 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ShoppingObservation]:
|
| 35 |
+
"""Parse server response into StepResult[ShoppingObservation]."""
|
| 36 |
+
obs_data = payload.get("observation", payload)
|
| 37 |
+
reward = payload.get("reward") or obs_data.get("reward", 0.0)
|
| 38 |
+
done = payload.get("done", obs_data.get("done", False))
|
| 39 |
+
|
| 40 |
+
observation = ShoppingObservation(**obs_data)
|
| 41 |
+
return StepResult(
|
| 42 |
+
observation=observation,
|
| 43 |
+
reward=reward,
|
| 44 |
+
done=done,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def _parse_state(self, payload: Dict[str, Any]) -> ShoppingState:
|
| 48 |
+
"""Parse server state response into ShoppingState."""
|
| 49 |
+
return ShoppingState(**payload)
|
openenv_models.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenEnv-compatible models for the Shopping Agent Environment.
|
| 3 |
+
|
| 4 |
+
These models inherit from openenv's base Action/Observation/State types
|
| 5 |
+
so they can be used with create_app() and EnvClient.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import Field
|
| 11 |
+
|
| 12 |
+
from openenv.core.env_server.types import (
|
| 13 |
+
Action as OpenEnvAction,
|
| 14 |
+
Observation as OpenEnvObservation,
|
| 15 |
+
State as OpenEnvState,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Action: what the agent sends (OpenEnv-compatible)
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
class ShoppingAction(OpenEnvAction):
|
| 23 |
+
"""
|
| 24 |
+
An action the agent can take in the shopping environment.
|
| 25 |
+
|
| 26 |
+
Supported action_types:
|
| 27 |
+
search, view_item, compare, shortlist, add_to_cart,
|
| 28 |
+
remove_from_cart, buy, skip, ask_more
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
action_type: str = Field(
|
| 32 |
+
...,
|
| 33 |
+
description="One of: search, view_item, compare, shortlist, "
|
| 34 |
+
"add_to_cart, remove_from_cart, buy, skip, ask_more",
|
| 35 |
+
)
|
| 36 |
+
item_ids: List[str] = Field(
|
| 37 |
+
default_factory=list,
|
| 38 |
+
description="Product IDs involved in the action",
|
| 39 |
+
)
|
| 40 |
+
search_query: Optional[str] = Field(
|
| 41 |
+
default=None,
|
| 42 |
+
description="Query string when action_type is 'search'",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
model_config = {"extra": "allow"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Observation: what the environment returns (OpenEnv-compatible)
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
class ShoppingObservation(OpenEnvObservation):
|
| 52 |
+
"""What the agent observes after each step — OpenEnv Observation subclass."""
|
| 53 |
+
|
| 54 |
+
query: str = Field("", description="Current search query")
|
| 55 |
+
category: str = Field("", description="Current product category")
|
| 56 |
+
candidate_products: List[Dict[str, Any]] = Field(
|
| 57 |
+
default_factory=list,
|
| 58 |
+
description="Products currently visible to the agent",
|
| 59 |
+
)
|
| 60 |
+
memory_profile: Dict[str, Any] = Field(
|
| 61 |
+
default_factory=dict,
|
| 62 |
+
description="User personality traits and semantic memory",
|
| 63 |
+
)
|
| 64 |
+
cart: List[str] = Field(default_factory=list, description="Product IDs in cart")
|
| 65 |
+
shortlisted: List[str] = Field(default_factory=list, description="Shortlisted IDs")
|
| 66 |
+
viewed_items: List[str] = Field(default_factory=list, description="Viewed IDs")
|
| 67 |
+
compared_sets: List[List[str]] = Field(
|
| 68 |
+
default_factory=list, description="Compared product ID sets"
|
| 69 |
+
)
|
| 70 |
+
history_summary: str = Field("", description="Recent actions summary")
|
| 71 |
+
feedback: str = Field("", description="Environment feedback")
|
| 72 |
+
step_number: int = Field(0, description="Current step")
|
| 73 |
+
max_steps: int = Field(15, description="Max steps")
|
| 74 |
+
|
| 75 |
+
model_config = {"extra": "allow"}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# State: episode metadata (OpenEnv-compatible)
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
class ShoppingState(OpenEnvState):
|
| 82 |
+
"""Episode-level metadata — OpenEnv State subclass."""
|
| 83 |
+
|
| 84 |
+
task_name: str = ""
|
| 85 |
+
difficulty: str = ""
|
| 86 |
+
done: bool = False
|
| 87 |
+
cumulative_reward: float = 0.0
|
| 88 |
+
cart: List[str] = Field(default_factory=list)
|
| 89 |
+
shortlisted: List[str] = Field(default_factory=list)
|
| 90 |
+
product_query: str = Field(default="", description="Current product query")
|
product_generator.py
CHANGED
|
@@ -251,7 +251,7 @@ def _get_template(category: str) -> Dict[str, Any]:
|
|
| 251 |
return DEFAULT_TEMPLATE
|
| 252 |
|
| 253 |
|
| 254 |
-
def _make_seller(brand: str, seller_type: str) -> str:
|
| 255 |
"""Generate a seller name based on the brand and seller type."""
|
| 256 |
if seller_type == "official":
|
| 257 |
return f"{brand} Official"
|
|
@@ -262,7 +262,7 @@ def _make_seller(brand: str, seller_type: str) -> str:
|
|
| 262 |
"Unknown Marketplace", "Quick Deals", "Flash Sales",
|
| 263 |
"Random Seller", "Budget Bazaar",
|
| 264 |
]
|
| 265 |
-
return
|
| 266 |
|
| 267 |
|
| 268 |
def _deterministic_seed(query: str, salt: str = "") -> int:
|
|
@@ -297,8 +297,15 @@ def generate_products(
|
|
| 297 |
price_min, price_max = template["price_range"]
|
| 298 |
price_span = price_max - price_min
|
| 299 |
|
| 300 |
-
#
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
products: List[Dict[str, Any]] = []
|
| 303 |
|
| 304 |
# Clean up the query for product naming
|
|
@@ -312,13 +319,16 @@ def generate_products(
|
|
| 312 |
# Calculate price
|
| 313 |
base_price = price_min + (price_span * arch["price_pct"])
|
| 314 |
price_jitter = rng.uniform(-0.05, 0.05) * price_span
|
| 315 |
-
|
|
|
|
|
|
|
| 316 |
|
| 317 |
# Rating
|
| 318 |
-
rating = round(rng.uniform(*arch["rating"]), 1)
|
| 319 |
|
| 320 |
# Reviews
|
| 321 |
-
|
|
|
|
| 322 |
|
| 323 |
# Features
|
| 324 |
n_features = rng.randint(*arch["feature_count"])
|
|
@@ -326,12 +336,14 @@ def generate_products(
|
|
| 326 |
features = features_pool[:n_features]
|
| 327 |
|
| 328 |
# Seller
|
| 329 |
-
seller = _make_seller(brand, arch["seller_type"])
|
| 330 |
|
| 331 |
# Build product name
|
| 332 |
name = f"{arch['name_prefix']} {category_name}"
|
| 333 |
if arch["key"] == "discounted":
|
| 334 |
name = f"{category_name} (Was ${round(price * 1.8, 2)}, Now Sale!)"
|
|
|
|
|
|
|
| 335 |
|
| 336 |
product = {
|
| 337 |
"id": f"p{i + 1}",
|
|
|
|
| 251 |
return DEFAULT_TEMPLATE
|
| 252 |
|
| 253 |
|
| 254 |
+
def _make_seller(brand: str, seller_type: str, rng: random.Random) -> str:
|
| 255 |
"""Generate a seller name based on the brand and seller type."""
|
| 256 |
if seller_type == "official":
|
| 257 |
return f"{brand} Official"
|
|
|
|
| 262 |
"Unknown Marketplace", "Quick Deals", "Flash Sales",
|
| 263 |
"Random Seller", "Budget Bazaar",
|
| 264 |
]
|
| 265 |
+
return rng.choice(unknown_sellers)
|
| 266 |
|
| 267 |
|
| 268 |
def _deterministic_seed(query: str, salt: str = "") -> int:
|
|
|
|
| 297 |
price_min, price_max = template["price_range"]
|
| 298 |
price_span = price_max - price_min
|
| 299 |
|
| 300 |
+
# Reuse the base archetypes with deterministic variants when a task
|
| 301 |
+
# requests more products than the archetype library size.
|
| 302 |
+
archetypes: List[Dict[str, Any]] = []
|
| 303 |
+
for i in range(count):
|
| 304 |
+
base = dict(ARCHETYPES[i % len(ARCHETYPES)])
|
| 305 |
+
variant_index = i // len(ARCHETYPES)
|
| 306 |
+
if variant_index:
|
| 307 |
+
base["variant_index"] = variant_index
|
| 308 |
+
archetypes.append(base)
|
| 309 |
products: List[Dict[str, Any]] = []
|
| 310 |
|
| 311 |
# Clean up the query for product naming
|
|
|
|
| 319 |
# Calculate price
|
| 320 |
base_price = price_min + (price_span * arch["price_pct"])
|
| 321 |
price_jitter = rng.uniform(-0.05, 0.05) * price_span
|
| 322 |
+
variant_index = arch.get("variant_index", 0)
|
| 323 |
+
variant_multiplier = 1.0 + (0.04 * variant_index)
|
| 324 |
+
price = round(max(price_min, (base_price + price_jitter) * variant_multiplier), 2)
|
| 325 |
|
| 326 |
# Rating
|
| 327 |
+
rating = round(max(1.0, min(5.0, rng.uniform(*arch["rating"]) - (0.1 * variant_index))), 1)
|
| 328 |
|
| 329 |
# Reviews
|
| 330 |
+
review_floor, review_ceiling = arch["reviews"]
|
| 331 |
+
reviews = rng.randint(review_floor, max(review_floor, int(review_ceiling * (0.9 ** variant_index))))
|
| 332 |
|
| 333 |
# Features
|
| 334 |
n_features = rng.randint(*arch["feature_count"])
|
|
|
|
| 336 |
features = features_pool[:n_features]
|
| 337 |
|
| 338 |
# Seller
|
| 339 |
+
seller = _make_seller(brand, arch["seller_type"], rng)
|
| 340 |
|
| 341 |
# Build product name
|
| 342 |
name = f"{arch['name_prefix']} {category_name}"
|
| 343 |
if arch["key"] == "discounted":
|
| 344 |
name = f"{category_name} (Was ${round(price * 1.8, 2)}, Now Sale!)"
|
| 345 |
+
elif variant_index:
|
| 346 |
+
name = f"{name} {variant_index + 1}"
|
| 347 |
|
| 348 |
product = {
|
| 349 |
"id": f"p{i + 1}",
|
requirements.txt
CHANGED
|
@@ -4,3 +4,4 @@ pydantic>=2.0.0
|
|
| 4 |
python-dotenv>=1.0.0
|
| 5 |
openai>=1.0.0
|
| 6 |
pyyaml>=6.0
|
|
|
|
|
|
| 4 |
python-dotenv>=1.0.0
|
| 5 |
openai>=1.0.0
|
| 6 |
pyyaml>=6.0
|
| 7 |
+
openenv-core>=0.2.3
|
run_openenv.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenEnv Inference Runner — Personality-Driven Shopping Agent
|
| 3 |
+
=============================================================
|
| 4 |
+
Runs the shopping agent through the OpenEnv protocol:
|
| 5 |
+
1. Starts the server (ShoppingEnvironment via create_app)
|
| 6 |
+
2. Connects via ShoppingEnvClient (WebSocket)
|
| 7 |
+
3. Runs 3 tasks (easy → medium → hard) using an LLM or fallback heuristic
|
| 8 |
+
4. Prints results in OpenEnv STDOUT format
|
| 9 |
+
|
| 10 |
+
STDOUT FORMAT:
|
| 11 |
+
[START] task=<task_name> env=<benchmark> model=<model_name>
|
| 12 |
+
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 13 |
+
[END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python run_openenv.py
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import textwrap
|
| 24 |
+
import time
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import List, Optional
|
| 27 |
+
|
| 28 |
+
from dotenv import load_dotenv
|
| 29 |
+
|
| 30 |
+
# Ensure project root is importable
|
| 31 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 32 |
+
|
| 33 |
+
load_dotenv()
|
| 34 |
+
|
| 35 |
+
from openai import OpenAI
|
| 36 |
+
from openenv_models import ShoppingAction
|
| 37 |
+
from server.shopping_environment import ShoppingEnvironment
|
| 38 |
+
from memory_engine import load_profile
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Configuration
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
| 44 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 45 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 46 |
+
|
| 47 |
+
BENCHMARK = "shopping_agent"
|
| 48 |
+
TEMPERATURE = 0.7
|
| 49 |
+
MAX_TOKENS = 300
|
| 50 |
+
|
| 51 |
+
TASKS = [
|
| 52 |
+
{"name": "quick_pick", "query": "lip balm", "difficulty": "easy", "max_steps": 8, "product_count": 4},
|
| 53 |
+
{"name": "smart_shop", "query": "earbuds", "difficulty": "medium", "max_steps": 12, "product_count": 8},
|
| 54 |
+
{"name": "expert_deal", "query": "laptop backpack", "difficulty": "hard", "max_steps": 15, "product_count": 12},
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Logging (strict OpenEnv format)
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 62 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 66 |
+
error_val = error if error else "null"
|
| 67 |
+
done_val = str(done).lower()
|
| 68 |
+
print(
|
| 69 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 70 |
+
f"done={done_val} error={error_val}",
|
| 71 |
+
flush=True,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 76 |
+
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 77 |
+
print(
|
| 78 |
+
f"[END] success={str(success).lower()} steps={steps} "
|
| 79 |
+
f"score={score:.2f} rewards={rewards_str}",
|
| 80 |
+
flush=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# System prompt builder
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
def build_system_prompt(profile_text: str) -> str:
|
| 88 |
+
return textwrap.dedent(f"""\
|
| 89 |
+
You are a shopping agent acting on behalf of a specific user.
|
| 90 |
+
Your goal is to buy the product that THIS USER would choose.
|
| 91 |
+
|
| 92 |
+
{profile_text}
|
| 93 |
+
|
| 94 |
+
## Your Task
|
| 95 |
+
Given the observation (products, personality profile, cart, history),
|
| 96 |
+
decide the BEST next action for THIS user. Think about:
|
| 97 |
+
- Would this user research more before buying? (check research_depth)
|
| 98 |
+
- Would this user pick the cheapest option? (check price_sensitivity)
|
| 99 |
+
- Would this user avoid risky products? (check risk_aversion)
|
| 100 |
+
- Would this user trust this brand? (check brand_trust)
|
| 101 |
+
- Would this user care about reviews? (check review_dependence)
|
| 102 |
+
|
| 103 |
+
Reply with **only valid JSON**:
|
| 104 |
+
|
| 105 |
+
{{
|
| 106 |
+
"action_type": "search" | "view_item" | "compare" | "shortlist" |
|
| 107 |
+
"add_to_cart" | "remove_from_cart" | "buy" | "skip" | "ask_more",
|
| 108 |
+
"item_ids": ["p1", ...],
|
| 109 |
+
"search_query": "query text"
|
| 110 |
+
}}
|
| 111 |
+
|
| 112 |
+
Rules:
|
| 113 |
+
- research_depth > 0.7: view 3+ items and compare before buying.
|
| 114 |
+
- risk_aversion > 0.7: avoid products with <50 reviews or unknown sellers.
|
| 115 |
+
- Do NOT output anything besides the JSON object.
|
| 116 |
+
""").strip()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
# LLM interaction
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
def build_user_prompt(step: int, obs_dict: dict, last_reward: float, history: List[str]) -> str:
|
| 123 |
+
history_block = "\n".join(history[-5:]) if history else "None"
|
| 124 |
+
mem_profile = obs_dict.get("memory_profile", {})
|
| 125 |
+
obs_summary = {
|
| 126 |
+
"query": obs_dict.get("query", ""),
|
| 127 |
+
"personality_preferences": {
|
| 128 |
+
k: v for k, v in mem_profile.items()
|
| 129 |
+
if isinstance(v, (int, float)) and 0 <= v <= 1
|
| 130 |
+
},
|
| 131 |
+
"personality_conclusions": mem_profile.get("semantic_conclusions", [])[:4],
|
| 132 |
+
"products": [
|
| 133 |
+
{
|
| 134 |
+
"id": p["id"],
|
| 135 |
+
"name": p["name"],
|
| 136 |
+
"price": p["price"],
|
| 137 |
+
"rating": p["rating"],
|
| 138 |
+
"brand": p["brand"],
|
| 139 |
+
"reviews": p["reviews"],
|
| 140 |
+
"refundable": p["refundable"],
|
| 141 |
+
"seller": p.get("seller", ""),
|
| 142 |
+
}
|
| 143 |
+
for p in obs_dict.get("candidate_products", [])
|
| 144 |
+
],
|
| 145 |
+
"cart": obs_dict.get("cart", []),
|
| 146 |
+
"shortlisted": obs_dict.get("shortlisted", []),
|
| 147 |
+
"viewed": obs_dict.get("viewed_items", []),
|
| 148 |
+
"feedback": obs_dict.get("feedback", ""),
|
| 149 |
+
"step": obs_dict.get("step_number", step),
|
| 150 |
+
"max_steps": obs_dict.get("max_steps", 15),
|
| 151 |
+
}
|
| 152 |
+
return textwrap.dedent(f"""\
|
| 153 |
+
Step {step} | Last reward: {last_reward:.2f}
|
| 154 |
+
|
| 155 |
+
Observation:
|
| 156 |
+
{json.dumps(obs_summary, indent=2)}
|
| 157 |
+
|
| 158 |
+
Recent history:
|
| 159 |
+
{history_block}
|
| 160 |
+
|
| 161 |
+
Reply with the next action as JSON.""")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def get_agent_action(
|
| 165 |
+
client: OpenAI,
|
| 166 |
+
system_prompt: str,
|
| 167 |
+
step: int,
|
| 168 |
+
obs_dict: dict,
|
| 169 |
+
last_reward: float,
|
| 170 |
+
history: List[str],
|
| 171 |
+
) -> ShoppingAction:
|
| 172 |
+
user_prompt = build_user_prompt(step, obs_dict, last_reward, history)
|
| 173 |
+
try:
|
| 174 |
+
completion = client.chat.completions.create(
|
| 175 |
+
model=MODEL_NAME,
|
| 176 |
+
messages=[
|
| 177 |
+
{"role": "system", "content": system_prompt},
|
| 178 |
+
{"role": "user", "content": user_prompt},
|
| 179 |
+
],
|
| 180 |
+
temperature=TEMPERATURE,
|
| 181 |
+
max_tokens=MAX_TOKENS,
|
| 182 |
+
stream=False,
|
| 183 |
+
)
|
| 184 |
+
text = (completion.choices[0].message.content or "").strip()
|
| 185 |
+
if text.startswith("```"):
|
| 186 |
+
text = text.split("\n", 1)[-1]
|
| 187 |
+
if text.endswith("```"):
|
| 188 |
+
text = text.rsplit("```", 1)[0]
|
| 189 |
+
text = text.strip()
|
| 190 |
+
data = json.loads(text)
|
| 191 |
+
return ShoppingAction(**data)
|
| 192 |
+
except Exception as exc:
|
| 193 |
+
print(f"[DEBUG] LLM parse error: {exc}", flush=True)
|
| 194 |
+
return _fallback_action(obs_dict)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _fallback_action(obs_dict: dict) -> ShoppingAction:
|
| 198 |
+
"""Smart heuristic fallback when the LLM is unavailable."""
|
| 199 |
+
cart = obs_dict.get("cart", [])
|
| 200 |
+
shortlisted = obs_dict.get("shortlisted", [])
|
| 201 |
+
viewed = obs_dict.get("viewed_items", [])
|
| 202 |
+
compared = obs_dict.get("compared_sets", [])
|
| 203 |
+
products = obs_dict.get("candidate_products", [])
|
| 204 |
+
|
| 205 |
+
if cart:
|
| 206 |
+
return ShoppingAction(action_type="buy", item_ids=list(cart))
|
| 207 |
+
if shortlisted:
|
| 208 |
+
return ShoppingAction(action_type="add_to_cart", item_ids=[shortlisted[0]])
|
| 209 |
+
if compared:
|
| 210 |
+
flat = []
|
| 211 |
+
for s in compared:
|
| 212 |
+
flat.extend(s)
|
| 213 |
+
unique = list(dict.fromkeys(flat))[:2]
|
| 214 |
+
return ShoppingAction(action_type="shortlist", item_ids=unique)
|
| 215 |
+
if len(viewed) >= 2:
|
| 216 |
+
return ShoppingAction(action_type="compare", item_ids=viewed[:3])
|
| 217 |
+
mid_range = [
|
| 218 |
+
p["id"] for p in products
|
| 219 |
+
if p.get("price", 999) < 150
|
| 220 |
+
and p.get("rating", 0) >= 3.5
|
| 221 |
+
and p.get("reviews", 0) >= 50
|
| 222 |
+
][:3]
|
| 223 |
+
if mid_range:
|
| 224 |
+
return ShoppingAction(action_type="view_item", item_ids=mid_range)
|
| 225 |
+
if products:
|
| 226 |
+
return ShoppingAction(action_type="view_item", item_ids=[products[0]["id"]])
|
| 227 |
+
return ShoppingAction(action_type="skip")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
+
# Run one episode DIRECTLY against the OpenEnv Environment (local mode)
|
| 232 |
+
# ---------------------------------------------------------------------------
|
| 233 |
+
def run_episode_local(llm_client: OpenAI, task: dict) -> tuple:
|
| 234 |
+
"""Run a task episode using the OpenEnv Environment directly (no server)."""
|
| 235 |
+
task_name = task["name"]
|
| 236 |
+
query = task["query"]
|
| 237 |
+
max_steps = task["max_steps"]
|
| 238 |
+
product_count = task["product_count"]
|
| 239 |
+
|
| 240 |
+
env = ShoppingEnvironment()
|
| 241 |
+
|
| 242 |
+
history: List[str] = []
|
| 243 |
+
rewards: List[float] = []
|
| 244 |
+
steps_taken = 0
|
| 245 |
+
score = 0.0
|
| 246 |
+
success = False
|
| 247 |
+
|
| 248 |
+
# Build personality-aware system prompt
|
| 249 |
+
profile = load_profile()
|
| 250 |
+
profile_text = profile.to_prompt_text(category=query)
|
| 251 |
+
system_prompt = build_system_prompt(profile_text)
|
| 252 |
+
|
| 253 |
+
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
# Use the OpenEnv reset() — returns Observation directly
|
| 257 |
+
obs = env.reset(
|
| 258 |
+
query=query,
|
| 259 |
+
product_count=product_count,
|
| 260 |
+
task_name=task_name,
|
| 261 |
+
max_steps=max_steps,
|
| 262 |
+
)
|
| 263 |
+
obs_dict = obs.model_dump()
|
| 264 |
+
last_reward = 0.0
|
| 265 |
+
|
| 266 |
+
for step_num in range(1, max_steps + 1):
|
| 267 |
+
if obs.done:
|
| 268 |
+
break
|
| 269 |
+
|
| 270 |
+
action = get_agent_action(
|
| 271 |
+
llm_client, system_prompt, step_num, obs_dict, last_reward, history
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Use the OpenEnv step() — returns Observation directly
|
| 275 |
+
obs = env.step(action)
|
| 276 |
+
reward = obs.reward or 0.0
|
| 277 |
+
done = obs.done
|
| 278 |
+
error = None
|
| 279 |
+
|
| 280 |
+
rewards.append(reward)
|
| 281 |
+
steps_taken = step_num
|
| 282 |
+
obs_dict = obs.model_dump()
|
| 283 |
+
last_reward = reward
|
| 284 |
+
|
| 285 |
+
# Compact action string for logging
|
| 286 |
+
ids_str = ",".join(action.item_ids) if action.item_ids else ""
|
| 287 |
+
if action.search_query:
|
| 288 |
+
action_str = f"{action.action_type}('{action.search_query}')"
|
| 289 |
+
elif ids_str:
|
| 290 |
+
action_str = f"{action.action_type}({ids_str})"
|
| 291 |
+
else:
|
| 292 |
+
action_str = action.action_type
|
| 293 |
+
|
| 294 |
+
log_step(step=step_num, action=action_str, reward=reward, done=done, error=error)
|
| 295 |
+
history.append(f"Step {step_num}: {action_str} -> reward {reward:+.2f}")
|
| 296 |
+
|
| 297 |
+
if done:
|
| 298 |
+
break
|
| 299 |
+
|
| 300 |
+
# Score calculation
|
| 301 |
+
if rewards:
|
| 302 |
+
buy_reward = max((r for r in rewards if r >= 0.3), default=0.0)
|
| 303 |
+
if buy_reward > 0:
|
| 304 |
+
score = buy_reward
|
| 305 |
+
else:
|
| 306 |
+
positive_rewards = sum(r for r in rewards if r > 0)
|
| 307 |
+
score = min(positive_rewards / 2.0, 0.3)
|
| 308 |
+
score = min(max(score, 0.0), 1.0)
|
| 309 |
+
success = score >= 0.5
|
| 310 |
+
|
| 311 |
+
finally:
|
| 312 |
+
env.close()
|
| 313 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 314 |
+
|
| 315 |
+
return success, steps_taken, score, rewards
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
# Main
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
def main() -> None:
|
| 322 |
+
print("=" * 60, flush=True)
|
| 323 |
+
print("OpenEnv Shopping Agent — Direct Local Execution", flush=True)
|
| 324 |
+
print("=" * 60, flush=True)
|
| 325 |
+
print(f"Model: {MODEL_NAME}", flush=True)
|
| 326 |
+
print(f"API: {API_BASE_URL}", flush=True)
|
| 327 |
+
print(f"Key: {'***' + API_KEY[-4:] if API_KEY else 'NOT SET (using fallback heuristic)'}", flush=True)
|
| 328 |
+
print("=" * 60, flush=True)
|
| 329 |
+
print(flush=True)
|
| 330 |
+
|
| 331 |
+
llm_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 332 |
+
|
| 333 |
+
all_scores = {}
|
| 334 |
+
for task in TASKS:
|
| 335 |
+
_success, _steps, score, _rewards = run_episode_local(llm_client, task)
|
| 336 |
+
all_scores[task["name"]] = score
|
| 337 |
+
print(flush=True)
|
| 338 |
+
|
| 339 |
+
print("=" * 60, flush=True)
|
| 340 |
+
print("SUMMARY", flush=True)
|
| 341 |
+
for t, s in all_scores.items():
|
| 342 |
+
status = "PASS" if s >= 0.5 else "FAIL"
|
| 343 |
+
print(f" [{status}] {t:16s}: {s:.2f}", flush=True)
|
| 344 |
+
avg = sum(all_scores.values()) / len(all_scores)
|
| 345 |
+
print(f" {'average':19s}: {avg:.2f}", flush=True)
|
| 346 |
+
print("=" * 60, flush=True)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
if __name__ == "__main__":
|
| 350 |
+
main()
|
server/app.py
CHANGED
|
@@ -1,83 +1,50 @@
|
|
| 1 |
"""
|
| 2 |
-
FastAPI
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
GET /
|
| 12 |
-
|
| 13 |
-
GET /
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
| 19 |
-
import
|
| 20 |
-
import yaml
|
| 21 |
-
import datetime
|
| 22 |
-
from contextlib import asynccontextmanager
|
| 23 |
from pathlib import Path
|
| 24 |
-
from typing import Any, Dict, List, Optional
|
| 25 |
|
| 26 |
-
|
| 27 |
-
from fastapi.responses import JSONResponse, FileResponse
|
| 28 |
-
from fastapi.staticfiles import StaticFiles
|
| 29 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
-
from pydantic import BaseModel, Field
|
| 31 |
-
from dotenv import load_dotenv
|
| 32 |
|
| 33 |
-
# Ensure
|
| 34 |
-
import sys
|
| 35 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 36 |
|
| 37 |
-
from
|
| 38 |
-
from
|
|
|
|
| 39 |
from memory_engine import load_profile
|
| 40 |
-
from personality_grader import score_all_products
|
| 41 |
-
from rl_agent import RLShoppingAgent
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
|
| 45 |
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 46 |
-
YAML_PATH = Path(__file__).resolve().parent.parent / "openenv.yaml"
|
| 47 |
-
|
| 48 |
-
# Load task configs from openenv.yaml
|
| 49 |
-
TASK_CONFIGS = {}
|
| 50 |
-
if YAML_PATH.exists():
|
| 51 |
-
with open(YAML_PATH, "r", encoding="utf-8") as f:
|
| 52 |
-
_yaml = yaml.safe_load(f)
|
| 53 |
-
for task in _yaml.get("tasks", []):
|
| 54 |
-
TASK_CONFIGS[task["name"]] = task
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# ---------------------------------------------------------------------------
|
| 58 |
-
# RL Agent — persists across requests
|
| 59 |
-
# ---------------------------------------------------------------------------
|
| 60 |
-
_agent = RLShoppingAgent()
|
| 61 |
-
_env: ShoppingEnv = None # type: ignore
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@asynccontextmanager
|
| 65 |
-
async def lifespan(app: FastAPI):
|
| 66 |
-
global _env
|
| 67 |
-
_env = ShoppingEnv()
|
| 68 |
-
yield
|
| 69 |
-
await _env.close()
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
version="1.0.0",
|
| 80 |
-
lifespan=lifespan,
|
| 81 |
)
|
| 82 |
|
| 83 |
app.add_middleware(
|
|
@@ -88,196 +55,20 @@ app.add_middleware(
|
|
| 88 |
)
|
| 89 |
|
| 90 |
|
| 91 |
-
# ----------------------------------------------------------
|
| 92 |
-
|
| 93 |
-
# ---------------------------------------------------------------------------
|
| 94 |
@app.get("/", include_in_schema=False)
|
| 95 |
async def serve_frontend():
|
|
|
|
| 96 |
index_path = STATIC_DIR / "index.html"
|
| 97 |
if index_path.exists():
|
| 98 |
return FileResponse(str(index_path), media_type="text/html")
|
| 99 |
-
return JSONResponse(content={"
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
# ---------------------------------------------------------------------------
|
| 103 |
-
# OpenEnv Core Endpoints: /reset, /step, /state, /health
|
| 104 |
-
# ---------------------------------------------------------------------------
|
| 105 |
-
|
| 106 |
-
class ResetBody(BaseModel):
|
| 107 |
-
"""Body for /reset — accepts task name or free-form query."""
|
| 108 |
-
task: Optional[str] = Field(
|
| 109 |
-
default=None,
|
| 110 |
-
description="Task name: quick_pick, smart_shop, or expert_deal",
|
| 111 |
-
)
|
| 112 |
-
query: Optional[str] = Field(
|
| 113 |
-
default=None,
|
| 114 |
-
description="Free-form product query (used if task not specified)",
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
@app.get("/health")
|
| 119 |
-
async def health():
|
| 120 |
-
return {"status": "healthy"}
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
@app.post("/reset")
|
| 124 |
-
async def reset(body: ResetBody = ResetBody()):
|
| 125 |
-
"""
|
| 126 |
-
Reset the environment. OpenEnv-compliant.
|
| 127 |
-
|
| 128 |
-
Accepts either:
|
| 129 |
-
- task: "quick_pick" | "smart_shop" | "expert_deal"
|
| 130 |
-
- query: free-form product query
|
| 131 |
-
|
| 132 |
-
Returns StepResult with initial observation.
|
| 133 |
-
"""
|
| 134 |
-
global _env
|
| 135 |
-
|
| 136 |
-
# Resolve task config
|
| 137 |
-
task_name = body.task or "smart_shop"
|
| 138 |
-
task_config = TASK_CONFIGS.get(task_name, {})
|
| 139 |
-
query = body.query or task_config.get("query", "earbuds")
|
| 140 |
-
max_steps = task_config.get("max_steps", 12)
|
| 141 |
-
product_count = task_config.get("product_count", 8)
|
| 142 |
-
difficulty = task_config.get("difficulty", "medium")
|
| 143 |
-
|
| 144 |
-
_env = ShoppingEnv(task_name=task_name)
|
| 145 |
-
_env._max_steps = max_steps
|
| 146 |
-
result = await _env.reset(query=query, product_count=product_count)
|
| 147 |
-
|
| 148 |
-
# Add task metadata to info
|
| 149 |
-
profile = _env._user_profile
|
| 150 |
-
prefs = profile.get_prefs_for_category(query.lower())
|
| 151 |
-
scored = _env._scored_products
|
| 152 |
-
|
| 153 |
-
return {
|
| 154 |
-
"observation": result.observation.model_dump(),
|
| 155 |
-
"reward": result.reward,
|
| 156 |
-
"done": result.done,
|
| 157 |
-
"info": {
|
| 158 |
-
"task": task_name,
|
| 159 |
-
"difficulty": difficulty,
|
| 160 |
-
"query": query,
|
| 161 |
-
"product_count": len(_env.catalog),
|
| 162 |
-
"max_steps": max_steps,
|
| 163 |
-
"personality_traits": prefs,
|
| 164 |
-
"ideal_product": scored[0]["product"]["name"] if scored else None,
|
| 165 |
-
"ideal_score": scored[0]["personality_score"] if scored else 0,
|
| 166 |
-
"scored_products": [
|
| 167 |
-
{
|
| 168 |
-
"id": s["product"]["id"],
|
| 169 |
-
"name": s["product"]["name"],
|
| 170 |
-
"score": s["personality_score"],
|
| 171 |
-
"rank": s["rank"],
|
| 172 |
-
}
|
| 173 |
-
for s in scored
|
| 174 |
-
],
|
| 175 |
-
},
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
@app.post("/step")
|
| 180 |
-
async def step(action: ShoppingAction):
|
| 181 |
-
"""
|
| 182 |
-
Execute an action. OpenEnv-compliant.
|
| 183 |
-
|
| 184 |
-
Returns StepResult with observation, reward, done, info.
|
| 185 |
-
"""
|
| 186 |
-
if _env is None:
|
| 187 |
-
return JSONResponse(
|
| 188 |
-
status_code=400,
|
| 189 |
-
content={"error": "Environment not initialized. Call /reset first."},
|
| 190 |
-
)
|
| 191 |
-
result = await _env.step(action)
|
| 192 |
-
state = await _env.state()
|
| 193 |
-
return {
|
| 194 |
-
"observation": result.observation.model_dump(),
|
| 195 |
-
"reward": result.reward,
|
| 196 |
-
"done": result.done,
|
| 197 |
-
"info": {
|
| 198 |
-
"step_count": state.step_count,
|
| 199 |
-
"cumulative_reward": state.cumulative_reward,
|
| 200 |
-
"cart": state.cart,
|
| 201 |
-
},
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
@app.get("/state")
|
| 206 |
-
async def state():
|
| 207 |
-
"""
|
| 208 |
-
Get current episode state. OpenEnv-compliant.
|
| 209 |
-
"""
|
| 210 |
-
if _env is None:
|
| 211 |
-
return JSONResponse(
|
| 212 |
-
status_code=400,
|
| 213 |
-
content={"error": "Environment not initialized."},
|
| 214 |
-
)
|
| 215 |
-
s = await _env.state()
|
| 216 |
-
return s.model_dump()
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
# ---------------------------------------------------------------------------
|
| 220 |
-
# Autonomous RL Endpoints (for web UI)
|
| 221 |
-
# ---------------------------------------------------------------------------
|
| 222 |
-
|
| 223 |
-
@app.post("/auto-run")
|
| 224 |
-
async def auto_run():
|
| 225 |
-
"""
|
| 226 |
-
Run a FULL autonomous RL episode.
|
| 227 |
-
No human feedback needed — personality_grader IS the reward function.
|
| 228 |
-
"""
|
| 229 |
-
if _env is None or not _env.catalog:
|
| 230 |
-
return JSONResponse(
|
| 231 |
-
status_code=400,
|
| 232 |
-
content={"error": "No active session. Call /reset first."},
|
| 233 |
-
)
|
| 234 |
-
|
| 235 |
-
attempts = _agent.run_episode(
|
| 236 |
-
products=_env.catalog,
|
| 237 |
-
scored_products=_env._scored_products,
|
| 238 |
-
)
|
| 239 |
-
|
| 240 |
-
ideal = _env._scored_products[0] if _env._scored_products else None
|
| 241 |
-
stats = _agent.get_stats()
|
| 242 |
-
|
| 243 |
-
return {
|
| 244 |
-
"attempts": attempts,
|
| 245 |
-
"episode_number": stats["episode_count"],
|
| 246 |
-
"total_episode_reward": round(
|
| 247 |
-
sum(a["reward"] for a in attempts), 4
|
| 248 |
-
),
|
| 249 |
-
"success": any(a["is_success"] for a in attempts),
|
| 250 |
-
"success_attempt": next(
|
| 251 |
-
(a["attempt"] for a in attempts if a["is_success"]), None
|
| 252 |
-
),
|
| 253 |
-
"ideal_product": {
|
| 254 |
-
"name": ideal["product"]["name"],
|
| 255 |
-
"score": ideal["personality_score"],
|
| 256 |
-
"id": ideal["product"]["id"],
|
| 257 |
-
} if ideal else None,
|
| 258 |
-
"agent_stats": stats,
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
@app.get("/agent-stats")
|
| 263 |
-
async def agent_stats():
|
| 264 |
-
"""Get the agent's learning statistics and current weights."""
|
| 265 |
-
return _agent.get_stats()
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
@app.post("/agent-reset")
|
| 269 |
-
async def agent_reset():
|
| 270 |
-
"""Reset the agent's learned weights — start fresh."""
|
| 271 |
-
_agent.reset_weights()
|
| 272 |
-
return {
|
| 273 |
-
"message": "Agent weights reset. Learning starts from scratch.",
|
| 274 |
-
"stats": _agent.get_stats(),
|
| 275 |
-
}
|
| 276 |
|
| 277 |
|
| 278 |
@app.get("/profile")
|
| 279 |
async def profile():
|
| 280 |
-
"""
|
| 281 |
prof = load_profile()
|
| 282 |
return {
|
| 283 |
"personality_summary": prof.personality_summary[:500],
|
|
@@ -299,6 +90,15 @@ async def profile():
|
|
| 299 |
}
|
| 300 |
|
| 301 |
|
| 302 |
-
# Mount static files
|
| 303 |
if STATIC_DIR.exists():
|
| 304 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
FastAPI app for the Shopping Agent — powered by OpenEnv.
|
| 3 |
+
|
| 4 |
+
Uses openenv.core.env_server.http_server.create_app() to expose the
|
| 5 |
+
ShoppingEnvironment over HTTP + WebSocket endpoints that any EnvClient
|
| 6 |
+
(including ShoppingEnvClient) can consume.
|
| 7 |
+
|
| 8 |
+
Endpoints auto-provided by OpenEnv:
|
| 9 |
+
POST /reset → Reset environment
|
| 10 |
+
POST /step → Execute action
|
| 11 |
+
GET /state → Current episode state
|
| 12 |
+
GET /health → Health check
|
| 13 |
+
GET /schema → Action/Observation schemas
|
| 14 |
+
WS /ws → WebSocket persistent session
|
| 15 |
+
|
| 16 |
+
Custom endpoints added below:
|
| 17 |
+
GET / → Web UI
|
| 18 |
+
GET /profile → User personality profile
|
| 19 |
"""
|
| 20 |
|
| 21 |
import os
|
| 22 |
+
import sys
|
|
|
|
|
|
|
|
|
|
| 23 |
from pathlib import Path
|
|
|
|
| 24 |
|
| 25 |
+
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
# Ensure project root is importable
|
|
|
|
| 28 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 29 |
|
| 30 |
+
from openenv.core.env_server.http_server import create_app
|
| 31 |
+
from openenv_models import ShoppingAction, ShoppingObservation
|
| 32 |
+
from server.shopping_environment import ShoppingEnvironment
|
| 33 |
from memory_engine import load_profile
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 36 |
+
from fastapi.staticfiles import StaticFiles
|
| 37 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 38 |
|
| 39 |
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
# --- Create the OpenEnv app -------------------------------------------------
|
| 42 |
+
# Pass the CLASS (factory), not an instance — create_app creates per-session.
|
| 43 |
+
app = create_app(
|
| 44 |
+
ShoppingEnvironment,
|
| 45 |
+
ShoppingAction,
|
| 46 |
+
ShoppingObservation,
|
| 47 |
+
env_name="shopping_agent",
|
|
|
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
app.add_middleware(
|
|
|
|
| 55 |
)
|
| 56 |
|
| 57 |
|
| 58 |
+
# --- Custom endpoints -------------------------------------------------------
|
| 59 |
+
|
|
|
|
| 60 |
@app.get("/", include_in_schema=False)
|
| 61 |
async def serve_frontend():
|
| 62 |
+
"""Serve the web UI."""
|
| 63 |
index_path = STATIC_DIR / "index.html"
|
| 64 |
if index_path.exists():
|
| 65 |
return FileResponse(str(index_path), media_type="text/html")
|
| 66 |
+
return JSONResponse(content={"message": "Shopping Agent OpenEnv server is running."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
@app.get("/profile")
|
| 70 |
async def profile():
|
| 71 |
+
"""Return the loaded user personality profile summary."""
|
| 72 |
prof = load_profile()
|
| 73 |
return {
|
| 74 |
"personality_summary": prof.personality_summary[:500],
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
|
| 93 |
+
# Mount static files
|
| 94 |
if STATIC_DIR.exists():
|
| 95 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main():
|
| 99 |
+
"""Run the server directly."""
|
| 100 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
server/shopping_environment.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenEnv-compatible ShoppingEnvironment.
|
| 3 |
+
|
| 4 |
+
Wraps the existing shopping_env logic into the OpenEnv Environment
|
| 5 |
+
interface (reset → Observation, step → Observation, state → property).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
# Ensure parent directory is importable
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 16 |
+
|
| 17 |
+
from openenv.core.env_server.interfaces import Environment
|
| 18 |
+
from openenv.core.env_server.types import (
|
| 19 |
+
EnvironmentMetadata,
|
| 20 |
+
State,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
from openenv_models import ShoppingAction, ShoppingObservation, ShoppingState
|
| 24 |
+
from memory_engine import load_profile, UserProfile
|
| 25 |
+
from product_generator import generate_products
|
| 26 |
+
from personality_grader import grade_purchase as personality_grade_purchase, score_all_products
|
| 27 |
+
from task_config import DEFAULT_TASK_NAME, get_task_config
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
DEFAULT_MAX_STEPS = 15
|
| 31 |
+
DEFAULT_QUERY = "earbuds"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ShoppingEnvironment(Environment[ShoppingAction, ShoppingObservation, ShoppingState]):
|
| 35 |
+
"""
|
| 36 |
+
OpenEnv-compliant shopping environment.
|
| 37 |
+
|
| 38 |
+
Implements the Environment interface:
|
| 39 |
+
- reset() → ShoppingObservation
|
| 40 |
+
- step() → ShoppingObservation
|
| 41 |
+
- state → ShoppingState (property)
|
| 42 |
+
- close() → cleanup
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, **kwargs):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self._user_profile: UserProfile = load_profile()
|
| 48 |
+
|
| 49 |
+
# Episode state
|
| 50 |
+
self._step_count = 0
|
| 51 |
+
self._max_steps = DEFAULT_MAX_STEPS
|
| 52 |
+
self._done = False
|
| 53 |
+
self._cumulative_reward = 0.0
|
| 54 |
+
self._query = ""
|
| 55 |
+
self._category = ""
|
| 56 |
+
self._cart: List[str] = []
|
| 57 |
+
self._shortlisted: List[str] = []
|
| 58 |
+
self._viewed: List[str] = []
|
| 59 |
+
self._compared_sets: List[List[str]] = []
|
| 60 |
+
self._history: List[str] = []
|
| 61 |
+
self._feedback = ""
|
| 62 |
+
self._skipped_ids: List[str] = []
|
| 63 |
+
self.catalog: List[Dict[str, Any]] = []
|
| 64 |
+
self._scored_products: List[Dict[str, Any]] = []
|
| 65 |
+
self._episode_id = str(uuid4())
|
| 66 |
+
self._task_name = DEFAULT_TASK_NAME
|
| 67 |
+
self._difficulty = "medium"
|
| 68 |
+
|
| 69 |
+
# ---- OpenEnv interface -------------------------------------------------
|
| 70 |
+
|
| 71 |
+
def reset(
|
| 72 |
+
self,
|
| 73 |
+
seed: Optional[int] = None,
|
| 74 |
+
episode_id: Optional[str] = None,
|
| 75 |
+
query: str = DEFAULT_QUERY,
|
| 76 |
+
product_count: int = 8,
|
| 77 |
+
task_name: str = DEFAULT_TASK_NAME,
|
| 78 |
+
max_steps: int = DEFAULT_MAX_STEPS,
|
| 79 |
+
**kwargs: Any,
|
| 80 |
+
) -> ShoppingObservation:
|
| 81 |
+
"""Reset the environment. Returns initial ShoppingObservation."""
|
| 82 |
+
requested_task = kwargs.get("task", task_name)
|
| 83 |
+
task_config = get_task_config(requested_task)
|
| 84 |
+
query_was_explicit = "query" in kwargs or query != DEFAULT_QUERY
|
| 85 |
+
product_count_was_explicit = (
|
| 86 |
+
"product_count" in kwargs or product_count != 8
|
| 87 |
+
)
|
| 88 |
+
max_steps_was_explicit = "max_steps" in kwargs or max_steps != DEFAULT_MAX_STEPS
|
| 89 |
+
|
| 90 |
+
if task_config:
|
| 91 |
+
if not query_was_explicit:
|
| 92 |
+
query = task_config.get("query", query)
|
| 93 |
+
if not product_count_was_explicit:
|
| 94 |
+
product_count = task_config.get("product_count", product_count)
|
| 95 |
+
if not max_steps_was_explicit:
|
| 96 |
+
max_steps = task_config.get("max_steps", max_steps)
|
| 97 |
+
task_name = task_config["name"]
|
| 98 |
+
self._difficulty = task_config.get("difficulty", task_name)
|
| 99 |
+
else:
|
| 100 |
+
self._difficulty = "dynamic"
|
| 101 |
+
|
| 102 |
+
self._step_count = 0
|
| 103 |
+
self._done = False
|
| 104 |
+
self._cumulative_reward = 0.0
|
| 105 |
+
self._query = query.strip() or DEFAULT_QUERY
|
| 106 |
+
self._category = self._query.lower()
|
| 107 |
+
self._cart = []
|
| 108 |
+
self._shortlisted = []
|
| 109 |
+
self._viewed = []
|
| 110 |
+
self._compared_sets = []
|
| 111 |
+
self._history = []
|
| 112 |
+
self._skipped_ids = []
|
| 113 |
+
self._episode_id = episode_id or str(uuid4())
|
| 114 |
+
self._task_name = task_name
|
| 115 |
+
self._max_steps = max_steps
|
| 116 |
+
|
| 117 |
+
# Reload user profile (may have been updated)
|
| 118 |
+
self._user_profile = load_profile()
|
| 119 |
+
|
| 120 |
+
# Generate products dynamically
|
| 121 |
+
self.catalog = generate_products(self._query, count=product_count)
|
| 122 |
+
|
| 123 |
+
# Score all products against personality
|
| 124 |
+
self._scored_products = score_all_products(self.catalog, self._user_profile)
|
| 125 |
+
|
| 126 |
+
# Find the personality-ideal product
|
| 127 |
+
ideal = self._scored_products[0] if self._scored_products else None
|
| 128 |
+
ideal_name = ideal["product"]["name"] if ideal else "unknown"
|
| 129 |
+
ideal_score = ideal["personality_score"] if ideal else 0
|
| 130 |
+
research_depth = self._user_profile.research_depth
|
| 131 |
+
|
| 132 |
+
self._feedback = (
|
| 133 |
+
f"Welcome! Shopping for: {self._query}\n"
|
| 134 |
+
f"Generated {len(self.catalog)} products.\n"
|
| 135 |
+
f"Goal: Find the product that best matches your personality profile.\n"
|
| 136 |
+
f"The personality-ideal product is '{ideal_name}' "
|
| 137 |
+
f"(alignment score: {ideal_score:.2f}).\n"
|
| 138 |
+
f"Your research depth preference: {research_depth:.0%} — "
|
| 139 |
+
f"{'thorough research expected' if research_depth > 0.7 else 'quick decisions OK'}."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
return self._get_obs(reward=0.0)
|
| 143 |
+
|
| 144 |
+
def step(
|
| 145 |
+
self,
|
| 146 |
+
action: ShoppingAction,
|
| 147 |
+
timeout_s: Optional[float] = None,
|
| 148 |
+
**kwargs: Any,
|
| 149 |
+
) -> ShoppingObservation:
|
| 150 |
+
"""Execute an action. Returns ShoppingObservation with reward and done."""
|
| 151 |
+
if self._done:
|
| 152 |
+
return self._get_obs(reward=0.0, done=True)
|
| 153 |
+
|
| 154 |
+
self._step_count += 1
|
| 155 |
+
reward = 0.0
|
| 156 |
+
action_log = ""
|
| 157 |
+
research_depth = self._user_profile.research_depth
|
| 158 |
+
atype = action.action_type.lower().strip()
|
| 159 |
+
|
| 160 |
+
# ---- search -------------------------------------------------------
|
| 161 |
+
if atype == "search":
|
| 162 |
+
self._query = action.search_query or self._query
|
| 163 |
+
action_log = f"Searched for '{self._query}'"
|
| 164 |
+
self._feedback = f"Found {len(self.catalog)} products for '{self._query}'."
|
| 165 |
+
reward = 0.05 * research_depth
|
| 166 |
+
|
| 167 |
+
# ---- view_item ----------------------------------------------------
|
| 168 |
+
elif atype == "view_item":
|
| 169 |
+
if action.item_ids:
|
| 170 |
+
details = []
|
| 171 |
+
for pid in action.item_ids:
|
| 172 |
+
if pid not in self._viewed:
|
| 173 |
+
self._viewed.append(pid)
|
| 174 |
+
prod = self._find_product(pid)
|
| 175 |
+
if prod:
|
| 176 |
+
ps = self._get_personality_score(pid)
|
| 177 |
+
details.append(
|
| 178 |
+
f"{prod['name']}: ${prod['price']}, "
|
| 179 |
+
f"{prod['rating']}★, {prod['reviews']} reviews, "
|
| 180 |
+
f"brand {prod['brand']}, seller {prod['seller']}, "
|
| 181 |
+
f"refundable={prod['refundable']}, "
|
| 182 |
+
f"personality_alignment={ps:.2f}"
|
| 183 |
+
)
|
| 184 |
+
action_log = f"Viewed {len(action.item_ids)} item(s): {action.item_ids}"
|
| 185 |
+
self._feedback = (
|
| 186 |
+
"Details:\n" + "\n".join(f" - {d}" for d in details)
|
| 187 |
+
if details else "No matching products found."
|
| 188 |
+
)
|
| 189 |
+
reward = 0.05 * min(len(action.item_ids), 4) * research_depth
|
| 190 |
+
else:
|
| 191 |
+
self._feedback = "view_item requires at least one item_id."
|
| 192 |
+
|
| 193 |
+
# ---- compare ------------------------------------------------------
|
| 194 |
+
elif atype == "compare":
|
| 195 |
+
if not action.item_ids or len(action.item_ids) < 2:
|
| 196 |
+
self._feedback = "Compare requires at least 2 item_ids."
|
| 197 |
+
else:
|
| 198 |
+
self._compared_sets.append(list(action.item_ids))
|
| 199 |
+
names = []
|
| 200 |
+
for pid in action.item_ids:
|
| 201 |
+
p = self._find_product(pid)
|
| 202 |
+
if p:
|
| 203 |
+
ps = self._get_personality_score(pid)
|
| 204 |
+
names.append(
|
| 205 |
+
f"{p['name']} (${p['price']}, {p['rating']}★, "
|
| 206 |
+
f"alignment: {ps:.2f})"
|
| 207 |
+
)
|
| 208 |
+
action_log = f"Compared {len(action.item_ids)} items: {action.item_ids}"
|
| 209 |
+
self._feedback = "Comparison:\n" + "\n".join(f" - {n}" for n in names)
|
| 210 |
+
reward = 0.05 * min(len(action.item_ids), 5) * research_depth
|
| 211 |
+
|
| 212 |
+
# ---- shortlist ----------------------------------------------------
|
| 213 |
+
elif atype == "shortlist":
|
| 214 |
+
for pid in action.item_ids:
|
| 215 |
+
if pid not in self._shortlisted:
|
| 216 |
+
self._shortlisted.append(pid)
|
| 217 |
+
action_log = f"Shortlisted {action.item_ids}"
|
| 218 |
+
self._feedback = f"Shortlist now: {self._shortlisted}"
|
| 219 |
+
reward = 0.1 * research_depth
|
| 220 |
+
|
| 221 |
+
# ---- add_to_cart --------------------------------------------------
|
| 222 |
+
elif atype == "add_to_cart":
|
| 223 |
+
for pid in action.item_ids:
|
| 224 |
+
if pid not in self._cart:
|
| 225 |
+
self._cart.append(pid)
|
| 226 |
+
action_log = f"Added to cart: {action.item_ids}"
|
| 227 |
+
self._feedback = f"Cart: {self._cart}"
|
| 228 |
+
reward = 0.1
|
| 229 |
+
|
| 230 |
+
# ---- remove_from_cart ---------------------------------------------
|
| 231 |
+
elif atype == "remove_from_cart":
|
| 232 |
+
for pid in action.item_ids:
|
| 233 |
+
if pid in self._cart:
|
| 234 |
+
self._cart.remove(pid)
|
| 235 |
+
action_log = f"Removed from cart: {action.item_ids}"
|
| 236 |
+
self._feedback = f"Cart: {self._cart}"
|
| 237 |
+
reward = 0.0
|
| 238 |
+
|
| 239 |
+
# ---- buy ----------------------------------------------------------
|
| 240 |
+
elif atype == "buy":
|
| 241 |
+
action_log = "Attempted purchase"
|
| 242 |
+
self._done = True
|
| 243 |
+
purchased = set(self._cart + action.item_ids)
|
| 244 |
+
if not purchased:
|
| 245 |
+
self._feedback = "Cannot buy — cart is empty and no item_ids given."
|
| 246 |
+
reward = 0.0
|
| 247 |
+
else:
|
| 248 |
+
reward = self._grade_purchase(purchased)
|
| 249 |
+
purchased_names = []
|
| 250 |
+
for pid in purchased:
|
| 251 |
+
p = self._find_product(pid)
|
| 252 |
+
if p:
|
| 253 |
+
ps = self._get_personality_score(pid)
|
| 254 |
+
purchased_names.append(f"{p['name']} (alignment: {ps:.2f})")
|
| 255 |
+
self._feedback = (
|
| 256 |
+
f"Purchased: {', '.join(purchased_names)}.\n"
|
| 257 |
+
f"Final score: {reward:.2f}"
|
| 258 |
+
)
|
| 259 |
+
self._log_episode(purchased, reward)
|
| 260 |
+
|
| 261 |
+
# ---- skip ---------------------------------------------------------
|
| 262 |
+
elif atype == "skip":
|
| 263 |
+
if action.item_ids:
|
| 264 |
+
self._skipped_ids.extend(action.item_ids)
|
| 265 |
+
action_log = f"Skipped items: {action.item_ids}"
|
| 266 |
+
else:
|
| 267 |
+
action_log = "Skipped turn"
|
| 268 |
+
self._feedback = "Turn skipped."
|
| 269 |
+
reward = 0.0
|
| 270 |
+
|
| 271 |
+
# ---- ask_more -----------------------------------------------------
|
| 272 |
+
elif atype == "ask_more":
|
| 273 |
+
action_log = "Asked for more options"
|
| 274 |
+
self._feedback = "No additional products available in this catalog."
|
| 275 |
+
reward = 0.0
|
| 276 |
+
|
| 277 |
+
# ---- unknown ------------------------------------------------------
|
| 278 |
+
else:
|
| 279 |
+
action_log = f"Unknown action: {atype}"
|
| 280 |
+
self._feedback = (
|
| 281 |
+
f"Invalid action_type '{atype}'. Valid: search, view_item, "
|
| 282 |
+
f"compare, shortlist, add_to_cart, remove_from_cart, buy, skip, ask_more."
|
| 283 |
+
)
|
| 284 |
+
reward = -0.1
|
| 285 |
+
|
| 286 |
+
if action_log:
|
| 287 |
+
self._history.append(action_log)
|
| 288 |
+
|
| 289 |
+
# Penalize running out of steps
|
| 290 |
+
if self._step_count >= self._max_steps and not self._done:
|
| 291 |
+
self._done = True
|
| 292 |
+
self._feedback += " Episode ended — max steps reached without purchase."
|
| 293 |
+
reward -= 0.2
|
| 294 |
+
|
| 295 |
+
self._cumulative_reward += reward
|
| 296 |
+
return self._get_obs(reward=round(reward, 4), done=self._done)
|
| 297 |
+
|
| 298 |
+
@property
|
| 299 |
+
def state(self) -> ShoppingState:
|
| 300 |
+
"""Get current episode state."""
|
| 301 |
+
return ShoppingState(
|
| 302 |
+
episode_id=self._episode_id,
|
| 303 |
+
step_count=self._step_count,
|
| 304 |
+
task_name=self._task_name,
|
| 305 |
+
difficulty=self._difficulty,
|
| 306 |
+
done=self._done,
|
| 307 |
+
cumulative_reward=round(self._cumulative_reward, 4),
|
| 308 |
+
cart=list(self._cart),
|
| 309 |
+
shortlisted=list(self._shortlisted),
|
| 310 |
+
product_query=self._query,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
def get_metadata(self) -> EnvironmentMetadata:
|
| 314 |
+
return EnvironmentMetadata(
|
| 315 |
+
name="shopping_agent",
|
| 316 |
+
description=(
|
| 317 |
+
"Personality-driven RL shopping environment. "
|
| 318 |
+
"The agent learns to shop like a specific user."
|
| 319 |
+
),
|
| 320 |
+
version="1.0.0",
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
def close(self) -> None:
|
| 324 |
+
pass
|
| 325 |
+
|
| 326 |
+
# ---- helpers ----------------------------------------------------------
|
| 327 |
+
|
| 328 |
+
def _find_product(self, pid: str) -> Optional[Dict[str, Any]]:
|
| 329 |
+
for p in self.catalog:
|
| 330 |
+
if p["id"] == pid:
|
| 331 |
+
return p
|
| 332 |
+
return None
|
| 333 |
+
|
| 334 |
+
def _get_personality_score(self, pid: str) -> float:
|
| 335 |
+
for item in self._scored_products:
|
| 336 |
+
if item["product"]["id"] == pid:
|
| 337 |
+
return item["personality_score"]
|
| 338 |
+
return 0.0
|
| 339 |
+
|
| 340 |
+
def _get_obs(self, reward: float = 0.0, done: bool = False) -> ShoppingObservation:
|
| 341 |
+
history_text = "\n".join(self._history[-6:]) if self._history else "No actions yet."
|
| 342 |
+
profile = self._user_profile
|
| 343 |
+
prefs = profile.get_prefs_for_category(self._category)
|
| 344 |
+
|
| 345 |
+
return ShoppingObservation(
|
| 346 |
+
done=done,
|
| 347 |
+
reward=reward,
|
| 348 |
+
query=self._query,
|
| 349 |
+
category=self._category,
|
| 350 |
+
candidate_products=self.catalog,
|
| 351 |
+
memory_profile={
|
| 352 |
+
"goal": (
|
| 353 |
+
f"Find the best {self._query} that matches your personality: "
|
| 354 |
+
f"research-heavy ({prefs.get('research_depth', 0.5):.0%}), "
|
| 355 |
+
f"value-conscious ({prefs.get('price_sensitivity', 0.5):.0%}), "
|
| 356 |
+
f"quality-focused ({prefs.get('quality_preference', 0.5):.0%})."
|
| 357 |
+
),
|
| 358 |
+
**prefs,
|
| 359 |
+
"semantic_conclusions": [
|
| 360 |
+
c.get("conclusion", "")
|
| 361 |
+
for c in profile.semantic_conclusions[:6]
|
| 362 |
+
],
|
| 363 |
+
"personality_summary": profile.personality_summary[:300],
|
| 364 |
+
},
|
| 365 |
+
cart=list(self._cart),
|
| 366 |
+
shortlisted=list(self._shortlisted),
|
| 367 |
+
viewed_items=list(self._viewed),
|
| 368 |
+
compared_sets=[list(s) for s in self._compared_sets],
|
| 369 |
+
history_summary=history_text,
|
| 370 |
+
feedback=self._feedback,
|
| 371 |
+
step_number=self._step_count,
|
| 372 |
+
max_steps=self._max_steps,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
def _grade_purchase(self, purchased_ids: set) -> float:
|
| 376 |
+
return personality_grade_purchase(
|
| 377 |
+
purchased_ids=purchased_ids,
|
| 378 |
+
products=self.catalog,
|
| 379 |
+
profile=self._user_profile,
|
| 380 |
+
viewed=self._viewed,
|
| 381 |
+
compared_sets=self._compared_sets,
|
| 382 |
+
shortlisted=self._shortlisted,
|
| 383 |
+
skipped_ids=self._skipped_ids,
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
def _log_episode(self, purchased_ids: set, reward: float):
|
| 387 |
+
try:
|
| 388 |
+
import datetime
|
| 389 |
+
log_path = Path(__file__).parent.parent / "memory" / "episodic_log.jsonl"
|
| 390 |
+
purchased_products = []
|
| 391 |
+
for pid in purchased_ids:
|
| 392 |
+
p = self._find_product(pid)
|
| 393 |
+
if p:
|
| 394 |
+
purchased_products.append({
|
| 395 |
+
"id": p["id"],
|
| 396 |
+
"name": p["name"],
|
| 397 |
+
"price": p["price"],
|
| 398 |
+
"brand": p["brand"],
|
| 399 |
+
})
|
| 400 |
+
entry = {
|
| 401 |
+
"timestamp": datetime.datetime.now().isoformat(),
|
| 402 |
+
"event": "agent_purchase",
|
| 403 |
+
"query": self._query,
|
| 404 |
+
"purchased": purchased_products,
|
| 405 |
+
"reward": round(reward, 4),
|
| 406 |
+
"steps": self._step_count,
|
| 407 |
+
"viewed_count": len(self._viewed),
|
| 408 |
+
"compared_count": len(self._compared_sets),
|
| 409 |
+
"shortlisted_count": len(self._shortlisted),
|
| 410 |
+
}
|
| 411 |
+
with open(log_path, "a", encoding="utf-8") as f:
|
| 412 |
+
f.write(json.dumps(entry) + "\n")
|
| 413 |
+
except Exception as e:
|
| 414 |
+
print(f"[shopping_env] Error logging episode: {e}")
|
task_config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared task configuration loaded from openenv.yaml."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent
|
| 12 |
+
OPENENV_MANIFEST = ROOT / "openenv.yaml"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_openenv_manifest() -> Dict[str, Any]:
|
| 16 |
+
"""Load the environment manifest from disk."""
|
| 17 |
+
with OPENENV_MANIFEST.open("r", encoding="utf-8") as fh:
|
| 18 |
+
return yaml.safe_load(fh) or {}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_task_configs() -> Dict[str, Dict[str, Any]]:
|
| 22 |
+
"""Return tasks keyed by task name."""
|
| 23 |
+
manifest = load_openenv_manifest()
|
| 24 |
+
tasks = manifest.get("tasks", [])
|
| 25 |
+
return {task["name"]: task for task in tasks if "name" in task}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
TASK_CONFIGS = load_task_configs()
|
| 29 |
+
DEFAULT_TASK_NAME = "smart_shop" if "smart_shop" in TASK_CONFIGS else next(
|
| 30 |
+
iter(TASK_CONFIGS),
|
| 31 |
+
"dynamic",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_task_config(task_name: str | None) -> Dict[str, Any] | None:
|
| 36 |
+
"""Fetch a task configuration by name."""
|
| 37 |
+
if not task_name:
|
| 38 |
+
return None
|
| 39 |
+
return TASK_CONFIGS.get(task_name)
|
tests/test_openenv_requirements.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
import unittest
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(ROOT))
|
| 14 |
+
|
| 15 |
+
from memory_engine import load_profile
|
| 16 |
+
from personality_grader import grade_purchase
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ServerProcess:
|
| 20 |
+
def __init__(self, port: int) -> None:
|
| 21 |
+
self.port = port
|
| 22 |
+
self.process: subprocess.Popen[str] | None = None
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def base_url(self) -> str:
|
| 26 |
+
return f"http://127.0.0.1:{self.port}"
|
| 27 |
+
|
| 28 |
+
def start(self) -> None:
|
| 29 |
+
env = os.environ.copy()
|
| 30 |
+
env["PYTHONPATH"] = os.pathsep.join([str(ROOT), env.get("PYTHONPATH", "")]).strip(
|
| 31 |
+
os.pathsep
|
| 32 |
+
)
|
| 33 |
+
self.process = subprocess.Popen(
|
| 34 |
+
[
|
| 35 |
+
sys.executable,
|
| 36 |
+
"-m",
|
| 37 |
+
"uvicorn",
|
| 38 |
+
"server.app:app",
|
| 39 |
+
"--host",
|
| 40 |
+
"127.0.0.1",
|
| 41 |
+
"--port",
|
| 42 |
+
str(self.port),
|
| 43 |
+
],
|
| 44 |
+
cwd=ROOT,
|
| 45 |
+
env=env,
|
| 46 |
+
stdout=subprocess.PIPE,
|
| 47 |
+
stderr=subprocess.PIPE,
|
| 48 |
+
text=True,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
for _ in range(30):
|
| 52 |
+
try:
|
| 53 |
+
response = requests.get(f"{self.base_url}/health", timeout=1)
|
| 54 |
+
if response.status_code == 200:
|
| 55 |
+
return
|
| 56 |
+
except requests.RequestException:
|
| 57 |
+
time.sleep(0.5)
|
| 58 |
+
|
| 59 |
+
stderr = ""
|
| 60 |
+
if self.process and self.process.stderr:
|
| 61 |
+
stderr = self.process.stderr.read()
|
| 62 |
+
raise RuntimeError(f"Server failed to start on port {self.port}: {stderr}")
|
| 63 |
+
|
| 64 |
+
def stop(self) -> None:
|
| 65 |
+
if not self.process:
|
| 66 |
+
return
|
| 67 |
+
self.process.terminate()
|
| 68 |
+
try:
|
| 69 |
+
self.process.wait(timeout=5)
|
| 70 |
+
except subprocess.TimeoutExpired:
|
| 71 |
+
self.process.kill()
|
| 72 |
+
self.process.wait()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class OpenEnvRequirementsTests(unittest.TestCase):
|
| 76 |
+
@classmethod
|
| 77 |
+
def setUpClass(cls) -> None:
|
| 78 |
+
cls.server = ServerProcess(port=8010)
|
| 79 |
+
cls.server.start()
|
| 80 |
+
|
| 81 |
+
@classmethod
|
| 82 |
+
def tearDownClass(cls) -> None:
|
| 83 |
+
cls.server.stop()
|
| 84 |
+
|
| 85 |
+
def test_reset_uses_named_task_configuration(self) -> None:
|
| 86 |
+
expected = {
|
| 87 |
+
"quick_pick": ("lip balm", 4, 8),
|
| 88 |
+
"smart_shop": ("earbuds", 8, 12),
|
| 89 |
+
"expert_deal": ("laptop backpack", 12, 15),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
for task_name, (query, product_count, max_steps) in expected.items():
|
| 93 |
+
with self.subTest(task=task_name):
|
| 94 |
+
response = requests.post(
|
| 95 |
+
f"{self.server.base_url}/reset",
|
| 96 |
+
json={"task": task_name},
|
| 97 |
+
timeout=10,
|
| 98 |
+
)
|
| 99 |
+
response.raise_for_status()
|
| 100 |
+
payload = response.json()
|
| 101 |
+
observation = payload["observation"]
|
| 102 |
+
|
| 103 |
+
self.assertEqual(observation["query"], query)
|
| 104 |
+
self.assertEqual(len(observation["candidate_products"]), product_count)
|
| 105 |
+
self.assertEqual(observation["max_steps"], max_steps)
|
| 106 |
+
|
| 107 |
+
state_response = requests.get(f"{self.server.base_url}/state", timeout=10)
|
| 108 |
+
state_response.raise_for_status()
|
| 109 |
+
state = state_response.json()
|
| 110 |
+
self.assertEqual(state["step_count"], 0)
|
| 111 |
+
|
| 112 |
+
def test_purchase_grader_is_bounded(self) -> None:
|
| 113 |
+
profile = load_profile()
|
| 114 |
+
products = [
|
| 115 |
+
{
|
| 116 |
+
"id": "p1",
|
| 117 |
+
"name": "Budget Choice",
|
| 118 |
+
"price": 20.0,
|
| 119 |
+
"rating": 3.8,
|
| 120 |
+
"brand": "ValueCo",
|
| 121 |
+
"reviews": 120,
|
| 122 |
+
"category": "earbuds",
|
| 123 |
+
"seller": "Trusted Seller",
|
| 124 |
+
"refundable": True,
|
| 125 |
+
"archetype": "mid_range_best_value",
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"id": "p2",
|
| 129 |
+
"name": "Risky Deal",
|
| 130 |
+
"price": 8.0,
|
| 131 |
+
"rating": 2.4,
|
| 132 |
+
"brand": "Mystery",
|
| 133 |
+
"reviews": 4,
|
| 134 |
+
"category": "earbuds",
|
| 135 |
+
"seller": "Unknown Flash Mart",
|
| 136 |
+
"refundable": False,
|
| 137 |
+
"archetype": "suspiciously_cheap",
|
| 138 |
+
},
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
score = grade_purchase(
|
| 142 |
+
purchased_ids={"p1"},
|
| 143 |
+
products=products,
|
| 144 |
+
profile=profile,
|
| 145 |
+
viewed=["p1", "p2"],
|
| 146 |
+
compared_sets=[["p1", "p2"]],
|
| 147 |
+
shortlisted=["p1"],
|
| 148 |
+
skipped_ids=["p2"],
|
| 149 |
+
)
|
| 150 |
+
self.assertGreaterEqual(score, 0.0)
|
| 151 |
+
self.assertLessEqual(score, 1.0)
|
| 152 |
+
|
| 153 |
+
def test_openenv_validate_passes(self) -> None:
|
| 154 |
+
from openenv.cli._validation import validate_running_environment
|
| 155 |
+
|
| 156 |
+
report = validate_running_environment(self.server.base_url, timeout_s=10)
|
| 157 |
+
self.assertTrue(report["passed"], msg=json.dumps(report, indent=2))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
if __name__ == "__main__":
|
| 161 |
+
unittest.main()
|