prasadnu commited on
Commit
b4d5c9a
·
1 Parent(s): b7af200

feat: add Search Personalization demo module

Browse files

- Add search_personalization/ module with UBI tracking, agentic memory,
product catalog, and image utilities
- Add pages/Search_Personalization.py Streamlit page
- Update app.py: 2x2 card grid layout with new Personalization card,
defensive import and logo fallback, responsive CSS
- Update requirements.txt: add python-dotenv, strands-agents, PyYAML
- Update .gitattributes: add LFS for jpg/jpeg/png/pdf
- Update .gitignore: add .env and app_new/
- Add .env.example template

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +18 -0
  2. .gitattributes +1 -0
  3. .gitignore +4 -1
  4. app.py +52 -11
  5. pages/Search_Personalization.py +937 -0
  6. requirements.txt +5 -0
  7. search_personalization/IAM_SETUP.md +170 -0
  8. search_personalization/__init__.py +1 -0
  9. search_personalization/agentic_memory/__init__.py +4 -0
  10. search_personalization/agentic_memory/agents/__init__.py +0 -0
  11. search_personalization/agentic_memory/agents/query_agent.py +96 -0
  12. search_personalization/agentic_memory/agents/ranking_agent.py +70 -0
  13. search_personalization/agentic_memory/agents/results_explanation_agent.py +120 -0
  14. search_personalization/agentic_memory/config.py +46 -0
  15. search_personalization/agentic_memory/demo_runner.py +424 -0
  16. search_personalization/agentic_memory/memory_client.py +168 -0
  17. search_personalization/agentic_memory/memory_tools.py +347 -0
  18. search_personalization/agentic_memory/personas/__init__.py +0 -0
  19. search_personalization/agentic_memory/personas/alex.json +123 -0
  20. search_personalization/agentic_memory/personas/sarah.json +87 -0
  21. search_personalization/agentic_memory/schema_cache.py +104 -0
  22. search_personalization/data_loader.py +383 -0
  23. search_personalization/iam-policy.json +67 -0
  24. search_personalization/image_utils.py +228 -0
  25. search_personalization/product_samples/.thumbnails/accessories/01b24cde-af53-4629-b7f0-0965f795b870.jpg +3 -0
  26. search_personalization/product_samples/.thumbnails/accessories/1249276c-851a-4f46-81fb-873fd7d5f60f.jpg +3 -0
  27. search_personalization/product_samples/.thumbnails/accessories/16842055-6a51-443b-843c-51bc85dee353.jpg +3 -0
  28. search_personalization/product_samples/.thumbnails/accessories/1a3fa9d4-e320-4873-be58-f3f6af5f99f4.jpg +3 -0
  29. search_personalization/product_samples/.thumbnails/accessories/1d3ae532-f790-44ca-a8e8-f55aa9b66526.jpg +3 -0
  30. search_personalization/product_samples/.thumbnails/accessories/28c14c06-d947-44d0-96f8-dc2113845e4c.jpg +3 -0
  31. search_personalization/product_samples/.thumbnails/accessories/296d144e-7f86-464b-9c5a-f545257f1700.jpg +3 -0
  32. search_personalization/product_samples/.thumbnails/accessories/2c27752b-8194-489f-96fa-be356fd811e0.jpg +3 -0
  33. search_personalization/product_samples/.thumbnails/accessories/2cc89e77-685d-423c-80a9-82e07ea13b7c.jpg +3 -0
  34. search_personalization/product_samples/.thumbnails/accessories/2e852905-c6f4-47db-802c-654013571922.jpg +3 -0
  35. search_personalization/product_samples/.thumbnails/accessories/2f1ec487-86b9-4310-ba6d-3121019ef41d.jpg +3 -0
  36. search_personalization/product_samples/.thumbnails/accessories/3491deff-c0fe-4065-abbc-72b507da84b2.jpg +3 -0
  37. search_personalization/product_samples/.thumbnails/accessories/371cef78-adf2-4cb0-8e78-02f8cb7ee013.jpg +3 -0
  38. search_personalization/product_samples/.thumbnails/accessories/39db1f40-046d-4fa3-861b-babfc4ba4023.jpg +3 -0
  39. search_personalization/product_samples/.thumbnails/accessories/3cd33786-a655-407f-b22f-c4150b1e529e.jpg +3 -0
  40. search_personalization/product_samples/.thumbnails/accessories/3eba60d7-fbde-4f4d-bb3a-30df62057fe0.jpg +3 -0
  41. search_personalization/product_samples/.thumbnails/accessories/3efb5bf2-b419-43b9-9b1f-78a6aa6fc2b7.jpg +3 -0
  42. search_personalization/product_samples/.thumbnails/accessories/41ab23ce-b417-46b2-a52a-bf7030f93161.jpg +3 -0
  43. search_personalization/product_samples/.thumbnails/accessories/437f3c6b-5fe6-4af0-9db6-ff3a0f44f585.jpg +3 -0
  44. search_personalization/product_samples/.thumbnails/accessories/4952a24b-7c7c-4ee6-a448-720e97927ba8.jpg +3 -0
  45. search_personalization/product_samples/.thumbnails/accessories/4ec7ff5c-f70f-4984-b6c4-c7ef37cc0c09.jpg +3 -0
  46. search_personalization/product_samples/.thumbnails/accessories/563bfd0b-9689-4b85-a65e-fbb18051e773.jpg +3 -0
  47. search_personalization/product_samples/.thumbnails/accessories/564f9ced-8823-45ed-a781-1dc555353dd9.jpg +3 -0
  48. search_personalization/product_samples/.thumbnails/accessories/58e99e9e-8f17-4bda-a82c-f08d3eddacb3.jpg +3 -0
  49. search_personalization/product_samples/.thumbnails/accessories/5af0136a-862b-42fa-a127-958a8bbd2167.jpg +3 -0
  50. search_personalization/product_samples/.thumbnails/accessories/5c010bac-31a6-4ff3-9557-2f5e20b509cc.jpg +3 -0
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AWS Credentials
2
+ AWS_ACCESS_KEY_ID=
3
+ AWS_SECRET_ACCESS_KEY=
4
+ AWS_REGION=us-east-1
5
+
6
+ # OpenSearch
7
+ OPENSEARCH_ENDPOINT=
8
+ OPENSEARCH_MODEL_ID=
9
+
10
+ # Agentic Memory
11
+ MEMORY_CONTAINER_ID=
12
+ BEDROCK_MODEL_ID_FAST=
13
+
14
+ # UBI (User Behavior Insights)
15
+ UBI_EVENTS_ENDPOINT=
16
+ UBI_QUERIES_ENDPOINT=
17
+ UBI_ENABLED=true
18
+ UBI_APPLICATION_NAME=opensearch-ubi
.gitattributes CHANGED
@@ -33,6 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
36
  *.jpg filter=lfs diff=lfs merge=lfs -text
37
  *.jpeg filter=lfs diff=lfs merge=lfs -text
38
  *.png filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+
37
  *.jpg filter=lfs diff=lfs merge=lfs -text
38
  *.jpeg filter=lfs diff=lfs merge=lfs -text
39
  *.png filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -14,4 +14,7 @@ app.zip
14
  app_old/
15
  images_retail/
16
  app_bkp/
17
- .streamlit/secrets.toml
 
 
 
 
14
  app_old/
15
  images_retail/
16
  app_bkp/
17
+ .streamlit/secrets.toml
18
+
19
+ .env
20
+ app_new/
app.py CHANGED
@@ -7,7 +7,10 @@ import urllib.request
7
  import tarfile
8
  from yaml.loader import SafeLoader
9
  import warnings
10
- import utilities.invoke_models as invoke_models
 
 
 
11
  import threading
12
  warnings.filterwarnings("ignore", category=DeprecationWarning)
13
 
