Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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
- .env.example +18 -0
- .gitattributes +1 -0
- .gitignore +4 -1
- app.py +52 -11
- pages/Search_Personalization.py +937 -0
- requirements.txt +5 -0
- search_personalization/IAM_SETUP.md +170 -0
- search_personalization/__init__.py +1 -0
- search_personalization/agentic_memory/__init__.py +4 -0
- search_personalization/agentic_memory/agents/__init__.py +0 -0
- search_personalization/agentic_memory/agents/query_agent.py +96 -0
- search_personalization/agentic_memory/agents/ranking_agent.py +70 -0
- search_personalization/agentic_memory/agents/results_explanation_agent.py +120 -0
- search_personalization/agentic_memory/config.py +46 -0
- search_personalization/agentic_memory/demo_runner.py +424 -0
- search_personalization/agentic_memory/memory_client.py +168 -0
- search_personalization/agentic_memory/memory_tools.py +347 -0
- search_personalization/agentic_memory/personas/__init__.py +0 -0
- search_personalization/agentic_memory/personas/alex.json +123 -0
- search_personalization/agentic_memory/personas/sarah.json +87 -0
- search_personalization/agentic_memory/schema_cache.py +104 -0
- search_personalization/data_loader.py +383 -0
- search_personalization/iam-policy.json +67 -0
- search_personalization/image_utils.py +228 -0
- search_personalization/product_samples/.thumbnails/accessories/01b24cde-af53-4629-b7f0-0965f795b870.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/1249276c-851a-4f46-81fb-873fd7d5f60f.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/16842055-6a51-443b-843c-51bc85dee353.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/1a3fa9d4-e320-4873-be58-f3f6af5f99f4.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/1d3ae532-f790-44ca-a8e8-f55aa9b66526.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/28c14c06-d947-44d0-96f8-dc2113845e4c.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/296d144e-7f86-464b-9c5a-f545257f1700.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/2c27752b-8194-489f-96fa-be356fd811e0.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/2cc89e77-685d-423c-80a9-82e07ea13b7c.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/2e852905-c6f4-47db-802c-654013571922.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/2f1ec487-86b9-4310-ba6d-3121019ef41d.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/3491deff-c0fe-4065-abbc-72b507da84b2.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/371cef78-adf2-4cb0-8e78-02f8cb7ee013.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/39db1f40-046d-4fa3-861b-babfc4ba4023.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/3cd33786-a655-407f-b22f-c4150b1e529e.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/3eba60d7-fbde-4f4d-bb3a-30df62057fe0.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/3efb5bf2-b419-43b9-9b1f-78a6aa6fc2b7.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/41ab23ce-b417-46b2-a52a-bf7030f93161.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/437f3c6b-5fe6-4af0-9db6-ff3a0f44f585.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/4952a24b-7c7c-4ee6-a448-720e97927ba8.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/4ec7ff5c-f70f-4984-b6c4-c7ef37cc0c09.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/563bfd0b-9689-4b85-a65e-fbb18051e773.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/564f9ced-8823-45ed-a781-1dc555353dd9.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/58e99e9e-8f17-4bda-a82c-f08d3eddacb3.jpg +3 -0
- search_personalization/product_samples/.thumbnails/accessories/5af0136a-862b-42fa-a127-958a8bbd2167.jpg +3 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
search_personalization/product_samples/.thumbnails/accessories/1249276c-851a-4f46-81fb-873fd7d5f60f.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/16842055-6a51-443b-843c-51bc85dee353.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/1a3fa9d4-e320-4873-be58-f3f6af5f99f4.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/1d3ae532-f790-44ca-a8e8-f55aa9b66526.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/28c14c06-d947-44d0-96f8-dc2113845e4c.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/296d144e-7f86-464b-9c5a-f545257f1700.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/2c27752b-8194-489f-96fa-be356fd811e0.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/2cc89e77-685d-423c-80a9-82e07ea13b7c.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/2e852905-c6f4-47db-802c-654013571922.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/2f1ec487-86b9-4310-ba6d-3121019ef41d.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/3491deff-c0fe-4065-abbc-72b507da84b2.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/371cef78-adf2-4cb0-8e78-02f8cb7ee013.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/39db1f40-046d-4fa3-861b-babfc4ba4023.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/3cd33786-a655-407f-b22f-c4150b1e529e.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/3eba60d7-fbde-4f4d-bb3a-30df62057fe0.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/3efb5bf2-b419-43b9-9b1f-78a6aa6fc2b7.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/41ab23ce-b417-46b2-a52a-bf7030f93161.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/437f3c6b-5fe6-4af0-9db6-ff3a0f44f585.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/4952a24b-7c7c-4ee6-a448-720e97927ba8.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/4ec7ff5c-f70f-4984-b6c4-c7ef37cc0c09.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/563bfd0b-9689-4b85-a65e-fbb18051e773.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/564f9ced-8823-45ed-a781-1dc555353dd9.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/58e99e9e-8f17-4bda-a82c-f08d3eddacb3.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/5af0136a-862b-42fa-a127-958a8bbd2167.jpg
ADDED
|
|
Git LFS Details
|
search_personalization/product_samples/.thumbnails/accessories/5c010bac-31a6-4ff3-9557-2f5e20b509cc.jpg
ADDED
|
|
Git LFS Details
|