Neha Singh commited on
Commit
c5f2039
·
1 Parent(s): d6e8cd0

Add OpenEnv server integration and deployment checks

Browse files
.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 random.choice(unknown_sellers)
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
- # Use up to `count` archetypes
301
- archetypes = ARCHETYPES[:count]
 
 
 
 
 
 
 
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
- price = round(max(price_min, base_price + price_jitter), 2)
 
 
316
 
317
  # Rating
318
- rating = round(rng.uniform(*arch["rating"]), 1)
319
 
320
  # Reviews
321
- reviews = rng.randint(*arch["reviews"])
 
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 server for the Personality-Driven Shopping Agent Environment.
3
-
4
- OpenEnv-compliant HTTP API:
5
- POST /reset → Reset environment, returns StepResult
6
- POST /step → Execute an action, returns StepResult
7
- GET /state → Returns current ShoppingState
8
- GET /health → {"status": "healthy"}
9
-
10
- Also serves:
11
- GET / Interactive web UI
12
- POST /auto-runAutonomous RL episode (for web UI)
13
- GET /agent-stats Agent learning stats
14
- POST /agent-resetReset agent weights
15
- GET /profile → User personality profile
 
 
 
16
  """
17
 
18
  import os
19
- import json
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
- from fastapi import FastAPI, Query
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 parent directory is importable
34
- import sys
35
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
36
 
37
- from models import ResetRequest, StepResult, ShoppingAction, ShoppingState
38
- from shopping_env import ShoppingEnv
 
39
  from memory_engine import load_profile
40
- from personality_grader import score_all_products
41
- from rl_agent import RLShoppingAgent
42
 
43
- load_dotenv()
 
 
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
- app = FastAPI(
73
- title="Personality-Driven Shopping Agent",
74
- description=(
75
- "OpenEnv-compliant RL environment. The AI agent learns to shop "
76
- "like YOU by reading your memory/ profile. Supports 3 tasks: "
77
- "quick_pick (easy), smart_shop (medium), expert_deal (hard)."
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
- # Frontend
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={"error": "Frontend not found"}, status_code=404)
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
- """Returns the loaded user personality profile summary."""
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 — must be LAST
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 /healthHealth 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()