@@ -78,7 +81,8 @@ st.markdown("""
78
  padding: 24px 16px;
79
  width: 100%;
80
  margin: 0 auto;
81
- height: 350px;
 
82
  color: white;
83
  box-shadow: 0 0 10px rgba(0,0,0,0.3);
84
  transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out, border 0.3s ease-in-out;
@@ -90,6 +94,33 @@ st.markdown("""
90
  overflow: hidden;
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  .card:hover {
94
  transform: scale(1.06);
95
  transition: all 0.3s ease-in-out;
@@ -178,7 +209,10 @@ st.markdown("""
178
  # Header with logo and title
179
  col_logo, col_title = st.columns([32, 68])
180
  with col_logo:
181
- st.image("/data/app_bkp/images/OS_AI_1_cropped.png", use_container_width=True)
 
 
 
182
 
183
  spacer_col = st.columns(1)[0]
184
  with spacer_col:
@@ -186,7 +220,7 @@ with spacer_col:
186
 
187
 
188
 
189
- col1, col2, col3 = st.columns([1, 1, 1])
190
 
191
  with col1:
192
  st.markdown("""
@@ -212,13 +246,23 @@ with col2:
212
  </div>
213
  </a>
214
  """, unsafe_allow_html=True)
215
- st.markdown("""
216
- </div>
217
- """, unsafe_allow_html=True)
218
-
219
 
 
220
 
221
  with col3:
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  st.markdown("""
223
  <a href="/AI_Shopping_Assistant" target="_self" style="text-decoration: none;">
224
  <div class="card">
@@ -229,9 +273,6 @@ with col3:
229
  </div>
230
  </a>
231
  """, unsafe_allow_html=True)
232
- st.markdown("""
233
- </div>
234
- """, unsafe_allow_html=True)
235
 
236
  st.markdown("""
237
  <style>
 
7
  import tarfile
8
  from yaml.loader import SafeLoader
9
  import warnings
10
+ try:
11
+ import utilities.invoke_models as invoke_models
12
+ except Exception:
13
+ invoke_models = None
14
  import threading
15
  warnings.filterwarnings("ignore", category=DeprecationWarning)
16
 
 
81
  padding: 24px 16px;
82
  width: 100%;
83
  margin: 0 auto;
84
+ min-height: 280px;
85
+ height: auto;
86
  color: white;
87
  box-shadow: 0 0 10px rgba(0,0,0,0.3);
88
  transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out, border 0.3s ease-in-out;
 
94
  overflow: hidden;
95
  }
96
 
97
+ @media (max-width: 768px) {
98
+ .card {
99
+ min-height: 200px;
100
+ padding: 16px 12px;
101
+ }
102
+ .card-header {
103
+ font-size: 60px !important;
104
+ height: 40px !important;
105
+ width: 40px !important;
106
+ }
107
+ .card-text {
108
+ font-size: 24px !important;
109
+ }
110
+ .card-description {
111
+ font-size: 13px !important;
112
+ }
113
+ }
114
+
115
+ @media (min-width: 769px) and (max-width: 1200px) {
116
+ .card {
117
+ min-height: 250px;
118
+ }
119
+ .card-text {
120
+ font-size: 28px !important;
121
+ }
122
+ }
123
+
124
  .card:hover {
125
  transform: scale(1.06);
126
  transition: all 0.3s ease-in-out;
 
209
  # Header with logo and title
210
  col_logo, col_title = st.columns([32, 68])
211
  with col_logo:
212
+ try:
213
+ st.image("/data/app_bkp/images/OS_AI_1_cropped.png", use_container_width=True)
214
+ except Exception:
215
+ st.markdown("### 🔍 OpenSearch AI")
216
 
217
  spacer_col = st.columns(1)[0]
218
  with spacer_col:
 
220
 
221
 
222
 
223
+ col1, col2 = st.columns([1, 1])
224
 
225
  with col1:
226
  st.markdown("""
 
246
  </div>
247
  </a>
248
  """, unsafe_allow_html=True)
 
 
 
 
249
 
250
+ col3, col4 = st.columns([1, 1])
251
 
252
  with col3:
253
+ st.markdown("""
254
+ <a href="/Search_Personalization" target="_self" style="text-decoration: none;">
255
+ <div class="card">
256
+ <div class="card-header">🪄</div>
257
+ <div class="card-text" style="font-size: 31px; color: #e46e08;">Personalization</div>
258
+ <div class="card-description" style="font-size: 16px; color: #ccc; margin-top: 6px;text-align: center;white-space: normal;">
259
+ Explore how agentic memory drives real-time search personalization via UBI clickstream </div>
260
+ </div>
261
+ </a>
262
+ """, unsafe_allow_html=True)
263
+
264
+
265
+ with col4:
266
  st.markdown("""
267
  <a href="/AI_Shopping_Assistant" target="_self" style="text-decoration: none;">
268
  <div class="card">
 
273
  </div>
274
  </a>
275
  """, unsafe_allow_html=True)
 
 
 
276
 
277
  st.markdown("""
278
  <style>
pages/Search_Personalization.py ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Search Personalization — OpenSearch UBI Boutique
3
+ A personalized product search experience powered by OpenSearch agentic memory.
4
+
5
+ This page demonstrates how user behavior (clickstream) data stored in OpenSearch
6
+ can drive real-time search personalization through an agentic memory pipeline.
7
+ """
8
+ import streamlit as st
9
+ from typing import Dict, List, Any, Optional
10
+ from search_personalization.data_loader import get_opensearch_client
11
+ from search_personalization.image_utils import get_image_path, get_thumbnail_path, preload_image_cache
12
+ from opensearchpy import OpenSearchException
13
+ import os
14
+ from dotenv import load_dotenv
15
+ import uuid
16
+ from datetime import datetime, timedelta
17
+
18
+ # Import UBI tracking functions
19
+ from search_personalization.ubi_tracker import (
20
+ track_query, track_event, is_ubi_enabled, validate_ubi_config
21
+ )
22
+
23
+ # Import agentic memory pipeline
24
+ from search_personalization.agentic_memory.demo_runner import run_pipeline_for_persona
25
+
26
+ # Import user preferences
27
+ from search_personalization.user_preferences import get_user_preferences
28
+
29
+ # Load environment variables
30
+ load_dotenv()
31
+
32
+ # UBI Configuration
33
+ UBI_CONFIG = {
34
+ 'events_endpoint': os.getenv('UBI_EVENTS_ENDPOINT'),
35
+ 'queries_endpoint': os.getenv('UBI_QUERIES_ENDPOINT'),
36
+ 'aws_region': os.getenv('AWS_REGION', 'us-east-1'),
37
+ 'debug_mode': os.getenv('UBI_DEBUG_MODE', 'false').lower() == 'true',
38
+ 'enabled': os.getenv('UBI_ENABLED', 'true').lower() == 'true',
39
+ 'application_name': os.getenv('UBI_APPLICATION_NAME', 'opensearch-ubi')
40
+ }
41
+
42
+ # User Personas for clickstream tracking
43
+ USER_PERSONAS = [
44
+ {
45
+ "user_id": "anonymous",
46
+ "name": "Anonymous User",
47
+ "age": 30,
48
+ "gender": "unspecified",
49
+ "location": "General",
50
+ "occupation": "General User",
51
+ "interests": ["General Shopping"],
52
+ "shopping_behavior": "Standard search without personalization",
53
+ "key": "anonymous",
54
+ "emoji": "👤"
55
+ },
56
+ {
57
+ "name": "Sarah",
58
+ "user_id": "user1",
59
+ "age": 32,
60
+ "gender": "female",
61
+ "location": "San Francisco, CA",
62
+ "occupation": "Marketing Manager",
63
+ "interests": ["Technology", "Fitness", "Travel"],
64
+ "shopping_behavior": "Focused on quality and trends.",
65
+ "key": "business_professional",
66
+ "emoji": "👩‍💼"
67
+ },
68
+ {
69
+ "name": "Alex",
70
+ "user_id": "user2",
71
+ "age": 21,
72
+ "gender": "male",
73
+ "location": "Austin, TX",
74
+ "occupation": "Computer Science Student",
75
+ "interests": ["Gaming", "Programming", "Music"],
76
+ "shopping_behavior": "Budget-conscious, looks for deals and discounts",
77
+ "key": "student",
78
+ "emoji": "🎓"
79
+ }
80
+ ]
81
+
82
+ # Validate UBI configuration on startup
83
+ _ubi_valid, _ubi_error = validate_ubi_config()
84
+ if not _ubi_valid:
85
+ print(f"WARNING: {_ubi_error}")
86
+
87
+ SESSION_DURATION = timedelta(minutes=1)
88
+
89
+ # Base path for product images (relative to the search_personalization module)
90
+ _MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
91
+ _PRODUCT_SAMPLES_DIR = os.path.join(
92
+ os.path.dirname(_MODULE_DIR), "search_personalization", "product_samples"
93
+ )
94
+
95
+
96
+ def _ensure_user_session(user_id: str) -> str:
97
+ """Ensure the given user has a valid (non-expired) session."""
98
+ now = datetime.utcnow()
99
+ sessions = st.session_state.get('sp_user_sessions', {})
100
+ user_session = sessions.get(user_id)
101
+
102
+ if user_session is None or (now - user_session['started_at']) >= SESSION_DURATION:
103
+ sessions[user_id] = {
104
+ 'session_id': str(uuid.uuid4()),
105
+ 'started_at': now,
106
+ }
107
+ st.session_state.sp_user_sessions = sessions
108
+
109
+ return sessions[user_id]['session_id']
110
+
111
+
112
+ def _init_session_state() -> None:
113
+ """Initialize session state variables prefixed with sp_ to avoid collision."""
114
+ defaults = {
115
+ 'sp_cart': [],
116
+ 'sp_products': [],
117
+ 'sp_filtered_products': [],
118
+ 'sp_search_query': '',
119
+ 'sp_query_id': 'browsing',
120
+ 'sp_current_page': 1,
121
+ 'sp_selected_product': None,
122
+ 'sp_page_view': 'categories',
123
+ 'sp_selected_category': None,
124
+ 'sp_products_loaded': False,
125
+ 'sp_load_error': None,
126
+ 'sp_show_opensearch_query': False,
127
+ 'sp_show_agent_reasoning': False,
128
+ 'sp_last_opensearch_query': None,
129
+ 'sp_selected_user_id': 'anonymous',
130
+ 'sp_pending_query_tracking': False,
131
+ 'sp_last_search_product_ids': [],
132
+ 'sp_ubi_client_id': str(uuid.uuid4()),
133
+ 'sp_user_sessions': {},
134
+ 'sp_user_preferences': None,
135
+ 'sp_preferences_applied': False,
136
+ }
137
+ for key, default in defaults.items():
138
+ if key not in st.session_state:
139
+ st.session_state[key] = default
140
+
141
+ # Ensure current user has an active session
142
+ _ensure_user_session(st.session_state.sp_selected_user_id)
143
+ st.session_state.sp_ubi_session_id = (
144
+ st.session_state.sp_user_sessions[st.session_state.sp_selected_user_id]['session_id']
145
+ )
146
+
147
+ # Load user preferences on first run
148
+ if (st.session_state.sp_user_preferences is None
149
+ and st.session_state.sp_selected_user_id != 'anonymous'):
150
+ _load_and_apply_preferences(st.session_state.sp_selected_user_id)
151
+
152
+
153
+ def _load_and_apply_preferences(user_id: str) -> None:
154
+ """Load preferences from OpenSearch for the user."""
155
+ if user_id == 'anonymous':
156
+ st.session_state.sp_user_preferences = None
157
+ st.session_state.sp_preferences_applied = False
158
+ return
159
+ prefs = get_user_preferences(user_id)
160
+ st.session_state.sp_user_preferences = prefs
161
+ st.session_state.sp_preferences_applied = False
162
+
163
+
164
+ def _load_products() -> None:
165
+ """Load all products from OpenSearch."""
166
+ if st.session_state.sp_products_loaded:
167
+ return
168
+ try:
169
+ client = get_opensearch_client()
170
+ response = client.search(
171
+ index='products',
172
+ body={
173
+ "query": {"match_all": {}},
174
+ "size": 10000,
175
+ "_source": {"excludes": ["image_binary", "product_description_vector"]}
176
+ }
177
+ )
178
+ products = [hit['_source'] for hit in response['hits']['hits']]
179
+ st.session_state.sp_products = products
180
+ st.session_state.sp_filtered_products = products
181
+ st.session_state.sp_products_loaded = True
182
+ st.session_state.sp_load_error = None
183
+ if products:
184
+ preload_image_cache(products)
185
+ except Exception as e:
186
+ st.session_state.sp_load_error = str(e)
187
+ st.session_state.sp_products_loaded = False
188
+ st.session_state.sp_products = []
189
+ st.session_state.sp_filtered_products = []
190
+
191
+
192
+ def _get_product_by_id(product_id: str) -> Optional[Dict[str, Any]]:
193
+ """Get product by ID from session state."""
194
+ for product in st.session_state.sp_products:
195
+ if product.get('id') == product_id:
196
+ return product
197
+ return None
198
+
199
+
200
+ def _add_to_cart(product_id: str) -> None:
201
+ """Add a product to the shopping cart and track with UBI."""
202
+ st.session_state.sp_cart.append(product_id)
203
+ if is_ubi_enabled():
204
+ try:
205
+ product = _get_product_by_id(product_id)
206
+ if product:
207
+ query_id = st.session_state.get('sp_query_id', 'browsing')
208
+ event_attributes = {
209
+ 'object': {
210
+ 'object_id': product_id,
211
+ 'object_id_field': 'id',
212
+ 'description': product.get('description', '')
213
+ },
214
+ 'position': {'ordinal': 0},
215
+ 'product_name': product.get('name', ''),
216
+ 'product_price': product.get('price', 0),
217
+ 'product_category': product.get('category', '')
218
+ }
219
+ track_event(
220
+ action_name='add_to_cart',
221
+ query_id=query_id,
222
+ object_id=product_id,
223
+ event_attributes=event_attributes,
224
+ message=f"Added {product.get('name', 'product')} to cart",
225
+ message_type='CONVERSION',
226
+ user_id=st.session_state.sp_selected_user_id
227
+ )
228
+ except Exception:
229
+ pass
230
+
231
+
232
+ def _get_cart_items() -> List[Dict[str, Any]]:
233
+ """Get cart items with quantities."""
234
+ from collections import Counter
235
+ product_counts = Counter(st.session_state.sp_cart)
236
+ cart_items = []
237
+ for product_id, quantity in product_counts.items():
238
+ product = _get_product_by_id(product_id)
239
+ if product:
240
+ cart_items.append({
241
+ 'product': product,
242
+ 'quantity': quantity,
243
+ 'product_id': product_id
244
+ })
245
+ return cart_items
246
+
247
+
248
+ def _calculate_cart_total() -> float:
249
+ """Calculate the total price of all items in the cart."""
250
+ cart_items = _get_cart_items()
251
+ return sum(item['product'].get('price', 0) * item['quantity'] for item in cart_items)
252
+
253
+
254
+ # --- Mapping from user_id to persona name for agentic memory ---
255
+ _PERSONA_NAME_MAP = {"user1": "sarah", "user2": "alex"}
256
+
257
+
258
+ def _search_products(search_query: str) -> tuple:
259
+ """Search products in OpenSearch using neural knn search."""
260
+ try:
261
+ client = get_opensearch_client()
262
+ if not search_query or search_query.strip() == '':
263
+ query_body = {"query": {"match_all": {}}, "size": 10}
264
+ else:
265
+ query_body = {
266
+ "query": {
267
+ "neural": {
268
+ "product_description_vector": {
269
+ "query_text": search_query.strip(),
270
+ "model_id": os.getenv('OPENSEARCH_MODEL_ID', ''),
271
+ "k": 50
272
+ }
273
+ }
274
+ },
275
+ "size": 10
276
+ }
277
+ import json
278
+ st.session_state.sp_last_opensearch_query = json.dumps(query_body, indent=2)
279
+ response = client.search(index='products', body=query_body)
280
+ products = []
281
+ product_ids = []
282
+ for hit in response['hits']['hits']:
283
+ product = hit['_source']
284
+ products.append(product)
285
+ product_ids.append(product.get('id', hit.get('_id')))
286
+ total_count = response['hits']['total']['value']
287
+ return products, total_count, product_ids
288
+ except Exception as e:
289
+ st.error(f"Search error: {str(e)}")
290
+ return [], 0, []
291
+
292
+
293
+ def _agentic_search(search_query: str, user_id: str) -> tuple:
294
+ """Run the agentic memory pipeline for personalized search."""
295
+ import json as _json
296
+
297
+ persona_name = _PERSONA_NAME_MAP[user_id]
298
+ session_id = st.session_state.get("sp_ubi_session_id", "web-session")
299
+
300
+ trace = run_pipeline_for_persona(
301
+ query=search_query,
302
+ persona_name=persona_name,
303
+ session_id=session_id,
304
+ )
305
+
306
+ # Extract explanation
307
+ _explanation_data = trace.get("agents", {}).get("explanation", {})
308
+ explanation = _explanation_data.get("output")
309
+ if explanation is None and "_future" in _explanation_data:
310
+ _future = _explanation_data["_future"]
311
+ try:
312
+ _future.result(timeout=3.0)
313
+ explanation = _future.result() if _future.done() else None
314
+ except Exception:
315
+ explanation = None
316
+
317
+ # Extract enriched query and inferred attributes
318
+ query_agent_output = trace["agents"].get("query", {}).get("output", "")
319
+ enriched_query_text = search_query
320
+ inferred_attrs = {}
321
+ try:
322
+ _qstart = query_agent_output.find("{")
323
+ _qend = query_agent_output.rfind("}") + 1
324
+ if _qstart >= 0 and _qend > _qstart:
325
+ _parsed_query = _json.loads(query_agent_output[_qstart:_qend])
326
+ enriched_query_text = _parsed_query.get("enriched_query", search_query)
327
+ inferred_attrs = _parsed_query.get("inferred_attributes", {})
328
+ except (_json.JSONDecodeError, TypeError):
329
+ pass
330
+
331
+ # Store the OpenSearch query for display
332
+ actual_os_query = trace.get("opensearch_query")
333
+ if actual_os_query:
334
+ st.session_state.sp_last_opensearch_query = _json.dumps(actual_os_query, indent=2)
335
+
336
+ # Parse ranked product IDs from ranking agent
337
+ ranking_output = trace["agents"].get("ranking", {}).get("output", "")
338
+ ranked_product_ids = []
339
+ personalization_summary = None
340
+ try:
341
+ start = ranking_output.find("{")
342
+ end = ranking_output.rfind("}") + 1
343
+ if start >= 0 and end > start:
344
+ parsed = _json.loads(ranking_output[start:end])
345
+ for item in parsed.get("results", []):
346
+ pid = item.get("product_id") or item.get("id")
347
+ if pid:
348
+ ranked_product_ids.append(pid)
349
+ personalization_summary = parsed.get("personalization_summary")
350
+ except (_json.JSONDecodeError, TypeError):
351
+ pass
352
+
353
+ st.session_state._sp_personalization = personalization_summary
354
+
355
+ # Store reasoning trace
356
+ _reranked_count = len(ranked_product_ids)
357
+ st.session_state._sp_reasoning = {
358
+ "query_enrichment": {
359
+ "original_query": search_query,
360
+ "enriched_query": enriched_query_text,
361
+ "inferred_attributes": inferred_attrs,
362
+ },
363
+ "reranking": {
364
+ "model": "Cohere Rerank 3.5 (via Bedrock)",
365
+ "results_reranked": _reranked_count,
366
+ },
367
+ "aversion_filter": {
368
+ "aversions_from_profile": inferred_attrs.get("aversions", []),
369
+ },
370
+ "personalization_summary": personalization_summary,
371
+ "pipeline_duration_ms": trace.get("total_duration_ms"),
372
+ }
373
+
374
+ # Resolve product IDs to full product dicts
375
+ products = []
376
+ product_ids = []
377
+ for pid in ranked_product_ids:
378
+ product = _get_product_by_id(pid)
379
+ if product:
380
+ products.append(product)
381
+ product_ids.append(pid)
382
+
383
+ # Fallback to regular search if pipeline returned nothing
384
+ if not products:
385
+ products, _, product_ids = _search_products(enriched_query_text)
386
+ return products, len(products), product_ids, explanation
387
+
388
+ return products, len(products), product_ids, explanation
389
+
390
+
391
+ # ─── UI COMPONENTS ───────────────────────────────────────────────────────────
392
+
393
+
394
+ def _render_header() -> None:
395
+ """Render the header with title on top, then search bar + persona + cart below."""
396
+ # Home button
397
+ st.page_link("app.py", label=":orange[Home]", icon="🏠")
398
+ # Title on its own row
399
+ st.markdown(
400
+ '<h1 style="font-size:1.8rem;font-weight:bold;color:#ffffff;margin:0 0 0.5rem 0;">'
401
+ 'Search personalization with agentic memory and UBI data</h1>',
402
+ unsafe_allow_html=True
403
+ )
404
+
405
+ # Search bar, persona selector, cart on second row
406
+ header_col2, header_col3, header_col4 = st.columns([4, 1.5, 0.5])
407
+
408
+ with header_col2:
409
+ search_query = st.text_input(
410
+ "Search", placeholder="What are you looking for?",
411
+ label_visibility="collapsed", key="sp_header_search"
412
+ )
413
+ if search_query != st.session_state.sp_search_query:
414
+ st.session_state.sp_search_query = search_query
415
+ if search_query and search_query.strip():
416
+ st.session_state.sp_page_view = 'grid'
417
+ st.session_state.sp_selected_category = None
418
+ else:
419
+ st.session_state.sp_page_view = 'categories'
420
+ st.session_state.sp_selected_category = None
421
+ new_query_id = str(uuid.uuid4())
422
+ st.session_state.sp_query_id = new_query_id
423
+ st.session_state.sp_pending_query_tracking = True
424
+ if is_ubi_enabled():
425
+ try:
426
+ query_text = search_query.strip() if search_query and search_query.strip() else "match_all"
427
+ track_event(
428
+ action_name="search", query_id=new_query_id,
429
+ event_attributes={"query_text": query_text},
430
+ message=f"User searched for: {query_text}",
431
+ user_id=st.session_state.sp_selected_user_id
432
+ )
433
+ except Exception:
434
+ pass
435
+
436
+ with header_col3:
437
+ persona_options = [f"{p['emoji']} {p['name']}" for p in USER_PERSONAS]
438
+ current_idx = next(
439
+ (i for i, p in enumerate(USER_PERSONAS)
440
+ if p['user_id'] == st.session_state.sp_selected_user_id), 0
441
+ )
442
+ selected_persona = st.selectbox(
443
+ "User", options=persona_options, index=current_idx,
444
+ label_visibility="collapsed", key="sp_persona_selector",
445
+ help="Select a user persona for clickstream tracking"
446
+ )
447
+ selected_index = persona_options.index(selected_persona)
448
+ new_user_id = USER_PERSONAS[selected_index]['user_id']
449
+ if new_user_id != st.session_state.sp_selected_user_id:
450
+ st.session_state.sp_selected_user_id = new_user_id
451
+ st.session_state.sp_ubi_session_id = _ensure_user_session(new_user_id)
452
+ st.session_state.sp_query_id = str(uuid.uuid4())
453
+ # Clear agentic pipeline cache
454
+ st.session_state.pop("_sp_cache_key", None)
455
+ st.session_state.pop("_sp_cache", None)
456
+ st.session_state.pop("_sp_reasoning", None)
457
+ st.session_state.sp_last_opensearch_query = None
458
+ _load_and_apply_preferences(new_user_id)
459
+
460
+ with header_col4:
461
+ cart_count = len(st.session_state.sp_cart)
462
+ if st.button(f"🛒 {cart_count}", key="sp_cart_button", help="View Cart"):
463
+ st.session_state.sp_page_view = 'cart'
464
+ st.rerun()
465
+
466
+
467
+ def _render_product_card(product: Dict[str, Any], position: int = 0) -> None:
468
+ """Render a single product card with actions."""
469
+ product_id = product.get('id', '')
470
+ product_name = product.get('name', 'Unknown Product')
471
+ price = product.get('price', 0)
472
+ current_stock = product.get('current_stock', 0)
473
+ category = product.get('category', 'Unknown')
474
+ image_path = get_thumbnail_path(product)
475
+ query_id = st.session_state.get('sp_query_id', 'browsing')
476
+ user_id = st.session_state.get('sp_selected_user_id', 'anonymous')
477
+
478
+ col_image, col_content = st.columns([1, 4])
479
+
480
+ with col_image:
481
+ try:
482
+ st.image(image_path, use_container_width=True)
483
+ except Exception:
484
+ st.image(
485
+ os.path.join(_PRODUCT_SAMPLES_DIR, "product_image_coming_soon.png"),
486
+ use_container_width=True
487
+ )
488
+
489
+ with col_content:
490
+ st.markdown(f"**{product_name}**")
491
+ price_col, stock_col = st.columns([1, 2])
492
+ with price_col:
493
+ st.markdown(f"${price:,.2f}")
494
+ with stock_col:
495
+ if current_stock < 10:
496
+ st.markdown(f"⚠️ {current_stock} items left")
497
+ else:
498
+ st.caption(f"{current_stock} items left")
499
+
500
+ btn1, btn2, btn3, btn4 = st.columns([1, 1, 1, 2])
501
+ with btn1:
502
+ if st.button("🛒 Add", key=f"sp_cart_{product_id}", type="primary"):
503
+ _add_to_cart(product_id)
504
+ st.rerun()
505
+ with btn2:
506
+ if st.button("❤️", key=f"sp_like_{product_id}"):
507
+ if is_ubi_enabled():
508
+ try:
509
+ track_event(
510
+ action_name='user_feedback', query_id=query_id,
511
+ object_id=product_id,
512
+ event_attributes={
513
+ 'object': {'object_id': product_id, 'object_id_field': 'id'},
514
+ 'position': {'ordinal': position},
515
+ 'feedback': 'thumbs_up'
516
+ },
517
+ message=f"Thumbs up: {product_name}",
518
+ message_type='LIKE', user_id=user_id
519
+ )
520
+ except Exception:
521
+ pass
522
+ st.toast(f"You liked {product_name}!")
523
+
524
+ with btn3:
525
+ if st.button("👎", key=f"sp_dislike_{product_id}"):
526
+ if is_ubi_enabled():
527
+ try:
528
+ track_event(
529
+ action_name='user_feedback', query_id=query_id,
530
+ object_id=product_id,
531
+ event_attributes={
532
+ 'object': {'object_id': product_id, 'object_id_field': 'id'},
533
+ 'position': {'ordinal': position},
534
+ 'feedback': 'thumbs_down'
535
+ },
536
+ message=f"Thumbs down: {product_name}",
537
+ message_type='REJECT', user_id=user_id
538
+ )
539
+ except Exception:
540
+ pass
541
+ st.toast(f"You disliked {product_name}!")
542
+ with btn4:
543
+ if st.button("View Details", key=f"sp_details_{product_id}", type="secondary"):
544
+ if is_ubi_enabled():
545
+ try:
546
+ track_event(
547
+ action_name='view_product_details', query_id=query_id,
548
+ object_id=product_id,
549
+ event_attributes={
550
+ 'object': {'object_id': product_id, 'object_id_field': 'id'},
551
+ 'position': {'ordinal': position},
552
+ },
553
+ message=f"Viewed: {product_name}",
554
+ user_id=user_id
555
+ )
556
+ except Exception:
557
+ pass
558
+ st.session_state.sp_selected_product = product_id
559
+ st.session_state.sp_page_view = 'details'
560
+ st.rerun()
561
+
562
+ st.divider()
563
+
564
+
565
+ def _render_categories() -> None:
566
+ """Render the category landing page."""
567
+ categories = sorted(set(
568
+ p.get('category', '').lower()
569
+ for p in st.session_state.sp_products if p.get('category')
570
+ ))
571
+ if not categories:
572
+ st.info("No categories available.")
573
+ return
574
+
575
+ category_meta = {
576
+ 'accessories': {'emoji': '👜', 'desc': 'Bags, watches, belts and more'},
577
+ 'apparel': {'emoji': '👕', 'desc': 'Shirts, jackets, pants and dresses'},
578
+ 'electronics': {'emoji': '💻', 'desc': 'Gadgets, devices and tech gear'},
579
+ 'footwear': {'emoji': '👟', 'desc': 'Shoes, boots, sneakers and sandals'},
580
+ 'jewelry': {'emoji': '💍', 'desc': 'Rings, necklaces, bracelets and earrings'},
581
+ }
582
+
583
+ st.markdown("### Shop by Category")
584
+ cols_per_row = 3
585
+ for row_start in range(0, len(categories), cols_per_row):
586
+ row_cats = categories[row_start:row_start + cols_per_row]
587
+ cols = st.columns(cols_per_row)
588
+ for idx, cat in enumerate(row_cats):
589
+ meta = category_meta.get(cat, {'emoji': '📦', 'desc': ''})
590
+ with cols[idx]:
591
+ # Show sample thumbnail
592
+ thumb_dir = os.path.join(_PRODUCT_SAMPLES_DIR, ".thumbnails", cat)
593
+ sample_image = None
594
+ if os.path.isdir(thumb_dir):
595
+ images = [f for f in os.listdir(thumb_dir) if f.endswith('.jpg')]
596
+ if images:
597
+ sample_image = os.path.join(thumb_dir, images[0])
598
+ if sample_image:
599
+ st.image(sample_image, use_container_width=True)
600
+ else:
601
+ st.markdown(f"<div style='font-size:4rem;text-align:center;'>{meta['emoji']}</div>",
602
+ unsafe_allow_html=True)
603
+ st.markdown(f"**{meta['emoji']} {cat.title()}**")
604
+ st.caption(meta['desc'])
605
+ if st.button(f"Browse {cat.title()}", key=f"sp_cat_{cat}", use_container_width=True):
606
+ st.session_state.sp_selected_category = cat
607
+ st.session_state.sp_page_view = 'grid'
608
+ st.rerun()
609
+
610
+
611
+ def _render_product_grid(products: List[Dict[str, Any]]) -> None:
612
+ """Render the product list."""
613
+ if not products:
614
+ st.markdown("### 🔍 No results found")
615
+ st.caption("Try adjusting your search query to see more products.")
616
+ return
617
+ st.markdown(f"**Showing {len(products)} products**")
618
+ for i, product in enumerate(products):
619
+ _render_product_card(product, position=i + 1)
620
+
621
+
622
+ def _render_product_details(product_id: str) -> None:
623
+ """Render the product details page."""
624
+ product = _get_product_by_id(product_id)
625
+ if not product:
626
+ st.error("Product not found")
627
+ if st.button("← Back to Products", type="primary"):
628
+ st.session_state.sp_page_view = 'grid'
629
+ st.session_state.sp_selected_product = None
630
+ st.rerun()
631
+ return
632
+
633
+ if st.button("← Back to Products", key="sp_back_details", type="secondary"):
634
+ st.session_state.sp_page_view = 'grid'
635
+ st.session_state.sp_selected_product = None
636
+ st.rerun()
637
+
638
+ st.divider()
639
+ product_name = product.get('name', 'Unknown')
640
+ price = product.get('price', 0)
641
+ description = product.get('description', '')
642
+ category = product.get('category', '')
643
+ style = product.get('style', '')
644
+ current_stock = product.get('current_stock', 0)
645
+ image_path = get_thumbnail_path(product)
646
+
647
+ col_img, col_info = st.columns([1, 1], gap="large")
648
+ with col_img:
649
+ try:
650
+ st.image(image_path, use_container_width=True)
651
+ except Exception:
652
+ st.image(os.path.join(_PRODUCT_SAMPLES_DIR, "product_image_coming_soon.png"),
653
+ use_container_width=True)
654
+ with col_info:
655
+ st.markdown(f"## {product_name}")
656
+ st.markdown(description)
657
+ st.divider()
658
+ st.markdown(f"### ${price:,.2f}")
659
+ if current_stock > 10:
660
+ st.success(f"✓ In Stock ({current_stock} available)")
661
+ elif current_stock > 0:
662
+ st.warning(f"⚠️ Low Stock ({current_stock} left)")
663
+ else:
664
+ st.error("✗ Out of Stock")
665
+ if current_stock > 0:
666
+ if st.button("🛒 Add to Cart", key="sp_detail_add", type="primary", use_container_width=True):
667
+ _add_to_cart(product_id)
668
+ st.success(f"✓ Added to cart!")
669
+ st.rerun()
670
+ st.divider()
671
+ c1, c2, c3 = st.columns(3)
672
+ c1.metric("Category", category.title())
673
+ c2.metric("Style", style.title())
674
+ gender = product.get('gender_affinity', '')
675
+ c3.metric("Gender", "Women" if gender == "F" else "Men" if gender == "M" else "Unisex")
676
+
677
+
678
+ def _render_cart() -> None:
679
+ """Render the shopping cart view."""
680
+ if st.button("← Continue Shopping", key="sp_back_shop", type="secondary"):
681
+ st.session_state.sp_page_view = 'categories'
682
+ st.session_state.sp_selected_category = None
683
+ st.rerun()
684
+
685
+ st.divider()
686
+ cart_count = len(st.session_state.sp_cart)
687
+ st.markdown(f"### 🛒 Shopping Cart ({cart_count} items)")
688
+
689
+ if cart_count == 0:
690
+ st.info("Your cart is empty. Add some products to get started!")
691
+ if st.button("Browse Products", type="primary", use_container_width=True):
692
+ st.session_state.sp_page_view = 'categories'
693
+ st.rerun()
694
+ return
695
+
696
+ cart_items = _get_cart_items()
697
+ for idx, item in enumerate(cart_items):
698
+ product = item['product']
699
+ quantity = item['quantity']
700
+ product_id = item['product_id']
701
+ image_path = get_thumbnail_path(product)
702
+
703
+ col_img, col_det, col_act = st.columns([1, 3, 1])
704
+ with col_img:
705
+ try:
706
+ st.image(image_path, use_container_width=True)
707
+ except Exception:
708
+ st.image(os.path.join(_PRODUCT_SAMPLES_DIR, "product_image_coming_soon.png"),
709
+ use_container_width=True)
710
+ with col_det:
711
+ st.markdown(f"**{product.get('name', 'Unknown')}**")
712
+ st.caption(f"Category: {product.get('category', '').title()}")
713
+ st.markdown(f"${product.get('price', 0):,.2f} × {quantity} = "
714
+ f"**${product.get('price', 0) * quantity:,.2f}**")
715
+ with col_act:
716
+ if st.button("➖", key=f"sp_rm1_{idx}"):
717
+ if product_id in st.session_state.sp_cart:
718
+ st.session_state.sp_cart.remove(product_id)
719
+ st.rerun()
720
+ if st.button("🗑️", key=f"sp_rmall_{idx}"):
721
+ st.session_state.sp_cart = [
722
+ pid for pid in st.session_state.sp_cart if pid != product_id
723
+ ]
724
+ st.rerun()
725
+ st.divider()
726
+
727
+ # Cart summary
728
+ total = _calculate_cart_total()
729
+ st.markdown(f"### Total: ${total:,.2f}")
730
+
731
+ col_clear, col_checkout = st.columns(2)
732
+ with col_clear:
733
+ if st.button("🗑️ Clear Cart", key="sp_clear_cart"):
734
+ st.session_state.sp_cart = []
735
+ st.rerun()
736
+ with col_checkout:
737
+ if st.button("💳 Checkout", key="sp_checkout", type="primary"):
738
+ if is_ubi_enabled():
739
+ try:
740
+ track_event(
741
+ action_name='checkout',
742
+ query_id=st.session_state.get('sp_query_id', 'browsing'),
743
+ event_attributes={
744
+ 'cart_total': total,
745
+ 'cart_item_count': cart_count,
746
+ },
747
+ message=f"Checkout: {cart_count} items (${total:.2f})",
748
+ message_type='CONVERSION',
749
+ user_id=st.session_state.sp_selected_user_id
750
+ )
751
+ except Exception:
752
+ pass
753
+ st.session_state.sp_cart = []
754
+ st.success("✓ Thank you for your purchase!")
755
+ st.rerun()
756
+
757
+
758
+ def _render_developer_sidebar() -> None:
759
+ """Render developer tools in the sidebar."""
760
+ with st.sidebar:
761
+ st.subheader("🔧 Developer Tools")
762
+ show_query = st.checkbox("Show OpenSearch Query", key="sp_show_query")
763
+ if show_query and st.session_state.sp_last_opensearch_query:
764
+ st.markdown("**Current Query:**")
765
+ st.code(st.session_state.sp_last_opensearch_query, language="json")
766
+ elif show_query:
767
+ st.info("No query executed yet.")
768
+
769
+ show_reasoning = st.checkbox("Show Agent Reasoning", key="sp_show_reasoning")
770
+ if show_reasoning:
771
+ reasoning = st.session_state.get("_sp_reasoning")
772
+ if reasoning:
773
+ st.markdown("---")
774
+ st.markdown("#### 🧠 Agent Reasoning")
775
+ enrichment = reasoning.get("query_enrichment", {})
776
+ with st.expander("1. Query Enrichment", expanded=True):
777
+ st.markdown(f"**Original:** `{enrichment.get('original_query', '')}`")
778
+ st.markdown(f"**Enriched:** `{enrichment.get('enriched_query', '')}`")
779
+ attrs = enrichment.get("inferred_attributes", {})
780
+ if attrs:
781
+ for k, v in attrs.items():
782
+ if v:
783
+ st.markdown(f"- {k}: `{v}`")
784
+ reranking = reasoning.get("reranking", {})
785
+ with st.expander("2. Reranking", expanded=True):
786
+ st.markdown(f"**Model:** {reranking.get('model', 'N/A')}")
787
+ st.markdown(f"**Results reranked:** {reranking.get('results_reranked', 0)}")
788
+ aversion = reasoning.get("aversion_filter", {})
789
+ with st.expander("3. Aversions", expanded=True):
790
+ aversions = aversion.get("aversions_from_profile", [])
791
+ if aversions:
792
+ st.markdown(f"**Profile aversions:** {', '.join(f'`{a}`' for a in aversions)}")
793
+ else:
794
+ st.markdown("No aversions detected.")
795
+ duration = reasoning.get("pipeline_duration_ms")
796
+ if duration:
797
+ st.caption(f"⏱️ Pipeline: {duration}ms")
798
+ else:
799
+ st.info("Select Sarah or Alex and search to see reasoning.")
800
+
801
+
802
+ # ─── MAIN PAGE ────────────────────────────────────────────────────────────────
803
+
804
+
805
+ def main() -> None:
806
+ """Main entry point for the Search Personalization page."""
807
+ _init_session_state()
808
+
809
+ # Refresh the current user's session
810
+ st.session_state.sp_ubi_session_id = _ensure_user_session(
811
+ st.session_state.sp_selected_user_id
812
+ )
813
+
814
+ # Render header (search + persona + cart)
815
+ _render_header()
816
+ st.divider()
817
+
818
+ # Route to the correct view
819
+ if st.session_state.sp_page_view == 'details' and st.session_state.sp_selected_product:
820
+ _render_product_details(st.session_state.sp_selected_product)
821
+ _render_developer_sidebar()
822
+ return
823
+
824
+ if st.session_state.sp_page_view == 'cart':
825
+ _render_cart()
826
+ return
827
+
828
+ # Load products
829
+ if not st.session_state.sp_products_loaded and not st.session_state.sp_load_error:
830
+ with st.spinner("🔄 Loading products..."):
831
+ _load_products()
832
+ else:
833
+ _load_products()
834
+
835
+ # Categories landing
836
+ if st.session_state.sp_page_view == 'categories' and not st.session_state.sp_search_query:
837
+ if st.session_state.sp_products_loaded and st.session_state.sp_products:
838
+ _render_categories()
839
+ _render_developer_sidebar()
840
+ return
841
+
842
+ # Error states
843
+ if st.session_state.sp_load_error:
844
+ st.error("❌ Unable to connect to the product catalog.")
845
+ st.info("Check your AWS credentials and OpenSearch endpoint.")
846
+ if st.button("🔄 Retry", key="sp_retry", type="primary"):
847
+ st.session_state.sp_products_loaded = False
848
+ st.session_state.sp_load_error = None
849
+ st.rerun()
850
+ return
851
+
852
+ if not st.session_state.sp_products_loaded:
853
+ st.info("⏳ Loading products...")
854
+ return
855
+
856
+ if not st.session_state.sp_products:
857
+ st.warning("No products available in the catalog.")
858
+ return
859
+
860
+ # ─── Search + product grid ───
861
+ search_query = st.session_state.sp_search_query
862
+ page_size = 10
863
+
864
+ # Back to categories button when browsing a category
865
+ if st.session_state.sp_selected_category and not (search_query and search_query.strip()):
866
+ if st.button("← Back to Categories", key="sp_back_cats", type="secondary"):
867
+ st.session_state.sp_selected_category = None
868
+ st.session_state.sp_page_view = 'categories'
869
+ st.rerun()
870
+ st.markdown(f"## {st.session_state.sp_selected_category.title()}")
871
+
872
+ # Perform search
873
+ if search_query and search_query.strip():
874
+ persona_user_id = st.session_state.sp_selected_user_id
875
+ if persona_user_id in _PERSONA_NAME_MAP:
876
+ cache_key = f"{persona_user_id}:{search_query}"
877
+ if st.session_state.get("_sp_cache_key") != cache_key:
878
+ products_to_display, total_count, product_ids, explanation = (
879
+ _agentic_search(search_query, persona_user_id)
880
+ )
881
+ st.session_state._sp_cache_key = cache_key
882
+ st.session_state._sp_cache = (products_to_display, total_count, product_ids, explanation)
883
+ else:
884
+ products_to_display, total_count, product_ids, explanation = st.session_state._sp_cache
885
+ else:
886
+ products_to_display, total_count, product_ids = _search_products(search_query)
887
+ explanation = None
888
+ else:
889
+ all_products = st.session_state.sp_products
890
+ if st.session_state.sp_selected_category:
891
+ all_products = [
892
+ p for p in all_products
893
+ if p.get('category', '').lower() == st.session_state.sp_selected_category
894
+ ]
895
+ total_count = len(all_products)
896
+ start = (st.session_state.sp_current_page - 1) * page_size
897
+ products_to_display = all_products[start:start + page_size]
898
+ product_ids = [p.get('id', '') for p in products_to_display]
899
+ explanation = None
900
+
901
+ # UBI query tracking (non-blocking)
902
+ if is_ubi_enabled() and st.session_state.get('sp_pending_query_tracking', False):
903
+ st.session_state.sp_pending_query_tracking = False
904
+ import threading
905
+ _q_text = search_query.strip() if search_query and search_query.strip() else "match_all"
906
+ _q_id = st.session_state.get('sp_query_id', 'browsing')
907
+ _q_uid = st.session_state.sp_selected_user_id
908
+ _q_hits = list(product_ids)
909
+ def _track():
910
+ try:
911
+ track_query(_q_text, _q_id, user_id=_q_uid, query_response_hit_ids=_q_hits)
912
+ except Exception:
913
+ pass
914
+ threading.Thread(target=_track, daemon=True).start()
915
+
916
+ # Show search info
917
+ if search_query and search_query.strip():
918
+ if not products_to_display:
919
+ st.info(f"🔍 No results found for '{search_query}'")
920
+ else:
921
+ if explanation:
922
+ st.info(f"💡 {explanation}")
923
+ personalization = st.session_state.get("_sp_personalization")
924
+ persona_user_id = st.session_state.sp_selected_user_id
925
+ if persona_user_id in _PERSONA_NAME_MAP and personalization and "none" not in personalization.lower():
926
+ persona_name = _PERSONA_NAME_MAP.get(persona_user_id, "").title()
927
+ st.caption(f"📊 Personalized results for {persona_name}")
928
+
929
+ # Render product grid
930
+ _render_product_grid(products_to_display)
931
+
932
+ # Developer tools sidebar
933
+ _render_developer_sidebar()
934
+
935
+
936
+ # Run the page
937
+ main()
requirements.txt CHANGED
@@ -19,3 +19,8 @@ einops
19
  seaborn
20
  opentelemetry-sdk
21
  opentelemetry-exporter-otlp
 
 
 
 
 
 
19
  seaborn
20
  opentelemetry-sdk
21
  opentelemetry-exporter-otlp
22
+
23
+ python-dotenv
24
+ strands-agents
25
+ strands-agents-tools
26
+ PyYAML
search_personalization/IAM_SETUP.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # IAM Setup for Search Personalization Module
2
+
3
+ This document describes the least-privilege IAM configuration required to run the
4
+ Search Personalization module on Hugging Face Spaces (or any environment without
5
+ `~/.aws/config` profiles).
6
+
7
+ ## AWS Account
8
+
9
+ - **Account ID**: `099713751195`
10
+ - **Region**: `us-east-1` (primary), `us-west-2` (Cohere Rerank only)
11
+
12
+ > **Important**: The domain name in the IAM policy ARNs is `mydomain`. Verify your
13
+ > actual OpenSearch domain name matches. You can find it in the AWS Console under
14
+ > OpenSearch Service → Domains, or by inspecting the `OPENSEARCH_ENDPOINT` URL
15
+ > (the segment between `search-` and the hash is the domain name).
16
+
17
+ ## Resources Accessed
18
+
19
+ | AWS Service | Resource | Operations |
20
+ |-------------|----------|------------|
21
+ | **OpenSearch** (`mydomain`) | `products` index | Neural kNN search, read mapping + aggregations |
22
+ | **OpenSearch** (`mydomain`) | `user_preferences` index | Read/write preferences (upsert by user_id), create index |
23
+ | **OpenSearch** (`mydomain`) | `personas` index | Search |
24
+ | **OpenSearch** (`mydomain`) | `_plugins/_ml/memory_containers/*` | Read long-term/session/working/history memories, write memories |
25
+ | **Bedrock** (us-east-1) | `us.amazon.nova-micro-v1:0` | Agent reasoning (Query, Ranking, Explanation agents via Strands — uses streaming) |
26
+ | **Bedrock** (us-west-2) | `cohere.rerank-v3-5:0` | Semantic reranking of search results (non-streaming) |
27
+ | **OSIS** (us-east-1) | `ubi-events-pipeline-*` | POST UBI clickstream events (`/ubi/events`) |
28
+ | **OSIS** (us-east-1) | `ubi-queries-pipeline-*` | POST UBI query tracking data (`/ubi/queries`) |
29
+
30
+ ## Step 1: Create IAM User
31
+
32
+ ```bash
33
+ aws iam create-user --user-name hf-opensearch-personalization
34
+ ```
35
+
36
+ ## Step 2: Attach the IAM Policy
37
+
38
+ The policy file is at `search_personalization/iam-policy.json`.
39
+
40
+ ```bash
41
+ aws iam put-user-policy \
42
+ --user-name hf-opensearch-personalization \
43
+ --policy-name OpenSearchPersonalizationAccess \
44
+ --policy-document file://search_personalization/iam-policy.json
45
+ ```
46
+
47
+ ## Step 3: Create Access Keys
48
+
49
+ ```bash
50
+ aws iam create-access-key --user-name hf-opensearch-personalization
51
+ ```
52
+
53
+ Save the `AccessKeyId` and `SecretAccessKey` — you'll need them for HF Spaces.
54
+
55
+ ## Step 4: Configure OpenSearch Fine-Grained Access Control (FGAC)
56
+
57
+ Your domain uses fine-grained access control. You need to map the IAM user to a
58
+ backend role in OpenSearch Dashboards.
59
+
60
+ ### 4a. Create an OpenSearch Security Role
61
+
62
+ In OpenSearch Dashboards → Security → Roles → Create role:
63
+
64
+ **Role name**: `hf_demo_role`
65
+
66
+ **Cluster permissions**:
67
+ ```
68
+ cluster:admin/opensearch/ml/*
69
+ ```
70
+ (Required for the `/_plugins/_ml/memory_containers` API)
71
+
72
+ **Index permissions**:
73
+
74
+ | Index pattern | Allowed actions |
75
+ |---------------|----------------|
76
+ | `products` | `indices:data/read/*` |
77
+ | `personas` | `indices:data/read/*` |
78
+ | `user_preferences` | `indices:data/read/*`, `indices:data/write/*`, `indices:admin/create` |
79
+ | `.plugins-ml-memory*` | `indices:data/read/*`, `indices:data/write/*` |
80
+
81
+ > Note: Memory containers store data in hidden indices prefixed with
82
+ > `.plugins-ml-memory`. The exact pattern depends on your OpenSearch version.
83
+
84
+ ### 4b. Map the IAM User to the Role
85
+
86
+ In OpenSearch Dashboards → Security → Roles → `hf_demo_role` → Mapped users → Map user:
87
+
88
+ Add as **User** type (not Backend role):
89
+ ```
90
+ arn:aws:iam::099713751195:user/hf-opensearch-personalization
91
+ ```
92
+
93
+ > Note: IAM users authenticated via SigV4 are mapped as "User" type in FGAC.
94
+ > "Backend role" type is for IAM *roles* (assumed via STS). This is a common
95
+ > source of confusion in OpenSearch FGAC.
96
+
97
+ ## Step 5: Configure OSIS Pipeline Access
98
+
99
+ Your OSIS pipelines must also allow this IAM user to ingest data. The pipeline's
100
+ IAM role needs to trust this user, OR the pipeline access policy must include:
101
+
102
+ ```json
103
+ {
104
+ "Effect": "Allow",
105
+ "Principal": {
106
+ "AWS": "arn:aws:iam::099713751195:user/hf-opensearch-personalization"
107
+ },
108
+ "Action": "osis:Ingest",
109
+ "Resource": [
110
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-events-pipeline-*",
111
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-queries-pipeline-*"
112
+ ]
113
+ }
114
+ ```
115
+
116
+ ## Step 6: Set HF Space Secrets
117
+
118
+ In your Hugging Face Space settings (Settings → Repository Secrets), add:
119
+
120
+ | Secret Name | Value |
121
+ |-------------|-------|
122
+ | `AWS_ACCESS_KEY_ID` | The access key from Step 3 |
123
+ | `AWS_SECRET_ACCESS_KEY` | The secret key from Step 3 |
124
+ | `AWS_REGION` | `us-east-1` |
125
+ | `OPENSEARCH_ENDPOINT` | `https://search-mydomain-lsbvasigo2g6v5kmbtlov4573i.aos.us-east-1.on.aws` |
126
+ | `OPENSEARCH_MODEL_ID` | `PSZ-g54B5Zk16lKMlvWx` |
127
+ | `OPENSEARCH_INGEST_PIPELINE` | `product-multimodal-pipeline` |
128
+ | `EMBEDDING_MODEL_ID` | `PSZ-g54B5Zk16lKMlvWx` |
129
+ | `EMBEDDING_DIMENSION` | `1024` |
130
+ | `MEMORY_CONTAINER_ID` | `HTcGj54B5Zk16lKMsL49` |
131
+ | `BEDROCK_MODEL_ID_FAST` | `us.amazon.nova-micro-v1:0` |
132
+ | `BEDROCK_MODEL_ID` | `us.anthropic.claude-sonnet-4-6` |
133
+ | `UBI_EVENTS_ENDPOINT` | `https://ubi-events-pipeline-6qdwk2cov2ab44jrvriugly2au.us-east-1.osis.amazonaws.com` |
134
+ | `UBI_QUERIES_ENDPOINT` | `https://ubi-queries-pipeline-kxb6c3jrjlfbgcsq6tttgpeuum.us-east-1.osis.amazonaws.com` |
135
+ | `UBI_ENABLED` | `true` |
136
+ | `UBI_APPLICATION_NAME` | `os-product-catalog` |
137
+
138
+ **Do NOT set** `AWS_PROFILE` in HF Spaces — it's only for local development.
139
+
140
+ ## Step 7: Verify (Optional)
141
+
142
+ After setting up, test connectivity from the Space logs:
143
+
144
+ ```python
145
+ import boto3
146
+ session = boto3.Session()
147
+ sts = session.client('sts')
148
+ print(sts.get_caller_identity())
149
+ # Should show: arn:aws:iam::099713751195:user/hf-opensearch-personalization
150
+ ```
151
+
152
+ ## Security Notes
153
+
154
+ - The IAM user has NO admin access — it can only search/index specific indices and invoke specific models
155
+ - It cannot create/delete the OpenSearch domain itself
156
+ - It cannot access other AWS services (EC2, S3, Lambda, etc.)
157
+ - It cannot modify IAM policies or create new users
158
+ - Rotate keys periodically via `aws iam create-access-key` + `aws iam delete-access-key`
159
+ - Monitor usage via CloudTrail filtered to this IAM user
160
+
161
+ ## Important: What This Policy Does NOT Allow
162
+
163
+ - ❌ Creating or deleting OpenSearch domains
164
+ - ❌ Modifying domain configuration or access policies
165
+ - ❌ Accessing any index other than `products`, `personas`, `user_preferences`
166
+ - ❌ Invoking any Bedrock model other than Nova Micro and Cohere Rerank
167
+ - ❌ Any S3, EC2, Lambda, IAM, or other service access
168
+ - ❌ Deleting or recreating the `products` or `personas` indices (read-only)
169
+ - ❌ Deleting the `user_preferences` index (only write/read documents)
170
+ - ❌ Creating or deleting OSIS pipelines (only ingest data)
search_personalization/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Product Catalog Service - RESTful API for product management."""
search_personalization/agentic_memory/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """
2
+ Agentic Memory Demo — Multi-agent e-commerce query understanding
3
+ with OpenSearch agentic memory for personalized search.
4
+ """
search_personalization/agentic_memory/agents/__init__.py ADDED
File without changes
search_personalization/agentic_memory/agents/query_agent.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Query Understanding Agent — Enriches raw search queries using user profile context.
3
+ Returns only the enriched query attributes; the orchestrator handles the actual search.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ from strands import Agent
9
+ from strands.models import BedrockModel
10
+
11
+ from search_personalization.agentic_memory.config import BEDROCK_MODEL_ID_FAST, AWS_REGION
12
+ from search_personalization.agentic_memory.schema_cache import get_schema_prompt_block
13
+
14
+ _DEFAULT_SCHEMA_BLOCK = """PRODUCT INDEX SCHEMA:
15
+ - category (keyword, 5 values): apparel, accessories, footwear, electronics, jewelry
16
+ - style (keyword): jacket, formal, shirt, glasses, scarf, backpack, sneaker, belt, handbag, bracelet, camera, sandals, boot, necklace, socks, watch, earrings, bag, speaker, headphones, cable, television, computer, keyboard
17
+ - gender_affinity (keyword): F, M
18
+ - price (numeric)
19
+ - name (text) — product name
20
+ - description (text) — product description"""
21
+
22
+ _SYSTEM_PROMPT_TEMPLATE = """You are the Query Understanding Agent. Enrich a search query using the user's behavioral profile.
23
+
24
+ {schema_block}
25
+
26
+ FIELD USAGE:
27
+ - category → hard filter (exact match, 5 values)
28
+ - gender_affinity → hard filter ("F" or "M")
29
+ - max_price → hard filter (numeric ceiling) — ONLY when user explicitly states price in query
30
+ - enriched_query → drives vector/semantic search (no filter values here)
31
+
32
+ ENRICHED QUERY RULES:
33
+ 1. Format: space-separated keywords/phrases, NOT a sentence. Example: "shoes sneakers athletic comfortable black gray"
34
+ 2. Start with the user's original query words (strip any price mentions like "under $50").
35
+ 3. Append descriptors reflecting the user's DOMINANT style pattern (majority of behavior, not outliers). Example: 3 sneaker purchases + 1 dress shirt = sneaker person, NOT formal person.
36
+ 4. If the profile shows strong preference for a product sub-type (e.g., bought 3 sneakers), include that sub-type. This is the most important signal.
37
+ 5. Express aversions as positive opposites (e.g., aversion "flashy" → add "understated"; "loud colors" → "muted tones"). Do NOT use negation words.
38
+ 6. NEVER include in enriched_query: price values ("under $50", "$50", "budget"), category names, gender letters, or any term related to the aversions (e.g., if aversions include "formal"/"fancy", never add "formal", "dress", "elegant", or similar).
39
+ 7. Use conversation history to resolve references (e.g., "in blue" after "shoes" → "shoes blue").
40
+
41
+ INFERRED ATTRIBUTES:
42
+ - category: REQUIRED. Map query to: apparel, accessories, footwear, electronics, jewelry.
43
+ - gender_affinity: "F" or "M" if any gender signal exists in profile. Null otherwise.
44
+ - max_price: numeric ONLY if the user's raw query text explicitly mentions a price constraint (e.g., "under $50", "below $100"). If the query does NOT contain a price mention, max_price MUST be null even if the profile suggests a budget ceiling.
45
+ - style: closest schema value or null.
46
+ - colors: recurring color preferences from profile.
47
+ - preferred_materials: recurring materials from profile.
48
+ - use_context: primary shopping context (e.g., "campus daily wear", "business-casual work").
49
+ - aversions: raw aesthetic dislikes from profile (e.g., ["flashy", "formal"]). Never include the searched product type.
50
+
51
+ OUTPUT (strict JSON, nothing else):
52
+ {{"enriched_query": "keywords only, e.g.: shoes sneakers athletic comfortable muted", "inferred_attributes": {{"colors": [], "style": null, "category": null, "gender_affinity": null, "max_price": null, "aversions": [], "preferred_materials": [], "use_context": null}}}}
53
+ """
54
+
55
+
56
+ def create_query_agent() -> Agent:
57
+ """Create the Query Understanding Agent (no tools — pure reasoning)."""
58
+ schema_block = get_schema_prompt_block() or _DEFAULT_SCHEMA_BLOCK
59
+ system_prompt = _SYSTEM_PROMPT_TEMPLATE.format(schema_block=schema_block)
60
+
61
+ model = BedrockModel(
62
+ model_id=BEDROCK_MODEL_ID_FAST,
63
+ region_name=AWS_REGION,
64
+ )
65
+ return Agent(
66
+ model=model,
67
+ tools=[],
68
+ system_prompt=system_prompt,
69
+ )
70
+
71
+
72
+ def invoke_query_agent(
73
+ query: str,
74
+ persona_id: str,
75
+ session_id: str,
76
+ profile: str = "",
77
+ session_context: str = "",
78
+ ) -> str:
79
+ """
80
+ Invoke the Query Understanding Agent.
81
+
82
+ Returns:
83
+ JSON string with enriched_query and inferred_attributes only.
84
+ """
85
+ agent = create_query_agent()
86
+
87
+ profile_section = f"\n\nUser Profile:\n{profile}" if profile else "\n\nNo profile available — use query as-is."
88
+ history_section = f"\n\nConversation History:\n{session_context}" if session_context else ""
89
+
90
+ prompt = (
91
+ f"Process this search:\n\n"
92
+ f"Raw query: \"{query}\"{profile_section}{history_section}\n\n"
93
+ f"Enrich the query and output the JSON."
94
+ )
95
+ result = agent(prompt)
96
+ return str(result)
search_personalization/agentic_memory/agents/ranking_agent.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ranking Agent — Accepts enriched query + search results directly from the orchestrator
3
+ and produces personalized, reranked product results.
4
+ """
5
+
6
+ from strands import Agent
7
+ from strands.models import BedrockModel
8
+
9
+ from search_personalization.agentic_memory.config import BEDROCK_MODEL_ID_FAST, AWS_REGION
10
+ from search_personalization.agentic_memory.memory_tools import rerank_results
11
+
12
+
13
+ SYSTEM_PROMPT = """You are the Ranking Agent. Rerank search results and produce personalized ranked results.
14
+
15
+ WORKFLOW:
16
+ 1. You receive the enriched query and search results in the prompt.
17
+ 2. Call rerank_results with the enriched query string and the search results for semantic reranking.
18
+ 3. Filter out any products matching known aversions from the inferred_attributes.
19
+ 4. Output the final ranked JSON.
20
+
21
+ RULES:
22
+ - Top 7 results max.
23
+ - Use the enriched_query as the rerank query (it has user preferences baked in).
24
+ - Exclude products that match aversions listed in inferred_attributes.
25
+ - Output ONLY the JSON object. No text before or after.
26
+
27
+ OUTPUT FORMAT (strict JSON, nothing else):
28
+ {"results": [{"rank": 1, "product_id": "", "name": "", "price": 0, "score": 0}], "enriched_query": "the enriched query used", "personalization_summary": "one sentence"}
29
+ """
30
+
31
+
32
+ def create_ranking_agent() -> Agent:
33
+ """Create the Ranking Agent."""
34
+ model = BedrockModel(
35
+ model_id=BEDROCK_MODEL_ID_FAST,
36
+ region_name=AWS_REGION,
37
+ )
38
+ return Agent(
39
+ model=model,
40
+ tools=[rerank_results],
41
+ system_prompt=SYSTEM_PROMPT,
42
+ )
43
+
44
+
45
+ def invoke_ranking_agent(
46
+ persona_id: str,
47
+ session_id: str,
48
+ search_results: str,
49
+ ) -> str:
50
+ """
51
+ Invoke the Ranking Agent.
52
+
53
+ Args:
54
+ persona_id: The user ID.
55
+ session_id: The current session ID.
56
+ search_results: JSON string from the Query Agent containing enriched_query,
57
+ inferred_attributes, and search_results.
58
+
59
+ Returns:
60
+ The agent's response with ranked, personalized results.
61
+ """
62
+ agent = create_ranking_agent()
63
+ prompt = (
64
+ f"Produce personalized ranked results for persona_id '{persona_id}' "
65
+ f"in session '{session_id}'.\n\n"
66
+ f"Here are the enriched query and search results:\n{search_results}\n\n"
67
+ f"Rerank them, filter aversions, and return the final ranked results."
68
+ )
69
+ result = agent(prompt)
70
+ return str(result)
search_personalization/agentic_memory/agents/results_explanation_agent.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Results Explanation Agent — Explains search result gaps to users.
3
+
4
+ Runs ASYNCHRONOUSLY after ranking to avoid adding latency to the main pipeline.
5
+ Only generates an explanation when there's a clear mismatch between
6
+ user intent and returned results.
7
+ """
8
+
9
+ import json
10
+ from typing import Optional
11
+
12
+ from strands import Agent
13
+ from strands.models import BedrockModel
14
+
15
+ from search_personalization.agentic_memory.config import BEDROCK_MODEL_ID_FAST, AWS_REGION
16
+
17
+ _SYSTEM_PROMPT = """You detect mismatches between search results and the user's intent (query + profile preferences). Output JSON only.
18
+
19
+ RULES:
20
+ 1. Output {"explanation": null} if results align well with BOTH the query AND the user's profile preferences.
21
+ 2. A mismatch occurs when:
22
+ - Price: user asked "under $50" but ALL or MOST results exceed that price.
23
+ - Style/sub-type: user prefers sneakers (from profile) but results are mostly boots/dress shoes due to price or availability constraints.
24
+ - Color: user asked for "red shoes" but no red products appear.
25
+ - Product type: user asked for "dress" but catalog has no dresses.
26
+ 3. Shoes includes ALL footwear: sneakers, boots, lace-ups, sandals, heels, loafers, dress shoes. Results being a different footwear sub-type than the user's preference IS a mismatch worth explaining.
27
+ 4. When there's a style mismatch due to constraints (e.g., not enough sneakers under $50), explain WHY different styles appear — reference the constraint that caused it.
28
+ 5. If only 1-2 results don't match preferences but the majority do, output null.
29
+ 6. Do NOT explain when results match both query and preferences well.
30
+ 7. One sentence max. Be helpful, not apologetic.
31
+
32
+ EXAMPLES:
33
+ - Query "shoes under $50", profile prefers sneakers, results are boots/oxfords → {"explanation": "Limited sneaker options under $50 — showing other footwear styles within your budget."}
34
+ - Query "shoes under $50", profile prefers sneakers, results ARE sneakers → {"explanation": null}
35
+ - Query "backpack", results are backpacks → {"explanation": null}
36
+ - Query "red jacket", results have no red items → {"explanation": "No red jackets available — showing closest color matches."}
37
+
38
+ OUTPUT: {"explanation": "one sentence" or null}
39
+ """
40
+
41
+
42
+ def should_explain(query: str, results: list, price_filter_relaxed: bool = False) -> bool:
43
+ """Determine whether the explanation agent should run."""
44
+ if price_filter_relaxed:
45
+ return True
46
+ if not results:
47
+ return True
48
+ return True
49
+
50
+
51
+ def invoke_results_explanation_agent(
52
+ query: str,
53
+ results: list,
54
+ price_filter_relaxed: bool = False,
55
+ original_max_price: float = None,
56
+ inferred_attributes: dict = None,
57
+ **kwargs,
58
+ ) -> Optional[str]:
59
+ """
60
+ Generate a brief explanation when results don't match user intent or profile preferences.
61
+
62
+ Returns:
63
+ A short explanation string, or None if results are fine.
64
+ """
65
+ if not should_explain(query, results, price_filter_relaxed):
66
+ return None
67
+
68
+ # Price filter relaxation — deterministic message, no LLM needed
69
+ if price_filter_relaxed and original_max_price:
70
+ return f"Not enough options under ${original_max_price:.0f} — showing best matches across all price ranges."
71
+
72
+ model = BedrockModel(
73
+ model_id=BEDROCK_MODEL_ID_FAST,
74
+ region_name=AWS_REGION,
75
+ )
76
+ agent = Agent(
77
+ model=model,
78
+ tools=[],
79
+ system_prompt=_SYSTEM_PROMPT,
80
+ )
81
+
82
+ # Compact prompt — query, profile preferences, and result summaries
83
+ result_summaries = ", ".join(
84
+ f"{r.get('name', '?')} (${r.get('price', '?')}, style: {r.get('style', '?')})"
85
+ for r in results[:5]
86
+ )
87
+
88
+ # Build profile preference context
89
+ profile_context = ""
90
+ if inferred_attributes:
91
+ pref_parts = []
92
+ if inferred_attributes.get("style"):
93
+ pref_parts.append(f"preferred style: {inferred_attributes['style']}")
94
+ if inferred_attributes.get("colors"):
95
+ pref_parts.append(f"preferred colors: {', '.join(inferred_attributes['colors'][:3])}")
96
+ if inferred_attributes.get("use_context"):
97
+ pref_parts.append(f"use context: {inferred_attributes['use_context']}")
98
+ if inferred_attributes.get("max_price"):
99
+ pref_parts.append(f"budget: ${inferred_attributes['max_price']}")
100
+ if pref_parts:
101
+ profile_context = f"\nUser profile preferences: {'; '.join(pref_parts)}"
102
+
103
+ prompt = (
104
+ f"Query: \"{query}\"{profile_context}\n"
105
+ f"Results: {result_summaries}\n"
106
+ f"JSON:"
107
+ )
108
+
109
+ try:
110
+ result = agent(prompt)
111
+ raw = str(result)
112
+ start = raw.find("{")
113
+ end = raw.rfind("}") + 1
114
+ if start >= 0 and end > start:
115
+ parsed = json.loads(raw[start:end])
116
+ return parsed.get("explanation")
117
+ except Exception:
118
+ pass
119
+
120
+ return None
search_personalization/agentic_memory/config.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration for the Agentic Memory demo.
3
+ Manages OpenSearch connection, memory container IDs, and model settings.
4
+ """
5
+
6
+ import os
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ # OpenSearch cluster
12
+ OPENSEARCH_ENDPOINT = os.getenv("OPENSEARCH_ENDPOINT", "")
13
+ AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
14
+
15
+ # Bedrock model for agents
16
+ BEDROCK_MODEL_ID_FAST = os.getenv(
17
+ "BEDROCK_MODEL_ID_FAST",
18
+ "us.amazon.nova-micro-v1:0"
19
+ )
20
+ BEDROCK_MODEL_ID = os.getenv(
21
+ "BEDROCK_MODEL_ID",
22
+ "us.anthropic.claude-sonnet-4-6"
23
+ )
24
+
25
+ # Embedding model for memory containers
26
+ EMBEDDING_MODEL_ID = os.getenv("EMBEDDING_MODEL_ID", "")
27
+ EMBEDDING_DIMENSION = int(os.getenv("EMBEDDING_DIMENSION", "1024"))
28
+
29
+ # LLM model for memory processing (extraction/summarization)
30
+ MEMORY_LLM_MODEL_ID = os.getenv("MEMORY_LLM_MODEL_ID", "")
31
+
32
+ # Single memory container (holds all memory types: long-term, sessions, working, history)
33
+ CONTAINER_NAME = "product-search-agent"
34
+ CONTAINER_ID = os.getenv("MEMORY_CONTAINER_ID")
35
+
36
+ # Persona IDs
37
+ PERSONAS = {
38
+ "sarah": "user1",
39
+ "alex": "user2",
40
+ }
41
+
42
+ # Persona genders (used for gender_affinity matching in product personalization)
43
+ PERSONA_GENDERS = {
44
+ "user1": "female",
45
+ "user2": "male",
46
+ }
search_personalization/agentic_memory/demo_runner.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Demo Runner — Orchestrates the 3-agent pipeline for the agentic memory demo.
3
+
4
+ Architecture:
5
+ - Long-term memory: user preferences fetched from OpenSearch agentic memory
6
+ - Query Understanding Agent: enriches query, searches catalog, returns results directly
7
+ - Ranking Agent: receives results directly, reranks with personalization
8
+ - Working memory write: async fire-and-forget for observability/tracing
9
+
10
+ Usage:
11
+ python -m src.agentic_memory.demo_runner
12
+ python -m src.agentic_memory.demo_runner --query "black boots for a concert"
13
+ python -m src.agentic_memory.demo_runner --interactive
14
+ """
15
+
16
+ import json
17
+ import time
18
+ import uuid
19
+ import threading
20
+ from datetime import datetime
21
+ from typing import Optional
22
+
23
+ from search_personalization.agentic_memory.config import PERSONAS
24
+ from search_personalization.agentic_memory.memory_tools import get_user_profile
25
+ from search_personalization.agentic_memory.agents.query_agent import invoke_query_agent
26
+ from search_personalization.agentic_memory.memory_tools import write_session_memory, write_history, read_session_memory
27
+
28
+
29
+ DEFAULT_QUERY = "backpack"
30
+
31
+
32
+ def run_pipeline_for_persona(
33
+ query: str,
34
+ persona_name: str,
35
+ session_id: Optional[str] = None,
36
+ conversation_history: Optional[str] = None,
37
+ ) -> dict:
38
+ """
39
+ Run the 3-step pipeline for a single persona:
40
+ 1. Profile Cache (instant)
41
+ 2. Query Understanding Agent (enrich + search)
42
+ 3. Ranking Agent (rerank with direct results)
43
+ """
44
+ persona_id = PERSONAS[persona_name]
45
+ if not session_id:
46
+ session_id = f"sess-{uuid.uuid4().hex[:8]}"
47
+
48
+ print(f"\n{'='*60}")
49
+ print(f" PERSONA: {persona_name.upper()} ({persona_id})")
50
+ print(f" Query: \"{query}\"")
51
+ print(f" Session: {session_id}")
52
+ print(f"{'='*60}")
53
+
54
+ pipeline_start = time.time()
55
+ pipeline_trace = {
56
+ "persona_id": persona_id,
57
+ "persona_name": persona_name,
58
+ "query": query,
59
+ "session_id": session_id,
60
+ "timestamp": datetime.utcnow().isoformat(),
61
+ "agents": {},
62
+ }
63
+
64
+ # --- Step 1: Profile + session in parallel ---
65
+ from concurrent.futures import ThreadPoolExecutor
66
+
67
+ start = time.time()
68
+ with ThreadPoolExecutor(max_workers=2) as pool:
69
+ profile_future = pool.submit(get_user_profile, persona_id)
70
+ if conversation_history:
71
+ session_context = conversation_history
72
+ profile_raw = profile_future.result()
73
+ else:
74
+ session_future = pool.submit(read_session_memory, persona_id, session_id)
75
+ profile_raw = profile_future.result()
76
+ session_context = session_future.result()
77
+
78
+ # Format USER_PREFERENCE memories into a profile string for the agent
79
+ profile_data = json.loads(profile_raw)
80
+ memories = profile_data.get("memories", [])
81
+ preference_statements = [
82
+ m.get("_source", {}).get("memory", "")
83
+ for m in memories
84
+ if m.get("_source", {}).get("strategy_type") == "USER_PREFERENCE"
85
+ ]
86
+ profile_lines = [f"- {s}" for s in preference_statements if s]
87
+ profile = (
88
+ f"Profile based on {len(preference_statements)} behavioral observations:\n"
89
+ + "\n".join(profile_lines)
90
+ ) if profile_lines else ""
91
+
92
+ memory_duration = time.time() - start
93
+ print(f"\n [memory] Profile + session loaded ({memory_duration*1000:.1f}ms)")
94
+ pipeline_trace["agents"]["memory"] = {
95
+ "duration_ms": round(memory_duration * 1000, 1),
96
+ "source": "opensearch-long-term-memory",
97
+ "preference_records": len(preference_statements),
98
+ }
99
+
100
+ # --- Step 2: Query Understanding Agent (enrich only) ---
101
+ print(f" [agent] Query Understanding Agent — enrich...")
102
+ start = time.time()
103
+ query_result = invoke_query_agent(query, persona_id, session_id, profile=profile or "", session_context=session_context)
104
+ query_duration = time.time() - start
105
+ pipeline_trace["agents"]["query"] = {
106
+ "duration_ms": int(query_duration * 1000),
107
+ "output": query_result,
108
+ }
109
+ print(f" Done ({query_duration:.1f}s)")
110
+
111
+ # --- Step 2b: Embed enriched query + knn search ---
112
+ print(f" [search] Embed + catalog search...")
113
+ start = time.time()
114
+ try:
115
+ enrichment = json.loads(query_result)
116
+ except (json.JSONDecodeError, TypeError):
117
+ # Agent output may contain surrounding text — extract JSON object
118
+ enrichment = None
119
+ try:
120
+ _start = query_result.find("{")
121
+ _end = query_result.rfind("}") + 1
122
+ if _start >= 0 and _end > _start:
123
+ enrichment = json.loads(query_result[_start:_end])
124
+ except (json.JSONDecodeError, TypeError):
125
+ pass
126
+ if not enrichment:
127
+ enrichment = {"enriched_query": query, "inferred_attributes": {}}
128
+
129
+ attrs = enrichment.get("inferred_attributes", {})
130
+ enriched_query = enrichment.get("enriched_query", query)
131
+
132
+ # Let the agent handle all semantic resolution — no deterministic style matching.
133
+ # Style is a semantic field: "shoes" should surface boots, sneakers, sandals, etc.
134
+ # Category and gender_affinity are hard kNN pre-filters.
135
+ # max_price is only applied as a kNN filter when the user explicitly mentions
136
+ # price in their original query. The agent may infer max_price from profile
137
+ # (useful for reranking/explanation) but we don't use it as a hard filter
138
+ # unless the user asked for it.
139
+ style_filter = None
140
+ category_filter = attrs.get("category")
141
+ max_price_filter = attrs.get("max_price")
142
+ # Ensure max_price is numeric if present
143
+ if max_price_filter is not None:
144
+ try:
145
+ max_price_filter = float(max_price_filter)
146
+ except (ValueError, TypeError):
147
+ max_price_filter = None
148
+
149
+ # Only use max_price as a kNN pre-filter if the user explicitly stated price
150
+ import re
151
+ _price_match = re.search(
152
+ r'\$\s*(\d+(?:\.\d+)?)|(?:under|below|less\s+than|cheaper\s+than|max)\s+\$?\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*dollars',
153
+ query, re.IGNORECASE
154
+ )
155
+ if _price_match:
156
+ # Extract the price value from whichever group matched
157
+ _explicit_price = next((g for g in _price_match.groups() if g is not None), None)
158
+ if _explicit_price:
159
+ knn_price_filter = float(_explicit_price)
160
+ else:
161
+ knn_price_filter = max_price_filter
162
+ else:
163
+ knn_price_filter = None
164
+
165
+ from search_personalization.agentic_memory.memory_tools import search_product_catalog, rerank_results
166
+ gender_filter = attrs.get("gender_affinity")
167
+ search_results = search_product_catalog(
168
+ query=enriched_query,
169
+ category=category_filter,
170
+ max_price=knn_price_filter,
171
+ gender_affinity=gender_filter,
172
+ )
173
+
174
+ search_duration = time.time() - start
175
+ print(f" Done ({search_duration:.1f}s)")
176
+
177
+ # Extract the actual OpenSearch query from the search results for display
178
+ try:
179
+ _search_parsed = json.loads(search_results) if isinstance(search_results, str) else search_results
180
+ actual_opensearch_query = _search_parsed.get("_opensearch_query")
181
+ except (json.JSONDecodeError, TypeError):
182
+ actual_opensearch_query = None
183
+
184
+ # --- Step 3: Rerank + filter aversions (no LLM needed) ---
185
+ print(f" [rerank] Reranking + filtering...")
186
+ start = time.time()
187
+ # Build a rerank query that blends user intent with style/use-context signals.
188
+ # The search step already used the enriched query (with profile preferences),
189
+ # so reranking surfaces results that best match what the user actually asked for
190
+ # plus their functional context.
191
+ rerank_parts = [query]
192
+ use_ctx = attrs.get("use_context")
193
+ preferred_materials = attrs.get("preferred_materials", [])
194
+ if use_ctx:
195
+ rerank_parts.append(use_ctx)
196
+ if preferred_materials:
197
+ rerank_parts.append(", ".join(preferred_materials[:2]))
198
+ rerank_query = ", ".join(rerank_parts)
199
+ reranked_raw = rerank_results(query=rerank_query, documents=search_results, top_n=10)
200
+
201
+ # Aversions are handled semantically — the query agent expresses them as positive
202
+ # opposites in the enriched_query (e.g., "flashy" → "subtle understated") so the
203
+ # vector search naturally favors non-aversive products. No post-filtering needed.
204
+ raw_aversions = attrs.get("aversions", [])
205
+ reranked = json.loads(reranked_raw) if isinstance(reranked_raw, str) else reranked_raw
206
+ results_list = reranked.get("reranked_results", [])
207
+
208
+ # Price post-filter: apply after reranking so semantic relevance isn't starved.
209
+ # If too few results survive the price filter, keep the full set and let the
210
+ # explanation agent inform the user about the price gap.
211
+ price_filter_relaxed = False
212
+ # Only apply price filtering if the user explicitly mentioned price in their query
213
+ import re
214
+ _user_mentioned_price = bool(re.search(r'(\$\d|under|below|less than|cheap|budget|affordable|\d+\s*dollars)', query.lower()))
215
+ if max_price_filter and results_list and _user_mentioned_price:
216
+ price_filtered = [r for r in results_list if (r.get("price") or 0) <= max_price_filter]
217
+ if len(price_filtered) >= 3:
218
+ results_list = price_filtered
219
+ else:
220
+ # Not enough results within budget — keep full set, flag for explanation
221
+ price_filter_relaxed = True
222
+ print(f" [price] Only {len(price_filtered)} results under ${max_price_filter:.0f}, showing all with explanation")
223
+
224
+ # Build final output
225
+ final_results = {
226
+ "results": [
227
+ {"rank": i + 1, "product_id": r.get("id"), "name": r.get("name"), "price": r.get("price"), "score": r.get("rerank_score")}
228
+ for i, r in enumerate(results_list[:7])
229
+ ],
230
+ "enriched_query": enriched_query,
231
+ "personalization_summary": (
232
+ f"Use context: {use_ctx or 'none'}, "
233
+ f"materials: {preferred_materials or 'none'}, "
234
+ f"filtered aversions: {raw_aversions}"
235
+ ) if (raw_aversions or use_ctx or preferred_materials) else "No personalization applied",
236
+ }
237
+ ranking_result = json.dumps(final_results, indent=2, default=str)
238
+ ranking_duration = time.time() - start
239
+ pipeline_trace["agents"]["ranking"] = {
240
+ "duration_ms": int(ranking_duration * 1000),
241
+ "output": ranking_result,
242
+ }
243
+ print(f" Done ({ranking_duration:.1f}s)")
244
+
245
+ # Combine for working memory
246
+ combined_payload = json.dumps({**enrichment, "search_results": results_list}, default=str)
247
+
248
+ # --- Async: write working memory for observability (fire-and-forget) ---
249
+ def _write_working_memory_async():
250
+ try:
251
+ from search_personalization.agentic_memory.memory_tools import write_working_memory
252
+ write_working_memory(persona_id, session_id, combined_payload)
253
+ except Exception:
254
+ pass
255
+
256
+ threading.Thread(target=_write_working_memory_async, daemon=True).start()
257
+
258
+ # --- Async: Results Explanation Agent (runs in parallel, doesn't block) ---
259
+ from concurrent.futures import Future
260
+ explanation_future: Future = Future()
261
+
262
+ def _run_explanation_async():
263
+ try:
264
+ from search_personalization.agentic_memory.agents.results_explanation_agent import (
265
+ invoke_results_explanation_agent,
266
+ )
267
+ explanation = invoke_results_explanation_agent(
268
+ query=query,
269
+ results=results_list[:5],
270
+ price_filter_relaxed=price_filter_relaxed,
271
+ original_max_price=max_price_filter if price_filter_relaxed else None,
272
+ inferred_attributes=attrs,
273
+ )
274
+ explanation_future.set_result(explanation)
275
+ except Exception:
276
+ explanation_future.set_result(None)
277
+
278
+ explanation_thread = threading.Thread(target=_run_explanation_async, daemon=True)
279
+ explanation_thread.start()
280
+
281
+ pipeline_trace["total_duration_ms"] = int((time.time() - pipeline_start) * 1000)
282
+ pipeline_trace["opensearch_query"] = actual_opensearch_query
283
+ print(f"\n Total pipeline: {pipeline_trace['total_duration_ms']}ms")
284
+
285
+ # Price relaxation explanation is synchronous (no LLM needed, just a string).
286
+ # LLM-based explanations run fully async — the future is returned in the trace
287
+ # so the caller can resolve it without blocking the pipeline.
288
+ if price_filter_relaxed and max_price_filter:
289
+ pipeline_trace["agents"]["explanation"] = {
290
+ "output": f"Not enough options under ${max_price_filter:.0f} — showing best matches across all price ranges."
291
+ }
292
+ else:
293
+ pipeline_trace["agents"]["explanation"] = {"output": None, "_future": explanation_future}
294
+
295
+ # --- Post-pipeline: session memory write (async fire-and-forget) ---
296
+ def _write_session_memory_async():
297
+ try:
298
+ top_results = ", ".join(
299
+ r.get("name", "") for r in results_list[:3]
300
+ )
301
+ session_content = (
302
+ f"User searched: \"{query}\". "
303
+ f"Category: {attrs.get('category') or 'general'}. "
304
+ f"Top results: {top_results}."
305
+ )
306
+ write_session_memory(persona_id, session_id, session_content)
307
+ except Exception:
308
+ pass
309
+
310
+ threading.Thread(target=_write_session_memory_async, daemon=True).start()
311
+
312
+ # History write can remain async (not needed for multi-turn)
313
+ def _write_history_async():
314
+ try:
315
+ write_history(persona_id, session_id,
316
+ f"Session {session_id} | Query: \"{query}\" | "
317
+ f"Persona: {persona_name} | Timestamp: {pipeline_trace['timestamp']}")
318
+ except Exception:
319
+ pass
320
+
321
+ threading.Thread(target=_write_history_async, daemon=True).start()
322
+
323
+ return pipeline_trace
324
+
325
+
326
+ def run_comparison_demo(query: str):
327
+ """Run the same query through both personas side by side."""
328
+ print("\n" + "=" * 70)
329
+ print(" AGENTIC MEMORY DEMO — PERSONALIZED SEARCH")
330
+ print("=" * 70)
331
+ print(f"\n Query: \"{query}\"")
332
+ print(f" Personas: Sarah (classic/professional style) vs Alex (casual/streetwear style)")
333
+ print(f" Architecture: Cache → Query Understanding → Ranking (3 agents)")
334
+
335
+ demo_session_prefix = f"demo-{uuid.uuid4().hex[:6]}"
336
+
337
+ sarah_trace = run_pipeline_for_persona(
338
+ query=query, persona_name="sarah", session_id=f"{demo_session_prefix}-sarah",
339
+ )
340
+ alex_trace = run_pipeline_for_persona(
341
+ query=query, persona_name="alex", session_id=f"{demo_session_prefix}-alex",
342
+ )
343
+
344
+ print("\n" + "=" * 70)
345
+ print(" COMPARISON SUMMARY")
346
+ print("=" * 70)
347
+ print(f"\n Same query: \"{query}\"")
348
+ print(f"\n Sarah's results:")
349
+ print(f" {_extract_summary(sarah_trace['agents'].get('ranking', {}).get('output', ''))}")
350
+ print(f"\n Alex's results:")
351
+ print(f" {_extract_summary(alex_trace['agents'].get('ranking', {}).get('output', ''))}")
352
+ print(f"\n Key insight: Same query — memory + decoupled ranking is the difference.")
353
+ print("=" * 70)
354
+
355
+ return {"sarah": sarah_trace, "alex": alex_trace}
356
+
357
+
358
+ def _extract_summary(output: str) -> str:
359
+ if not output:
360
+ return "(no output)"
361
+ clean = output.replace("\n", " ").strip()
362
+ return clean[:300] + "..." if len(clean) > 300 else clean
363
+
364
+
365
+ def run_interactive():
366
+ """Interactive mode."""
367
+ print("\n" + "=" * 70)
368
+ print(" AGENTIC MEMORY DEMO — INTERACTIVE MODE")
369
+ print("=" * 70)
370
+ print("\n Type a query to run through both personas")
371
+ print(" 'sarah: <query>' or 'alex: <query>' for one persona")
372
+ print(" 'quit' to stop\n")
373
+
374
+ session_ids = {
375
+ "sarah": f"interactive-{uuid.uuid4().hex[:6]}-sarah",
376
+ "alex": f"interactive-{uuid.uuid4().hex[:6]}-alex",
377
+ }
378
+
379
+ while True:
380
+ try:
381
+ user_input = input("Query: ").strip()
382
+ except (EOFError, KeyboardInterrupt):
383
+ print("\nGoodbye!")
384
+ break
385
+
386
+ if not user_input or user_input.lower() in ("quit", "exit"):
387
+ break
388
+
389
+ if user_input.lower().startswith("sarah:"):
390
+ run_pipeline_for_persona(user_input[6:].strip(), "sarah", session_ids["sarah"])
391
+ elif user_input.lower().startswith("alex:"):
392
+ run_pipeline_for_persona(user_input[5:].strip(), "alex", session_ids["alex"])
393
+ else:
394
+ run_comparison_demo(user_input)
395
+ print()
396
+
397
+
398
+ def main():
399
+ import argparse
400
+ parser = argparse.ArgumentParser(description="Run the agentic memory demo pipeline")
401
+ parser.add_argument("--query", type=str, default=DEFAULT_QUERY)
402
+ parser.add_argument("--interactive", action="store_true")
403
+ parser.add_argument("--persona", type=str, choices=["sarah", "alex"])
404
+ args = parser.parse_args()
405
+
406
+ # Refresh schema cache and start daily scheduler (7am)
407
+ from search_personalization.agentic_memory.schema_cache import refresh_cache, start_scheduler
408
+ try:
409
+ print(" Refreshing schema cache...")
410
+ refresh_cache()
411
+ except Exception as e:
412
+ print(f" Schema cache refresh failed (will use defaults): {e}")
413
+ start_scheduler(hour=7, minute=0)
414
+
415
+ if args.interactive:
416
+ run_interactive()
417
+ elif args.persona:
418
+ run_pipeline_for_persona(query=args.query, persona_name=args.persona)
419
+ else:
420
+ run_comparison_demo(args.query)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ main()
search_personalization/agentic_memory/memory_client.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenSearch Agentic Memory client.
3
+ Wraps the /_plugins/_ml/memory_containers/ API (introduced in OpenSearch 3.3).
4
+ """
5
+
6
+ from typing import Any, Optional
7
+ from opensearchpy import OpenSearch
8
+ from search_personalization.data_loader import get_opensearch_client
9
+ from search_personalization.agentic_memory.config import EMBEDDING_MODEL_ID, MEMORY_LLM_MODEL_ID, EMBEDDING_DIMENSION
10
+
11
+
12
+ def _client() -> OpenSearch:
13
+ """Get the shared OpenSearch client."""
14
+ return get_opensearch_client()
15
+
16
+
17
+ # --- Container Management ---
18
+
19
+
20
+ def create_container(
21
+ name: str,
22
+ description: str,
23
+ strategies: list[dict],
24
+ ) -> dict:
25
+ """
26
+ Create a memory container.
27
+
28
+ Args:
29
+ name: Human-readable container name.
30
+ description: What this container stores.
31
+ strategies: List of strategy dicts, e.g. [{"type": "USER_PREFERENCE", "namespace": ["user_id"]}]
32
+ """
33
+ client = _client()
34
+ body = {
35
+ "name": name,
36
+ "description": description,
37
+ "configuration": {
38
+ "embedding_model_type": "TEXT_EMBEDDING",
39
+ "embedding_model_id": EMBEDDING_MODEL_ID,
40
+ "embedding_dimension": EMBEDDING_DIMENSION,
41
+ "llm_id": MEMORY_LLM_MODEL_ID,
42
+ "strategies": strategies,
43
+ },
44
+ }
45
+ return client.transport.perform_request(
46
+ "POST", "/_plugins/_ml/memory_containers/_create", body=body
47
+ )
48
+
49
+
50
+ def list_containers() -> dict:
51
+ """Search all memory containers."""
52
+ client = _client()
53
+ body = {"query": {"match_all": {}}}
54
+ return client.transport.perform_request(
55
+ "POST", "/_plugins/_ml/memory_containers/_search", body=body
56
+ )
57
+
58
+
59
+ def get_container(container_id: str) -> dict:
60
+ """Get a specific container by ID."""
61
+ client = _client()
62
+ return client.transport.perform_request(
63
+ "GET", f"/_plugins/_ml/memory_containers/{container_id}"
64
+ )
65
+
66
+
67
+ def delete_container(container_id: str) -> dict:
68
+ """Delete a memory container."""
69
+ client = _client()
70
+ return client.transport.perform_request(
71
+ "DELETE", f"/_plugins/_ml/memory_containers/{container_id}"
72
+ )
73
+
74
+
75
+ # --- Memory Operations ---
76
+
77
+
78
+ def write_memory(
79
+ container_id: str,
80
+ namespace: dict,
81
+ content: str,
82
+ payload_type: str = "conversational",
83
+ infer: bool = True,
84
+ ) -> dict:
85
+ """
86
+ Write a memory to a container.
87
+
88
+ Args:
89
+ container_id: The memory container ID.
90
+ namespace: Namespace dict, e.g. {"user_id": "user1", "session_id": "s1"}.
91
+ content: The text content to store.
92
+ payload_type: "conversational" or "data".
93
+ infer: Whether to use LLM to extract knowledge.
94
+ """
95
+ client = _client()
96
+ body: dict[str, Any] = {
97
+ "messages": [
98
+ {
99
+ "role": "user",
100
+ "content": [{"text": content, "type": "text"}],
101
+ }
102
+ ],
103
+ "namespace": namespace,
104
+ "payload_type": payload_type,
105
+ "infer": infer,
106
+ }
107
+ return client.transport.perform_request(
108
+ "POST", f"/_plugins/_ml/memory_containers/{container_id}/memories", body=body
109
+ )
110
+
111
+
112
+ def read_memory(
113
+ container_id: str,
114
+ namespace: dict,
115
+ memory_type: str = "long-term",
116
+ size: int = 10,
117
+ ) -> dict:
118
+ """
119
+ Search memories in a container by namespace.
120
+
121
+ Args:
122
+ container_id: The memory container ID.
123
+ namespace: Namespace dict to filter by.
124
+ memory_type: One of "sessions", "working", "long-term", "history".
125
+ size: Max results.
126
+ """
127
+ client = _client()
128
+ must_clauses = [{"term": {f"namespace.{k}": v}} for k, v in namespace.items()]
129
+ body = {"query": {"bool": {"must": must_clauses}}, "size": size}
130
+ return client.transport.perform_request(
131
+ "GET",
132
+ f"/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type}/_search",
133
+ body=body,
134
+ )
135
+
136
+
137
+ def search_memory(
138
+ container_id: str,
139
+ namespace: dict,
140
+ query: str,
141
+ memory_type: str = "long-term",
142
+ size: int = 5,
143
+ ) -> dict:
144
+ """
145
+ Semantic search within a memory container namespace.
146
+
147
+ Args:
148
+ container_id: The memory container ID.
149
+ namespace: Namespace dict to filter by.
150
+ query: Natural language query for semantic search.
151
+ memory_type: One of "sessions", "working", "long-term", "history".
152
+ size: Max results.
153
+ """
154
+ client = _client()
155
+ must_clauses = [{"term": {f"namespace.{k}": v}} for k, v in namespace.items()]
156
+ body = {
157
+ "query": {
158
+ "bool": {
159
+ "must": must_clauses + [{"match": {"content": query}}],
160
+ }
161
+ },
162
+ "size": size,
163
+ }
164
+ return client.transport.perform_request(
165
+ "GET",
166
+ f"/_plugins/_ml/memory_containers/{container_id}/memories/{memory_type}/_search",
167
+ body=body,
168
+ )
search_personalization/agentic_memory/memory_tools.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Strands @tool functions for agents to read/write OpenSearch agentic memory.
3
+ These tools are shared across the Query Understanding and Ranking agents.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from typing import Optional
9
+ from strands import tool
10
+
11
+ from search_personalization.agentic_memory.config import CONTAINER_ID
12
+ from search_personalization.agentic_memory.memory_client import read_memory, write_memory
13
+
14
+ # In-memory cache for rerank results (keyed by hash of query + documents)
15
+ _rerank_cache: dict[str, str] = {}
16
+
17
+
18
+ def _get_container_id() -> str:
19
+ """Get the single memory container ID, raising if not configured."""
20
+ if not CONTAINER_ID:
21
+ raise ValueError(
22
+ "MEMORY_CONTAINER_ID not configured. Run setup or set the env var."
23
+ )
24
+ return CONTAINER_ID
25
+
26
+
27
+ @tool
28
+ def get_user_profile(persona_id: str) -> str:
29
+ """Retrieve the user's long-term memory profile from OpenSearch agentic memory.
30
+ Returns extracted preferences including color preferences, style, size, price sensitivity,
31
+ and any explicit aversions learned from purchase history, returns, and reviews.
32
+
33
+ Args:
34
+ persona_id: The user ID (e.g., user1, user2).
35
+ """
36
+ from search_personalization.agentic_memory.memory_client import _client
37
+
38
+ container_id = _get_container_id()
39
+ namespace = {"user_id": persona_id}
40
+
41
+ # Query long-term memory filtered to USER_PREFERENCE strategy only
42
+ client = _client()
43
+ body = {
44
+ "query": {"bool": {"must": [
45
+ {"term": {"namespace.user_id": persona_id}},
46
+ {"term": {"strategy_type": "USER_PREFERENCE"}},
47
+ ]}},
48
+ "size": 20,
49
+ }
50
+ result = client.transport.perform_request(
51
+ "GET",
52
+ f"/_plugins/_ml/memory_containers/{container_id}/memories/long-term/_search",
53
+ body=body,
54
+ )
55
+
56
+ memories = result.get("hits", {}).get("hits", [])
57
+ trace = {
58
+ "memory_reads": [
59
+ {
60
+ "namespace": namespace,
61
+ "type": "long-term",
62
+ "strategy_filter": "USER_PREFERENCE",
63
+ "records_retrieved": len(memories),
64
+ }
65
+ ]
66
+ }
67
+
68
+ return json.dumps(
69
+ {"persona_id": persona_id, "memories": memories, "trace": trace},
70
+ indent=2,
71
+ default=str,
72
+ )
73
+
74
+
75
+ @tool
76
+ def read_session_memory(persona_id: str, session_id: str) -> str:
77
+ """Retrieve current session context for multi-turn query understanding.
78
+ Returns previous queries and interactions within this shopping session.
79
+
80
+ Args:
81
+ persona_id: The user ID (e.g., user1, user2).
82
+ session_id: The current session identifier.
83
+ """
84
+ container_id = _get_container_id()
85
+ namespace = {"user_id": persona_id, "session_id": session_id}
86
+
87
+ result = read_memory(
88
+ container_id=container_id,
89
+ namespace=namespace,
90
+ memory_type="sessions",
91
+ )
92
+
93
+ memories = result.get("hits", {}).get("hits", [])
94
+ trace = {
95
+ "memory_reads": [
96
+ {
97
+ "namespace": namespace,
98
+ "type": "session",
99
+ "records_retrieved": len(memories),
100
+ }
101
+ ]
102
+ }
103
+
104
+ return json.dumps(
105
+ {"persona_id": persona_id, "session_id": session_id, "memories": memories, "trace": trace},
106
+ indent=2,
107
+ default=str,
108
+ )
109
+
110
+
111
+ @tool
112
+ def write_session_memory(persona_id: str, session_id: str, content: str) -> str:
113
+ """Write a conversation turn to session memory for multi-turn context.
114
+
115
+ Args:
116
+ persona_id: The user ID.
117
+ session_id: The current session identifier.
118
+ content: The conversation turn content to store (query + results summary).
119
+ """
120
+ container_id = _get_container_id()
121
+ namespace = {"user_id": persona_id, "session_id": session_id}
122
+
123
+ result = write_memory(
124
+ container_id=container_id,
125
+ namespace=namespace,
126
+ content=content,
127
+ )
128
+
129
+ trace = {
130
+ "memory_writes": [
131
+ {
132
+ "namespace": namespace,
133
+ "type": "session",
134
+ "action": "write_session_turn",
135
+ }
136
+ ]
137
+ }
138
+
139
+ return json.dumps({"status": "written", "trace": trace}, indent=2, default=str)
140
+
141
+
142
+ @tool
143
+ def write_working_memory(persona_id: str, session_id: str, enriched_query: str) -> str:
144
+ """Write the enriched query decomposition to working memory for the Ranking Agent to consume.
145
+ This is the inter-agent communication channel.
146
+
147
+ Args:
148
+ persona_id: The user ID.
149
+ session_id: The current session identifier.
150
+ enriched_query: JSON string containing the enriched, decomposed query with all inferred attributes.
151
+ """
152
+ container_id = _get_container_id()
153
+ namespace = {"user_id": persona_id, "session_id": session_id}
154
+
155
+ result = write_memory(
156
+ container_id=container_id,
157
+ namespace=namespace,
158
+ content=enriched_query,
159
+ infer=False,
160
+ )
161
+
162
+ trace = {
163
+ "memory_writes": [
164
+ {
165
+ "namespace": namespace,
166
+ "type": "working-memory",
167
+ "action": "write_enriched_query",
168
+ }
169
+ ]
170
+ }
171
+
172
+ return json.dumps({"status": "written", "trace": trace}, indent=2, default=str)
173
+
174
+
175
+ @tool
176
+ def write_history(persona_id: str, session_id: str, content: str) -> str:
177
+ """Append a completed interaction to the user's history for long-term pattern learning.
178
+
179
+ Args:
180
+ persona_id: The user ID.
181
+ session_id: The session identifier.
182
+ content: Summary of the completed interaction (query, enrichment, results, outcome).
183
+ """
184
+ container_id = _get_container_id()
185
+ namespace = {"user_id": persona_id, "session_id": session_id}
186
+
187
+ result = write_memory(
188
+ container_id=container_id,
189
+ namespace=namespace,
190
+ content=content,
191
+ )
192
+
193
+ trace = {
194
+ "memory_writes": [
195
+ {
196
+ "namespace": namespace,
197
+ "type": "history",
198
+ "action": "append_interaction",
199
+ }
200
+ ]
201
+ }
202
+
203
+ return json.dumps({"status": "written", "trace": trace}, indent=2, default=str)
204
+
205
+
206
+ @tool
207
+ def search_product_catalog(query: str, category: Optional[str] = None, max_price: Optional[float] = None, gender_affinity: Optional[str] = None, size: int = 10) -> str:
208
+ """Search the product catalog using server-side neural query (OpenSearch does the embedding).
209
+ Style/product-type matching is handled semantically by the vector search — do NOT use term filters
210
+ for fields with semantic meaning.
211
+
212
+ Only truly categorical/numeric fields are used as hard filters:
213
+ - category: broad taxonomy (5 values) — safe for exact filtering
214
+ - max_price: numeric range — safe for range filtering
215
+ - gender_affinity: hard filter ("M" or "F") to ensure persona-appropriate results
216
+
217
+ Args:
218
+ query: Natural language search query (e.g., "navy leather boots").
219
+ category: Optional category filter (apparel, footwear, accessories, jewelry, electronics).
220
+ max_price: Optional maximum price filter.
221
+ gender_affinity: Optional gender affinity filter ("M" or "F"). Hard-filters to persona-appropriate products.
222
+ size: Max number of results to return.
223
+ """
224
+ import os
225
+ from search_personalization.data_loader import get_opensearch_client
226
+
227
+ client = get_opensearch_client()
228
+ model_id = os.getenv('OPENSEARCH_MODEL_ID', 'default_model_id')
229
+
230
+ # Hard filters: only for truly categorical/numeric fields
231
+ filter_clauses = []
232
+ if category:
233
+ filter_clauses.append({"term": {"category": category}})
234
+ if max_price:
235
+ filter_clauses.append({"range": {"price": {"lte": max_price}}})
236
+ if gender_affinity:
237
+ filter_clauses.append({"term": {"gender_affinity": gender_affinity.upper()}})
238
+
239
+ # Use server-side neural query — OpenSearch calls the embedding model via its ML connector
240
+ neural_query: dict = {
241
+ "neural": {
242
+ "product_description_vector": {
243
+ "query_text": query,
244
+ "model_id": model_id,
245
+ "k": size,
246
+ }
247
+ }
248
+ }
249
+
250
+ if filter_clauses:
251
+ # Use neural query's built-in filter (pre-filter before kNN neighbor selection)
252
+ neural_query["neural"]["product_description_vector"]["filter"] = {"bool": {"filter": filter_clauses}}
253
+
254
+ query_body = {
255
+ "size": size,
256
+ "query": neural_query,
257
+ }
258
+
259
+ resp = client.search(index="products", body=query_body)
260
+ hits = resp["hits"]["hits"]
261
+
262
+ results = []
263
+ for h in hits:
264
+ src = h["_source"]
265
+ results.append({
266
+ "id": src.get("id"),
267
+ "name": src.get("name"),
268
+ "category": src.get("category"),
269
+ "style": src.get("style"),
270
+ "price": src.get("price"),
271
+ "description": src.get("description", "")[:150],
272
+ "score": h.get("_score"),
273
+ })
274
+
275
+ return json.dumps({"total_hits": resp["hits"]["total"]["value"], "results": results, "_opensearch_query": query_body}, indent=2, default=str)
276
+
277
+
278
+ @tool
279
+ def rerank_results(query: str, documents: str, top_n: int = 10) -> str:
280
+ """Rerank search results using Cohere Rerank 3.5 via Bedrock.
281
+ Call this AFTER search_product_catalog to reorder results by relevance
282
+ to the enriched query. Pass the enriched query (with user preferences baked in)
283
+ as the query parameter for personalized reranking.
284
+
285
+ Args:
286
+ query: The enriched query string to rerank against (e.g., "spacious tan leather backpack, neutral colors, business-casual, $75-$175").
287
+ documents: JSON string — either the full search_product_catalog output or just the results array.
288
+ top_n: Number of top results to return after reranking.
289
+ """
290
+ import hashlib
291
+ import boto3
292
+
293
+ # Cache lookup — keyed by query + document content
294
+ cache_key = hashlib.sha256((query + documents if isinstance(documents, str) else query + json.dumps(documents, sort_keys=True)).encode()).hexdigest()
295
+ if cache_key in _rerank_cache:
296
+ return _rerank_cache[cache_key]
297
+
298
+ client = boto3.client("bedrock-runtime", region_name=os.getenv("AWS_REGION", "us-east-1"))
299
+
300
+ # Robustly parse documents — handle both full output and just the array
301
+ if isinstance(documents, str):
302
+ parsed = json.loads(documents)
303
+ else:
304
+ parsed = documents
305
+
306
+ if isinstance(parsed, dict) and "results" in parsed:
307
+ docs = parsed["results"]
308
+ elif isinstance(parsed, list):
309
+ docs = parsed
310
+ else:
311
+ docs = parsed.get("reranked_results", []) if isinstance(parsed, dict) else []
312
+
313
+ if not docs:
314
+ return json.dumps({"error": "No documents to rerank"})
315
+
316
+ # Build document strings for Cohere Rerank API (must be plain strings)
317
+ doc_texts = []
318
+ for d in docs:
319
+ text = f"{d.get('name', '')}. {d.get('description', '')}. Style: {d.get('style', '')}. Category: {d.get('category', '')}. Price: ${d.get('price', '')}"
320
+ doc_texts.append(text)
321
+
322
+ body = json.dumps({
323
+ "query": query,
324
+ "documents": doc_texts,
325
+ "top_n": min(top_n, len(doc_texts)),
326
+ "api_version": 2,
327
+ })
328
+
329
+ response = client.invoke_model(
330
+ modelId="cohere.rerank-v3-5:0",
331
+ body=body,
332
+ contentType="application/json",
333
+ accept="application/json",
334
+ )
335
+
336
+ result = json.loads(response["body"].read())
337
+ reranked = []
338
+ for item in result.get("results", []):
339
+ idx = item["index"]
340
+ if idx < len(docs):
341
+ original = docs[idx].copy()
342
+ original["rerank_score"] = item["relevance_score"]
343
+ reranked.append(original)
344
+
345
+ result_json = json.dumps({"reranked_results": reranked}, indent=2, default=str)
346
+ _rerank_cache[cache_key] = result_json
347
+ return result_json
search_personalization/agentic_memory/personas/__init__.py ADDED
File without changes
search_personalization/agentic_memory/personas/alex.json ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "persona_id": "user2",
3
+ "name": "Alex",
4
+ "description": "21-year-old Computer Science student in Austin, TX. Active lifestyle, prefers comfortable sporty clothing and sneakers. Dislikes fancy or flashy items.",
5
+ "raw_signals": [
6
+ {
7
+ "type": "purchase",
8
+ "item": "Comfortable Black Running Sneakers",
9
+ "category": "footwear",
10
+ "style": "sneaker",
11
+ "color": "black",
12
+ "date": "2026-01-05"
13
+ },
14
+ {
15
+ "type": "purchase",
16
+ "item": "Lightweight White Training Sneakers",
17
+ "category": "footwear",
18
+ "style": "sneaker",
19
+ "color": "white",
20
+ "date": "2026-01-18"
21
+ },
22
+ {
23
+ "type": "purchase",
24
+ "item": "Sporty Gray Jogger Pants",
25
+ "category": "apparel",
26
+ "style": "jacket",
27
+ "color": "gray",
28
+ "date": "2026-02-02"
29
+ },
30
+ {
31
+ "type": "purchase",
32
+ "item": "Athletic Navy Zip-Up Hoodie",
33
+ "category": "apparel",
34
+ "style": "jacket",
35
+ "color": "navy",
36
+ "date": "2026-02-15"
37
+ },
38
+ {
39
+ "type": "purchase",
40
+ "item": "Red Cushioned Sport Sneakers",
41
+ "category": "footwear",
42
+ "style": "sneaker",
43
+ "color": "red",
44
+ "date": "2026-03-01"
45
+ },
46
+ {
47
+ "type": "purchase",
48
+ "item": "Quick-Dry Athletic Shorts",
49
+ "category": "apparel",
50
+ "style": "shirt",
51
+ "color": "black",
52
+ "date": "2026-03-10"
53
+ },
54
+ {
55
+ "type": "purchase",
56
+ "item": "Sporty Digital Watch",
57
+ "category": "accessories",
58
+ "style": "watch",
59
+ "color": "black",
60
+ "date": "2026-03-20"
61
+ },
62
+ {
63
+ "type": "purchase",
64
+ "item": "Classic Navy Dress Shirt",
65
+ "category": "apparel",
66
+ "style": "shirt",
67
+ "color": "navy",
68
+ "date": "2026-04-05"
69
+ },
70
+ {
71
+ "type": "return",
72
+ "item": "Sparkling Elegant Bracelet",
73
+ "category": "jewelry",
74
+ "style": "bracelet",
75
+ "reason": "Not my style at all — too fancy and flashy. I prefer simple, functional stuff."
76
+ },
77
+ {
78
+ "type": "return",
79
+ "item": "Shiny Gold Statement Necklace",
80
+ "category": "jewelry",
81
+ "style": "necklace",
82
+ "reason": "Way too flashy for me. I don't wear jewelry like this."
83
+ },
84
+ {
85
+ "type": "review",
86
+ "item": "Comfortable Black Running Sneakers",
87
+ "category": "footwear",
88
+ "text": "Super comfortable for walking around campus and hitting the gym. Exactly what I needed — lightweight and breathable."
89
+ },
90
+ {
91
+ "type": "review",
92
+ "item": "Red Cushioned Sport Sneakers",
93
+ "category": "footwear",
94
+ "text": "Great cushioning for running. I wear these for my morning jogs and they hold up well."
95
+ },
96
+ {
97
+ "type": "review",
98
+ "item": "Athletic Navy Zip-Up Hoodie",
99
+ "category": "apparel",
100
+ "text": "Perfect for throwing on before and after workouts. Comfortable and simple."
101
+ },
102
+ {
103
+ "type": "support_chat",
104
+ "text": "I wear size M in tops and 10 in shoes. Mostly looking for sporty comfortable stuff for campus and the gym."
105
+ },
106
+ {
107
+ "type": "browse_pattern",
108
+ "description": "Viewed 25 sneakers, 10 athletic wear items, 2 formal shirts. Filtered by 'sneaker' style 15 times. Never browsed jewelry or accessories beyond watches."
109
+ },
110
+ {
111
+ "type": "wishlist_add",
112
+ "item": "Breathable Mesh Running Sneakers",
113
+ "category": "footwear",
114
+ "style": "sneaker"
115
+ },
116
+ {
117
+ "type": "wishlist_add",
118
+ "item": "Lightweight Training Tank Top",
119
+ "category": "apparel",
120
+ "style": "shirt"
121
+ }
122
+ ]
123
+ }
search_personalization/agentic_memory/personas/sarah.json ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "persona_id": "user1",
3
+ "name": "Sarah",
4
+ "description": "32-year-old Marketing Manager in San Francisco. Quality-focused, trend-aware shopper who values polished, professional style.",
5
+ "raw_signals": [
6
+ {
7
+ "type": "purchase",
8
+ "item": "Navy Casual Jacket",
9
+ "category": "apparel",
10
+ "style": "jacket",
11
+ "color": "navy",
12
+ "date": "2026-01-10"
13
+ },
14
+ {
15
+ "type": "purchase",
16
+ "item": "Sleek Saddle Leather Knee Boots",
17
+ "category": "footwear",
18
+ "style": "boot",
19
+ "color": "saddle brown",
20
+ "date": "2026-01-25"
21
+ },
22
+ {
23
+ "type": "purchase",
24
+ "item": "Sophisticated Everyday Elegance Bracelet",
25
+ "category": "jewelry",
26
+ "style": "bracelet",
27
+ "color": "gold",
28
+ "date": "2026-02-05"
29
+ },
30
+ {
31
+ "type": "purchase",
32
+ "item": "Spacious Tan Backpack for Her Travels",
33
+ "category": "accessories",
34
+ "style": "backpack",
35
+ "color": "tan",
36
+ "date": "2026-02-20"
37
+ },
38
+ {
39
+ "type": "purchase",
40
+ "item": "Timeless Grace Bracelet",
41
+ "category": "jewelry",
42
+ "style": "bracelet",
43
+ "color": "silver",
44
+ "date": "2026-03-01"
45
+ },
46
+ {
47
+ "type": "return",
48
+ "item": "Stylish Orange Backpack",
49
+ "category": "accessories",
50
+ "style": "backpack",
51
+ "reason": "Too flashy for my work environment, I prefer neutral tones that go with everything"
52
+ },
53
+ {
54
+ "type": "return",
55
+ "item": "Cozy Orange Ski Jacket",
56
+ "category": "apparel",
57
+ "style": "jacket",
58
+ "reason": "The color is too loud for me. I wanted something more understated"
59
+ },
60
+ {
61
+ "type": "review",
62
+ "item": "Sleek Saddle Leather Knee Boots",
63
+ "category": "footwear",
64
+ "text": "Perfect quality leather, exactly the classic style I look for. Goes with everything from jeans to work outfits."
65
+ },
66
+ {
67
+ "type": "review",
68
+ "item": "Navy Casual Jacket",
69
+ "category": "apparel",
70
+ "text": "Love the fit and the fabric quality. Navy is my go-to color — professional but not boring."
71
+ },
72
+ {
73
+ "type": "support_chat",
74
+ "text": "I recently changed jobs and need more business-casual pieces. Also my shoe size is now 8 instead of 7.5 — can you update my profile?"
75
+ },
76
+ {
77
+ "type": "browse_pattern",
78
+ "description": "Viewed 15 leather boots, 8 jackets in navy/gray/black, 0 sneakers."
79
+ },
80
+ {
81
+ "type": "wishlist_add",
82
+ "item": "Cozy Dark Gray Women's Jacket",
83
+ "category": "apparel",
84
+ "style": "jacket"
85
+ }
86
+ ]
87
+ }
search_personalization/agentic_memory/schema_cache.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schema Cache — Discovers the product index schema from OpenSearch and caches it.
3
+
4
+ Runs on a schedule (daily at 7am) to keep the Query Understanding Agent prompt
5
+ in sync with the actual index mapping without per-query overhead.
6
+ """
7
+
8
+ import json
9
+ import threading
10
+ import time
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from search_personalization.data_loader import get_opensearch_client
16
+
17
+ _CACHE_FILE = Path(__file__).parent / "schema_cache" / "products_schema.json"
18
+ _FILTERABLE_TYPES = {"keyword", "float", "integer", "boolean"}
19
+ _INDEX_NAME = "products"
20
+ _EXCLUDED_FIELDS = {"id", "image", "image_binary", "product_description_vector"}
21
+ _MAX_TERMS = 50
22
+
23
+
24
+ def discover_schema() -> dict:
25
+ """Fetch index mapping and top terms for filterable fields from OpenSearch."""
26
+ client = get_opensearch_client()
27
+
28
+ # Get mapping
29
+ mapping_resp = client.indices.get_mapping(index=_INDEX_NAME)
30
+ properties = mapping_resp[_INDEX_NAME]["mappings"]["properties"]
31
+
32
+ # Identify filterable fields
33
+ schema = {}
34
+ keyword_fields = []
35
+ for field_name, field_def in properties.items():
36
+ if field_name in _EXCLUDED_FIELDS:
37
+ continue
38
+ field_type = field_def.get("type")
39
+ if field_type in _FILTERABLE_TYPES:
40
+ schema[field_name] = {"type": field_type, "values": None}
41
+ if field_type == "keyword":
42
+ keyword_fields.append(field_name)
43
+
44
+ # Get top terms for keyword fields via aggregations
45
+ if keyword_fields:
46
+ aggs = {f: {"terms": {"field": f, "size": _MAX_TERMS}} for f in keyword_fields}
47
+ agg_resp = client.search(index=_INDEX_NAME, body={"size": 0, "aggs": aggs})
48
+ for field_name in keyword_fields:
49
+ buckets = agg_resp["aggregations"][field_name].get("buckets", [])
50
+ schema[field_name]["values"] = [b["key"] for b in buckets]
51
+
52
+ return {"index": _INDEX_NAME, "discovered_at": datetime.now().isoformat(), "fields": schema}
53
+
54
+
55
+ def refresh_cache() -> dict:
56
+ """Run discovery and write result to disk cache."""
57
+ schema = discover_schema()
58
+ _CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
59
+ _CACHE_FILE.write_text(json.dumps(schema, indent=2))
60
+ return schema
61
+
62
+
63
+ def get_cached_schema() -> Optional[dict]:
64
+ """Load cached schema from disk. Returns None if not cached."""
65
+ if _CACHE_FILE.exists():
66
+ return json.loads(_CACHE_FILE.read_text())
67
+ return None
68
+
69
+
70
+ def get_schema_prompt_block() -> str:
71
+ """Render the cached schema as a prompt block for the Query Agent."""
72
+ schema = get_cached_schema()
73
+ if not schema:
74
+ return ""
75
+
76
+ lines = [f"PRODUCT INDEX SCHEMA (discovered from '{schema['index']}' index, use these exact values for filters):"]
77
+ for field_name, field_def in schema["fields"].items():
78
+ if field_def["values"]:
79
+ lines.append(f"- {field_name} ({field_def['type']}): {', '.join(str(v) for v in field_def['values'])}")
80
+ else:
81
+ lines.append(f"- {field_name} ({field_def['type']})")
82
+ return "\n".join(lines)
83
+
84
+
85
+ def _run_scheduler(hour: int = 7, minute: int = 0):
86
+ """Background thread that refreshes the cache daily at the specified time."""
87
+ while True:
88
+ now = datetime.now()
89
+ target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
90
+ if target <= now:
91
+ target += timedelta(days=1)
92
+ wait_seconds = (target - now).total_seconds()
93
+ time.sleep(wait_seconds)
94
+ try:
95
+ refresh_cache()
96
+ print(f"[schema_cache] Refreshed at {datetime.now().isoformat()}")
97
+ except Exception as e:
98
+ print(f"[schema_cache] Refresh failed: {e}")
99
+
100
+
101
+ def start_scheduler(hour: int = 7, minute: int = 0):
102
+ """Start the daily schema refresh as a daemon thread."""
103
+ t = threading.Thread(target=_run_scheduler, args=(hour, minute), daemon=True)
104
+ t.start()
search_personalization/data_loader.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data loader for products from YAML file and OpenSearch indexing."""
2
+ import os
3
+ import yaml
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any, Optional
6
+ from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth, helpers
7
+ import boto3
8
+ from dotenv import load_dotenv
9
+
10
+
11
+ # Load environment variables
12
+ load_dotenv()
13
+
14
+ # OpenSearch configuration from environment variables
15
+ OPENSEARCH_ENDPOINT = os.getenv("OPENSEARCH_ENDPOINT", "")
16
+ AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
17
+
18
+
19
+ def load_products_from_yaml(yaml_path: str = "../search_personalization/product_samples/products.yaml") -> List[Dict[str, Any]]:
20
+ """
21
+ Load products from YAML file.
22
+
23
+ Args:
24
+ yaml_path: Path to the products YAML file (relative to src directory)
25
+
26
+ Returns:
27
+ List of product dictionaries
28
+ """
29
+ # Get path relative to this file
30
+ file_path = Path(__file__).parent / yaml_path
31
+
32
+ if not file_path.exists():
33
+ raise FileNotFoundError(f"Products YAML file not found at: {file_path}")
34
+
35
+ print(f"Loading products from: {file_path}")
36
+
37
+ with open(file_path, 'r') as f:
38
+ products = yaml.safe_load(f)
39
+
40
+ return products if products else []
41
+
42
+
43
+ def get_sample_products(count: int = 10) -> List[Dict[str, Any]]:
44
+ """
45
+ Get a sample of products from the YAML file.
46
+
47
+ Args:
48
+ count: Number of products to return
49
+
50
+ Returns:
51
+ List of product dictionaries
52
+ """
53
+ products = load_products_from_yaml()
54
+ return products[:count]
55
+
56
+
57
+ _opensearch_client: Optional[OpenSearch] = None
58
+
59
+
60
+ def get_opensearch_client(endpoint: str = OPENSEARCH_ENDPOINT) -> OpenSearch:
61
+ """
62
+ Return a cached OpenSearch client singleton (created once, reused).
63
+
64
+ Authenticates using AWS SigV4 signing with the IAM role resolved from
65
+ the configured AWS_PROFILE (or default credential chain).
66
+
67
+ Args:
68
+ endpoint: OpenSearch cluster endpoint URL
69
+
70
+ Returns:
71
+ OpenSearch client instance
72
+ """
73
+ global _opensearch_client
74
+ if _opensearch_client is not None:
75
+ return _opensearch_client
76
+
77
+ # Extract host from endpoint URL
78
+ host = endpoint.replace('https://', '').replace('http://', '')
79
+
80
+ # Authenticate via AWS SigV4 signing using IAM role
81
+ aws_profile = os.getenv("AWS_PROFILE")
82
+ session = boto3.Session(profile_name=aws_profile) if aws_profile else boto3.Session()
83
+ credentials = session.get_credentials()
84
+ if not credentials:
85
+ raise ValueError("AWS credentials not found")
86
+ http_auth = AWSV4SignerAuth(credentials, AWS_REGION, 'es')
87
+
88
+ # Create OpenSearch client
89
+ _opensearch_client = OpenSearch(
90
+ hosts=[{'host': host, 'port': 443}],
91
+ http_auth=http_auth,
92
+ use_ssl=True,
93
+ verify_certs=True,
94
+ connection_class=RequestsHttpConnection,
95
+ timeout=30,
96
+ max_retries=3,
97
+ retry_on_timeout=True
98
+ )
99
+
100
+ return _opensearch_client
101
+
102
+
103
+ def create_products_index(client: OpenSearch, index_name: str = 'products') -> None:
104
+ """
105
+ Create the products index with appropriate mappings.
106
+
107
+ Args:
108
+ client: OpenSearch client
109
+ index_name: Name of the index to create
110
+ """
111
+ ingest_pipeline = os.getenv('OPENSEARCH_INGEST_PIPELINE', 'product-multimodal-pipeline')
112
+ index_body = {
113
+ 'settings': {
114
+ 'default_pipeline': ingest_pipeline,
115
+ 'index.knn': True,
116
+ 'number_of_shards': 1,
117
+ 'number_of_replicas': 1
118
+ },
119
+ 'mappings': {
120
+ 'properties': {
121
+ 'id': {'type': 'keyword'},
122
+ 'name': {'type': 'text', 'analyzer': 'standard'},
123
+ 'description': {'type': 'text', 'analyzer': 'standard'},
124
+ 'price': {'type': 'float'},
125
+ 'category': {'type': 'keyword'},
126
+ 'style': {'type': 'keyword'},
127
+ 'current_stock': {'type': 'integer'},
128
+ 'image': {'type': 'keyword'},
129
+ 'gender_affinity': {'type': 'keyword'},
130
+ 'where_visible': {'type': 'keyword'},
131
+ 'promoted': {'type': 'boolean'},
132
+ 'product_description_vector': {
133
+ 'type': 'knn_vector',
134
+ 'dimension': 1024,
135
+ 'method': {
136
+ 'name': 'hnsw',
137
+ 'space_type': 'cosinesimil',
138
+ 'engine': 'faiss',
139
+ 'parameters': {
140
+ 'ef_construction': 128,
141
+ 'm': 16
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ # Delete index if it exists
150
+ if client.indices.exists(index=index_name):
151
+ print(f"Deleting existing index: {index_name}")
152
+ client.indices.delete(index=index_name)
153
+
154
+ # Create new index
155
+ print(f"Creating index: {index_name}")
156
+ client.indices.create(index=index_name, body=index_body)
157
+ print(f"Index '{index_name}' created successfully")
158
+
159
+
160
+ def _get_image_base64(product: Dict[str, Any]) -> Optional[str]:
161
+ """Load product image as base64 string for multimodal embedding."""
162
+ import base64
163
+ category = product.get('category', '')
164
+ image_filename = product.get('image', '')
165
+ if not category or not image_filename:
166
+ return None
167
+ image_path = Path(__file__).parent.parent / 'product_samples' / category / image_filename
168
+ if not image_path.exists():
169
+ return None
170
+ with open(image_path, 'rb') as f:
171
+ return base64.b64encode(f.read()).decode('utf-8')
172
+
173
+
174
+ def index_products_to_opensearch(
175
+ products: List[Dict[str, Any]],
176
+ client: OpenSearch = None,
177
+ index_name: str = 'products'
178
+ ) -> tuple[int, int]:
179
+ """
180
+ Index products into OpenSearch using bulk API.
181
+
182
+ Args:
183
+ products: List of product dictionaries
184
+ client: OpenSearch client (creates new one if None)
185
+ index_name: Name of the index
186
+
187
+ Returns:
188
+ Tuple of (success_count, failed_count)
189
+ """
190
+ from opensearchpy import helpers
191
+
192
+ if client is None:
193
+ client = get_opensearch_client()
194
+
195
+ # Prepare bulk actions with image_binary for multimodal embedding
196
+ actions = []
197
+ for product in products:
198
+ doc = dict(product)
199
+ image_b64 = _get_image_base64(product)
200
+ if image_b64:
201
+ doc['image_binary'] = image_b64
202
+ action = {
203
+ '_index': index_name,
204
+ '_id': product['id'],
205
+ '_source': doc
206
+ }
207
+ actions.append(action)
208
+
209
+ # Bulk index
210
+ print(f"Indexing {len(products)} products...")
211
+ success, failed = helpers.bulk(client, actions, raise_on_error=False)
212
+
213
+ print(f"Successfully indexed: {success} products")
214
+ if failed:
215
+ print(f"Failed to index: {len(failed)} products")
216
+
217
+ # Refresh index to make documents searchable
218
+ client.indices.refresh(index=index_name)
219
+ print(f"Index '{index_name}' refreshed")
220
+
221
+ return success, len(failed) if failed else 0
222
+
223
+
224
+ def load_yaml_to_opensearch(
225
+ yaml_path: str = "../search_personalization/product_samples/products.yaml",
226
+ endpoint: str = OPENSEARCH_ENDPOINT,
227
+ index_name: str = 'products',
228
+ recreate_index: bool = True
229
+ ) -> None:
230
+ """
231
+ Load products from YAML and index them into OpenSearch.
232
+
233
+ Args:
234
+ yaml_path: Path to the products YAML file
235
+ endpoint: OpenSearch cluster endpoint URL
236
+ index_name: Name of the index
237
+ recreate_index: Whether to recreate the index (deletes existing)
238
+ """
239
+ # Load products from YAML
240
+ print("Loading products from YAML...")
241
+ products = load_products_from_yaml(yaml_path)
242
+ print(f"Loaded {len(products)} products")
243
+
244
+ # Get OpenSearch client
245
+ print(f"Connecting to OpenSearch at {endpoint}...")
246
+ client = get_opensearch_client(endpoint)
247
+
248
+ # Test connection
249
+ info = client.info()
250
+ print(f"Connected to cluster: {info['cluster_name']}")
251
+
252
+ # Create index if needed
253
+ if recreate_index:
254
+ create_products_index(client, index_name)
255
+
256
+ # Index products
257
+ success, failed = index_products_to_opensearch(products, client, index_name)
258
+
259
+ print(f"\n✓ Indexing complete: {success} successful, {failed} failed")
260
+
261
+
262
+ # --- Persona Index ---
263
+
264
+ PERSONAS = [
265
+ {
266
+ "user_id": "anonymous",
267
+ "name": "Anonymous User",
268
+ "age": 30,
269
+ "gender": "unspecified",
270
+ "location": "General",
271
+ "occupation": "General User",
272
+ "interests": ["General Shopping"],
273
+ "shopping_behavior": "Standard search without personalization",
274
+ "key": "anonymous",
275
+ "emoji": "👤"
276
+ },
277
+ {
278
+ "name": "Sarah",
279
+ "user_id": "user1",
280
+ "age": 32,
281
+ "gender": "female",
282
+ "location": "San Francisco, CA",
283
+ "occupation": "Marketing Manager",
284
+ "interests": ["Technology", "Fitness", "Travel"],
285
+ "shopping_behavior": "Focused on quality and trends.",
286
+ "key": "business_professional",
287
+ "emoji": "👩‍💼"
288
+ },
289
+ {
290
+ "name": "Alex",
291
+ "user_id": "user2",
292
+ "age": 21,
293
+ "gender": "male",
294
+ "location": "Austin, TX",
295
+ "occupation": "Computer Science Student",
296
+ "interests": ["Gaming", "Programming", "Music"],
297
+ "shopping_behavior": "Budget-conscious, looks for deals and discounts",
298
+ "key": "student",
299
+ "emoji": "🎓"
300
+ }
301
+ ]
302
+
303
+
304
+ def create_personas_index(client: OpenSearch, index_name: str = 'personas') -> None:
305
+ """Create the personas index with appropriate mappings."""
306
+ index_body = {
307
+ 'settings': {
308
+ 'number_of_shards': 1,
309
+ 'number_of_replicas': 1
310
+ },
311
+ 'mappings': {
312
+ 'properties': {
313
+ 'user_id': {'type': 'keyword'},
314
+ 'name': {'type': 'text'},
315
+ 'age': {'type': 'integer'},
316
+ 'gender': {'type': 'keyword'},
317
+ 'location': {'type': 'text', 'fields': {'keyword': {'type': 'keyword'}}},
318
+ 'occupation': {'type': 'text', 'fields': {'keyword': {'type': 'keyword'}}},
319
+ 'interests': {'type': 'keyword'},
320
+ 'shopping_behavior': {'type': 'text'},
321
+ 'key': {'type': 'keyword'},
322
+ 'emoji': {'type': 'keyword'}
323
+ }
324
+ }
325
+ }
326
+
327
+ if client.indices.exists(index=index_name):
328
+ print(f"Deleting existing index: {index_name}")
329
+ client.indices.delete(index=index_name)
330
+
331
+ print(f"Creating index: {index_name}")
332
+ client.indices.create(index=index_name, body=index_body)
333
+ print(f"Index '{index_name}' created successfully")
334
+
335
+
336
+ def index_personas(
337
+ client: OpenSearch = None,
338
+ index_name: str = 'personas'
339
+ ) -> tuple[int, int]:
340
+ """Index persona documents into OpenSearch."""
341
+ if client is None:
342
+ client = get_opensearch_client()
343
+
344
+ actions = [
345
+ {'_index': index_name, '_id': p['user_id'], '_source': p}
346
+ for p in PERSONAS
347
+ ]
348
+
349
+ print(f"Indexing {len(PERSONAS)} personas...")
350
+ success, failed = helpers.bulk(client, actions, raise_on_error=False)
351
+
352
+ print(f"Successfully indexed: {success} personas")
353
+ if failed:
354
+ print(f"Failed to index: {len(failed)} personas")
355
+
356
+ client.indices.refresh(index=index_name)
357
+ print(f"Index '{index_name}' refreshed")
358
+
359
+ return success, len(failed) if failed else 0
360
+
361
+
362
+ def load_personas_to_opensearch(
363
+ endpoint: str = OPENSEARCH_ENDPOINT,
364
+ index_name: str = 'personas',
365
+ recreate_index: bool = True
366
+ ) -> None:
367
+ """Create the personas index and load all persona documents."""
368
+ print(f"Connecting to OpenSearch at {endpoint}...")
369
+ client = get_opensearch_client(endpoint)
370
+
371
+ info = client.info()
372
+ print(f"Connected to cluster: {info['cluster_name']}")
373
+
374
+ if recreate_index:
375
+ create_personas_index(client, index_name)
376
+
377
+ success, failed = index_personas(client, index_name)
378
+ print(f"\n✓ Persona indexing complete: {success} successful, {failed} failed")
379
+
380
+
381
+ if __name__ == '__main__':
382
+ # Run the data loading when script is executed directly
383
+ load_yaml_to_opensearch()
search_personalization/iam-policy.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Version": "2012-10-17",
3
+ "Statement": [
4
+ {
5
+ "Sid": "OpenSearchRead",
6
+ "Effect": "Allow",
7
+ "Action": [
8
+ "es:ESHttpGet",
9
+ "es:ESHttpHead",
10
+ "es:ESHttpPost"
11
+ ],
12
+ "Resource": [
13
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/products*",
14
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/personas*",
15
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/user_preferences*",
16
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/_plugins/_ml/memory_containers*"
17
+ ]
18
+ },
19
+ {
20
+ "Sid": "OpenSearchWriteUserPreferences",
21
+ "Effect": "Allow",
22
+ "Action": [
23
+ "es:ESHttpPut",
24
+ "es:ESHttpPost"
25
+ ],
26
+ "Resource": [
27
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/user_preferences*"
28
+ ]
29
+ },
30
+ {
31
+ "Sid": "OpenSearchWriteMemoryContainers",
32
+ "Effect": "Allow",
33
+ "Action": [
34
+ "es:ESHttpPost"
35
+ ],
36
+ "Resource": [
37
+ "arn:aws:es:us-east-1:099713751195:domain/mydomain/_plugins/_ml/memory_containers*"
38
+ ]
39
+ },
40
+ {
41
+ "Sid": "BedrockInvokeModels",
42
+ "Effect": "Allow",
43
+ "Action": [
44
+ "bedrock:InvokeModel",
45
+ "bedrock:InvokeModelWithResponseStream"
46
+ ],
47
+ "Resource": [
48
+ "arn:aws:bedrock:us-east-1:099713751195:inference-profile/us.amazon.nova-micro-v1:0",
49
+ "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-micro-v1:0",
50
+ "arn:aws:bedrock:us-west-2::foundation-model/cohere.rerank-v3-5:0"
51
+ ]
52
+ },
53
+ {
54
+ "Sid": "OSISIngestUBI",
55
+ "Effect": "Allow",
56
+ "Action": [
57
+ "osis:Ingest"
58
+ ],
59
+ "Resource": [
60
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-events-pipeline",
61
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-events-pipeline/*",
62
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-queries-pipeline",
63
+ "arn:aws:osis:us-east-1:099713751195:pipeline/ubi-queries-pipeline/*"
64
+ ]
65
+ }
66
+ ]
67
+ }
search_personalization/image_utils.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image mapping utilities for product catalog.
3
+
4
+ This module provides functions to map product image filenames to their
5
+ actual file paths in the product_samples directory structure, with
6
+ fallback handling for missing images and caching for performance.
7
+ """
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+ from functools import lru_cache
12
+
13
+
14
+ # Base path for product images (relative to project root)
15
+ PRODUCT_SAMPLES_BASE = "search_personalization/product_samples"
16
+ PLACEHOLDER_IMAGE = "search_personalization/product_samples/product_image_coming_soon.png"
17
+ THUMBNAIL_DIR = Path("search_personalization/product_samples/.thumbnails")
18
+
19
+ # In-memory cache for image path existence checks
20
+ _image_cache: Dict[str, str] = {}
21
+
22
+
23
+ def get_thumbnail_path(product: Dict) -> str:
24
+ """
25
+ Get a small thumbnail for fast UI rendering (~200px wide, ~70% quality).
26
+ Generates on first access, then serves from disk cache.
27
+
28
+ Args:
29
+ product: Product dictionary containing 'category' and 'image' fields
30
+
31
+ Returns:
32
+ Path to the thumbnail image, or placeholder if original doesn't exist.
33
+ """
34
+ original = get_image_path(product)
35
+ if original == PLACEHOLDER_IMAGE:
36
+ return PLACEHOLDER_IMAGE
37
+
38
+ rel = Path(original).relative_to(PRODUCT_SAMPLES_BASE)
39
+ thumb = THUMBNAIL_DIR / rel
40
+ if thumb.exists():
41
+ return str(thumb)
42
+
43
+ # Generate thumbnail
44
+ try:
45
+ from PIL import Image
46
+ thumb.parent.mkdir(parents=True, exist_ok=True)
47
+ img = Image.open(original)
48
+ img.thumbnail((200, 200))
49
+ img.save(thumb, "JPEG", quality=70)
50
+ return str(thumb)
51
+ except Exception:
52
+ return original
53
+
54
+
55
+ def get_image_path(product: Dict, use_cache: bool = True) -> str:
56
+ """
57
+ Map product image filename to actual file path in product_samples directory.
58
+
59
+ This function constructs the full path to a product image based on the
60
+ product's category and image filename. If the image file doesn't exist,
61
+ it returns the path to a placeholder image.
62
+
63
+ Args:
64
+ product: Product dictionary containing 'category' and 'image' fields
65
+ use_cache: Whether to use cached image path results (default: True)
66
+
67
+ Returns:
68
+ String path to the image file, either the actual product image or
69
+ the placeholder image if the product image is not found
70
+
71
+ Examples:
72
+ >>> product = {"category": "accessories", "image": "abc123.jpg"}
73
+ >>> get_image_path(product)
74
+ 'product_samples/accessories/abc123.jpg'
75
+
76
+ >>> product = {"category": "apparel", "image": "missing.jpg"}
77
+ >>> get_image_path(product) # If missing.jpg doesn't exist
78
+ 'product_samples/product_image_coming_soon.png'
79
+
80
+ Requirements: 9.4
81
+ """
82
+ # Extract category and image from product
83
+ category = product.get('category', '')
84
+ image_filename = product.get('image', '')
85
+
86
+ # If no image filename provided, return placeholder
87
+ if not image_filename:
88
+ return PLACEHOLDER_IMAGE
89
+
90
+ # If no category provided, return placeholder
91
+ if not category:
92
+ return PLACEHOLDER_IMAGE
93
+
94
+ # Create cache key
95
+ cache_key = f"{category}/{image_filename}"
96
+
97
+ # Check cache first if enabled
98
+ if use_cache and cache_key in _image_cache:
99
+ return _image_cache[cache_key]
100
+
101
+ # Construct the expected image path
102
+ image_path = os.path.join(PRODUCT_SAMPLES_BASE, category, image_filename)
103
+
104
+ # Check if the image file exists
105
+ if os.path.exists(image_path) and os.path.isfile(image_path):
106
+ # Cache the result
107
+ if use_cache:
108
+ _image_cache[cache_key] = image_path
109
+ return image_path
110
+
111
+ # Image doesn't exist, return placeholder
112
+ if use_cache:
113
+ _image_cache[cache_key] = PLACEHOLDER_IMAGE
114
+ return PLACEHOLDER_IMAGE
115
+
116
+
117
+ @lru_cache(maxsize=1000)
118
+ def get_image_path_cached(category: str, image_filename: str) -> str:
119
+ """
120
+ Cached version of image path lookup using functools.lru_cache.
121
+
122
+ This function provides an alternative caching mechanism using Python's
123
+ built-in LRU cache decorator. It's more efficient for repeated lookups
124
+ of the same category/image combinations.
125
+
126
+ Args:
127
+ category: Product category (e.g., 'accessories', 'apparel', 'footwear')
128
+ image_filename: Image filename (e.g., 'abc123.jpg')
129
+
130
+ Returns:
131
+ String path to the image file or placeholder
132
+
133
+ Note:
134
+ This function uses @lru_cache which caches up to 1000 unique
135
+ category/filename combinations. The cache persists for the
136
+ lifetime of the application.
137
+ """
138
+ # If no image filename provided, return placeholder
139
+ if not image_filename:
140
+ return PLACEHOLDER_IMAGE
141
+
142
+ # If no category provided, return placeholder
143
+ if not category:
144
+ return PLACEHOLDER_IMAGE
145
+
146
+ # Construct the expected image path
147
+ image_path = os.path.join(PRODUCT_SAMPLES_BASE, category, image_filename)
148
+
149
+ # Check if the image file exists
150
+ if os.path.exists(image_path) and os.path.isfile(image_path):
151
+ return image_path
152
+
153
+ # Image doesn't exist, return placeholder
154
+ return PLACEHOLDER_IMAGE
155
+
156
+
157
+ def clear_image_cache() -> None:
158
+ """
159
+ Clear the in-memory image path cache.
160
+
161
+ This function clears both the manual cache dictionary and the
162
+ LRU cache used by get_image_path_cached. Useful for testing
163
+ or when the file system changes.
164
+ """
165
+ global _image_cache
166
+ _image_cache.clear()
167
+ get_image_path_cached.cache_clear()
168
+
169
+
170
+ def get_cache_info() -> Dict[str, any]:
171
+ """
172
+ Get information about the current cache state.
173
+
174
+ Returns:
175
+ Dictionary containing cache statistics:
176
+ - manual_cache_size: Number of entries in the manual cache
177
+ - lru_cache_info: Statistics from the LRU cache
178
+ """
179
+ return {
180
+ 'manual_cache_size': len(_image_cache),
181
+ 'lru_cache_info': get_image_path_cached.cache_info()._asdict()
182
+ }
183
+
184
+
185
+ def preload_image_cache(products: list) -> int:
186
+ """
187
+ Preload the image cache with paths for a list of products.
188
+
189
+ This function can be used to warm up the cache when loading
190
+ products from OpenSearch, improving performance for subsequent
191
+ image path lookups.
192
+
193
+ Args:
194
+ products: List of product dictionaries
195
+
196
+ Returns:
197
+ Number of image paths successfully cached
198
+
199
+ Example:
200
+ >>> products = [
201
+ ... {"category": "accessories", "image": "img1.jpg"},
202
+ ... {"category": "apparel", "image": "img2.jpg"}
203
+ ... ]
204
+ >>> count = preload_image_cache(products)
205
+ >>> print(f"Cached {count} image paths")
206
+ """
207
+ cached_count = 0
208
+
209
+ for product in products:
210
+ category = product.get('category', '')
211
+ image_filename = product.get('image', '')
212
+
213
+ if category and image_filename:
214
+ # Use the cached version to populate the LRU cache
215
+ get_image_path_cached(category, image_filename)
216
+ cached_count += 1
217
+
218
+ return cached_count
219
+
220
+
221
+ def validate_placeholder_exists() -> bool:
222
+ """
223
+ Check if the placeholder image file exists.
224
+
225
+ Returns:
226
+ True if placeholder image exists, False otherwise
227
+ """
228
+ return os.path.exists(PLACEHOLDER_IMAGE) and os.path.isfile(PLACEHOLDER_IMAGE)
search_personalization/product_samples/.thumbnails/accessories/01b24cde-af53-4629-b7f0-0965f795b870.jpg ADDED

Git LFS Details

  • SHA256: 3876a8391e26a6046f0bef61c06a308d369c1cb14e011275c3435e6c2743278c
  • Pointer size: 129 Bytes
  • Size of remote file: 6.86 kB
search_personalization/product_samples/.thumbnails/accessories/1249276c-851a-4f46-81fb-873fd7d5f60f.jpg ADDED

Git LFS Details

  • SHA256: 9a8d133d9eef0733a5383e70b4c14c773e2e4ee3ea191afc28052f3e4b8ac4fe
  • Pointer size: 129 Bytes
  • Size of remote file: 4.07 kB
search_personalization/product_samples/.thumbnails/accessories/16842055-6a51-443b-843c-51bc85dee353.jpg ADDED

Git LFS Details

  • SHA256: 3c6f6cef0d7406e8e18bc980b2f5df9f94116e68cd65f4eba747bdb42857bbe0
  • Pointer size: 129 Bytes
  • Size of remote file: 4.72 kB
search_personalization/product_samples/.thumbnails/accessories/1a3fa9d4-e320-4873-be58-f3f6af5f99f4.jpg ADDED

Git LFS Details

  • SHA256: a4a7b56eb0217554bce3efed405bf1ff4be6a7e64708487f6c506e6af2f3bb7a
  • Pointer size: 129 Bytes
  • Size of remote file: 4.64 kB
search_personalization/product_samples/.thumbnails/accessories/1d3ae532-f790-44ca-a8e8-f55aa9b66526.jpg ADDED

Git LFS Details

  • SHA256: fe64afcb13601f060b4195308405f8aca21ef17ca55f2e8b81225cccb9772416
  • Pointer size: 129 Bytes
  • Size of remote file: 5 kB
search_personalization/product_samples/.thumbnails/accessories/28c14c06-d947-44d0-96f8-dc2113845e4c.jpg ADDED

Git LFS Details

  • SHA256: 4ea15409dae070ea9127f2a8f083f48a83353b5910950eab184619317d41b9b1
  • Pointer size: 129 Bytes
  • Size of remote file: 5.23 kB
search_personalization/product_samples/.thumbnails/accessories/296d144e-7f86-464b-9c5a-f545257f1700.jpg ADDED

Git LFS Details

  • SHA256: d5d931fdb2d679d7bfe3beb356f23ec0988d9dcbb9d53ca37aeb5acf56c49a6a
  • Pointer size: 129 Bytes
  • Size of remote file: 4.58 kB
search_personalization/product_samples/.thumbnails/accessories/2c27752b-8194-489f-96fa-be356fd811e0.jpg ADDED

Git LFS Details

  • SHA256: 080e854f0e072218d469cee85336a8d6251902963a9674b24b5fb279336d1569
  • Pointer size: 129 Bytes
  • Size of remote file: 5.98 kB
search_personalization/product_samples/.thumbnails/accessories/2cc89e77-685d-423c-80a9-82e07ea13b7c.jpg ADDED

Git LFS Details

  • SHA256: 2aa3f694739da7a25b15fea67158fc18ab32559b74f983fe36a90d7ac5525354
  • Pointer size: 129 Bytes
  • Size of remote file: 5.94 kB
search_personalization/product_samples/.thumbnails/accessories/2e852905-c6f4-47db-802c-654013571922.jpg ADDED

Git LFS Details

  • SHA256: e9523a6e01f0e91d5c6dd89e22b59cc45fbc19035028e40e26e6e3aa4e89c3ab
  • Pointer size: 129 Bytes
  • Size of remote file: 4.81 kB
search_personalization/product_samples/.thumbnails/accessories/2f1ec487-86b9-4310-ba6d-3121019ef41d.jpg ADDED

Git LFS Details

  • SHA256: 22d4d367e445e076487f530bf0b89c2dd286e0a00d8fda06f6b5431b39b63742
  • Pointer size: 129 Bytes
  • Size of remote file: 3.42 kB
search_personalization/product_samples/.thumbnails/accessories/3491deff-c0fe-4065-abbc-72b507da84b2.jpg ADDED

Git LFS Details

  • SHA256: 4468f7f4151bbd47a38e6accfbd81bcb5f09f42531d970f1abc575b3f70763a9
  • Pointer size: 129 Bytes
  • Size of remote file: 5.81 kB
search_personalization/product_samples/.thumbnails/accessories/371cef78-adf2-4cb0-8e78-02f8cb7ee013.jpg ADDED

Git LFS Details

  • SHA256: c75ea705c6ef8172b3b577be6c1163fa52e689d4fd5c8953014067e1ee836e4c
  • Pointer size: 129 Bytes
  • Size of remote file: 4.56 kB
search_personalization/product_samples/.thumbnails/accessories/39db1f40-046d-4fa3-861b-babfc4ba4023.jpg ADDED

Git LFS Details

  • SHA256: eb0c2ecf033cdfacb93d3a963982a6818a274b312b6dcd9d482cf0e542fa3885
  • Pointer size: 129 Bytes
  • Size of remote file: 3.84 kB
search_personalization/product_samples/.thumbnails/accessories/3cd33786-a655-407f-b22f-c4150b1e529e.jpg ADDED

Git LFS Details

  • SHA256: 69d498ae34710376881cbd98e0d37be8eecf7e217f4e37dd268ccc959b287a19
  • Pointer size: 129 Bytes
  • Size of remote file: 4.2 kB
search_personalization/product_samples/.thumbnails/accessories/3eba60d7-fbde-4f4d-bb3a-30df62057fe0.jpg ADDED

Git LFS Details

  • SHA256: 44e63569c9f511ddbde77a3ce26bd72d2b6e3cb7c786f291cd9894cad0328217
  • Pointer size: 129 Bytes
  • Size of remote file: 6.55 kB
search_personalization/product_samples/.thumbnails/accessories/3efb5bf2-b419-43b9-9b1f-78a6aa6fc2b7.jpg ADDED

Git LFS Details

  • SHA256: d5ee6f6676105ab00a671befefe442c1da3661683ba04358c21cba2a533616c5
  • Pointer size: 129 Bytes
  • Size of remote file: 3.27 kB
search_personalization/product_samples/.thumbnails/accessories/41ab23ce-b417-46b2-a52a-bf7030f93161.jpg ADDED

Git LFS Details

  • SHA256: 63c0f54b54a09b1b44834c2e8bd25a9c52e81edd0fb6f1425347cdb69e290765
  • Pointer size: 129 Bytes
  • Size of remote file: 3.81 kB
search_personalization/product_samples/.thumbnails/accessories/437f3c6b-5fe6-4af0-9db6-ff3a0f44f585.jpg ADDED

Git LFS Details

  • SHA256: 3c4460156076846c30d0a92004a9298a1498bd13055fcc15de0d30c5d6a068dd
  • Pointer size: 129 Bytes
  • Size of remote file: 6 kB
search_personalization/product_samples/.thumbnails/accessories/4952a24b-7c7c-4ee6-a448-720e97927ba8.jpg ADDED

Git LFS Details

  • SHA256: a372177348c54990968cd59c6df41db0f03f79ad65b3166a0516969de1a647a5
  • Pointer size: 129 Bytes
  • Size of remote file: 4.81 kB
search_personalization/product_samples/.thumbnails/accessories/4ec7ff5c-f70f-4984-b6c4-c7ef37cc0c09.jpg ADDED

Git LFS Details

  • SHA256: f0c9fdd4c89436655a269eb4f145fe01035abb79ab61f54a3f051bb0577ff889
  • Pointer size: 129 Bytes
  • Size of remote file: 5.19 kB
search_personalization/product_samples/.thumbnails/accessories/563bfd0b-9689-4b85-a65e-fbb18051e773.jpg ADDED

Git LFS Details

  • SHA256: 5b84b73ff2ae2f8bbd834ac0f0a87f1d12dad16657192d97780432dc4b5fe53a
  • Pointer size: 129 Bytes
  • Size of remote file: 4.41 kB
search_personalization/product_samples/.thumbnails/accessories/564f9ced-8823-45ed-a781-1dc555353dd9.jpg ADDED

Git LFS Details

  • SHA256: f947f71f524d1ad08166b6ae9436ceb98424ea65ec0409ca35178510df889975
  • Pointer size: 129 Bytes
  • Size of remote file: 3.68 kB
search_personalization/product_samples/.thumbnails/accessories/58e99e9e-8f17-4bda-a82c-f08d3eddacb3.jpg ADDED

Git LFS Details

  • SHA256: 0e37a0f7fc51e191830457b1a704a9477916b2e383759077c76623189b09110b
  • Pointer size: 129 Bytes
  • Size of remote file: 5.17 kB
search_personalization/product_samples/.thumbnails/accessories/5af0136a-862b-42fa-a127-958a8bbd2167.jpg ADDED

Git LFS Details

  • SHA256: ec5f9d711cd85ad01aab0b667e990b51fa314027065361df4be8c0d04fd8183f
  • Pointer size: 129 Bytes
  • Size of remote file: 4.37 kB
search_personalization/product_samples/.thumbnails/accessories/5c010bac-31a6-4ff3-9557-2f5e20b509cc.jpg ADDED

Git LFS Details

  • SHA256: f2943bde15921e303e35332f84ca21e700c571b02e7855efbf3dd505e4ce0aa1
  • Pointer size: 129 Bytes
  • Size of remote file: 3.48 kB