prasadnu's picture
feat: add Search Personalization demo module
b4d5c9a
Raw
History Blame
17.4 kB
"""
Demo Runner β€” Orchestrates the 3-agent pipeline for the agentic memory demo.
Architecture:
- Long-term memory: user preferences fetched from OpenSearch agentic memory
- Query Understanding Agent: enriches query, searches catalog, returns results directly
- Ranking Agent: receives results directly, reranks with personalization
- Working memory write: async fire-and-forget for observability/tracing
Usage:
python -m src.agentic_memory.demo_runner
python -m src.agentic_memory.demo_runner --query "black boots for a concert"
python -m src.agentic_memory.demo_runner --interactive
"""
import json
import time
import uuid
import threading
from datetime import datetime
from typing import Optional
from search_personalization.agentic_memory.config import PERSONAS
from search_personalization.agentic_memory.memory_tools import get_user_profile
from search_personalization.agentic_memory.agents.query_agent import invoke_query_agent
from search_personalization.agentic_memory.memory_tools import write_session_memory, write_history, read_session_memory
DEFAULT_QUERY = "backpack"
def run_pipeline_for_persona(
query: str,
persona_name: str,
session_id: Optional[str] = None,
conversation_history: Optional[str] = None,
) -> dict:
"""
Run the 3-step pipeline for a single persona:
1. Profile Cache (instant)
2. Query Understanding Agent (enrich + search)
3. Ranking Agent (rerank with direct results)
"""
persona_id = PERSONAS[persona_name]
if not session_id:
session_id = f"sess-{uuid.uuid4().hex[:8]}"
print(f"\n{'='*60}")
print(f" PERSONA: {persona_name.upper()} ({persona_id})")
print(f" Query: \"{query}\"")
print(f" Session: {session_id}")
print(f"{'='*60}")
pipeline_start = time.time()
pipeline_trace = {
"persona_id": persona_id,
"persona_name": persona_name,
"query": query,
"session_id": session_id,
"timestamp": datetime.utcnow().isoformat(),
"agents": {},
}
# --- Step 1: Profile + session in parallel ---
from concurrent.futures import ThreadPoolExecutor
start = time.time()
with ThreadPoolExecutor(max_workers=2) as pool:
profile_future = pool.submit(get_user_profile, persona_id)
if conversation_history:
session_context = conversation_history
profile_raw = profile_future.result()
else:
session_future = pool.submit(read_session_memory, persona_id, session_id)
profile_raw = profile_future.result()
session_context = session_future.result()
# Format USER_PREFERENCE memories into a profile string for the agent
profile_data = json.loads(profile_raw)
memories = profile_data.get("memories", [])
preference_statements = [
m.get("_source", {}).get("memory", "")
for m in memories
if m.get("_source", {}).get("strategy_type") == "USER_PREFERENCE"
]
profile_lines = [f"- {s}" for s in preference_statements if s]
profile = (
f"Profile based on {len(preference_statements)} behavioral observations:\n"
+ "\n".join(profile_lines)
) if profile_lines else ""
memory_duration = time.time() - start
print(f"\n [memory] Profile + session loaded ({memory_duration*1000:.1f}ms)")
pipeline_trace["agents"]["memory"] = {
"duration_ms": round(memory_duration * 1000, 1),
"source": "opensearch-long-term-memory",
"preference_records": len(preference_statements),
}
# --- Step 2: Query Understanding Agent (enrich only) ---
print(f" [agent] Query Understanding Agent β€” enrich...")
start = time.time()
query_result = invoke_query_agent(query, persona_id, session_id, profile=profile or "", session_context=session_context)
query_duration = time.time() - start
pipeline_trace["agents"]["query"] = {
"duration_ms": int(query_duration * 1000),
"output": query_result,
}
print(f" Done ({query_duration:.1f}s)")
# --- Step 2b: Embed enriched query + knn search ---
print(f" [search] Embed + catalog search...")
start = time.time()
try:
enrichment = json.loads(query_result)
except (json.JSONDecodeError, TypeError):
# Agent output may contain surrounding text β€” extract JSON object
enrichment = None
try:
_start = query_result.find("{")
_end = query_result.rfind("}") + 1
if _start >= 0 and _end > _start:
enrichment = json.loads(query_result[_start:_end])
except (json.JSONDecodeError, TypeError):
pass
if not enrichment:
enrichment = {"enriched_query": query, "inferred_attributes": {}}
attrs = enrichment.get("inferred_attributes", {})
enriched_query = enrichment.get("enriched_query", query)
# Let the agent handle all semantic resolution β€” no deterministic style matching.
# Style is a semantic field: "shoes" should surface boots, sneakers, sandals, etc.
# Category and gender_affinity are hard kNN pre-filters.
# max_price is only applied as a kNN filter when the user explicitly mentions
# price in their original query. The agent may infer max_price from profile
# (useful for reranking/explanation) but we don't use it as a hard filter
# unless the user asked for it.
style_filter = None
category_filter = attrs.get("category")
max_price_filter = attrs.get("max_price")
# Ensure max_price is numeric if present
if max_price_filter is not None:
try:
max_price_filter = float(max_price_filter)
except (ValueError, TypeError):
max_price_filter = None
# Only use max_price as a kNN pre-filter if the user explicitly stated price
import re
_price_match = re.search(
r'\$\s*(\d+(?:\.\d+)?)|(?:under|below|less\s+than|cheaper\s+than|max)\s+\$?\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*dollars',
query, re.IGNORECASE
)
if _price_match:
# Extract the price value from whichever group matched
_explicit_price = next((g for g in _price_match.groups() if g is not None), None)
if _explicit_price:
knn_price_filter = float(_explicit_price)
else:
knn_price_filter = max_price_filter
else:
knn_price_filter = None
from search_personalization.agentic_memory.memory_tools import search_product_catalog, rerank_results
gender_filter = attrs.get("gender_affinity")
search_results = search_product_catalog(
query=enriched_query,
category=category_filter,
max_price=knn_price_filter,
gender_affinity=gender_filter,
)
search_duration = time.time() - start
print(f" Done ({search_duration:.1f}s)")
# Extract the actual OpenSearch query from the search results for display
try:
_search_parsed = json.loads(search_results) if isinstance(search_results, str) else search_results
actual_opensearch_query = _search_parsed.get("_opensearch_query")
except (json.JSONDecodeError, TypeError):
actual_opensearch_query = None
# --- Step 3: Rerank + filter aversions (no LLM needed) ---
print(f" [rerank] Reranking + filtering...")
start = time.time()
# Build a rerank query that blends user intent with style/use-context signals.
# The search step already used the enriched query (with profile preferences),
# so reranking surfaces results that best match what the user actually asked for
# plus their functional context.
rerank_parts = [query]
use_ctx = attrs.get("use_context")
preferred_materials = attrs.get("preferred_materials", [])
if use_ctx:
rerank_parts.append(use_ctx)
if preferred_materials:
rerank_parts.append(", ".join(preferred_materials[:2]))
rerank_query = ", ".join(rerank_parts)
reranked_raw = rerank_results(query=rerank_query, documents=search_results, top_n=10)
# Aversions are handled semantically β€” the query agent expresses them as positive
# opposites in the enriched_query (e.g., "flashy" β†’ "subtle understated") so the
# vector search naturally favors non-aversive products. No post-filtering needed.
raw_aversions = attrs.get("aversions", [])
reranked = json.loads(reranked_raw) if isinstance(reranked_raw, str) else reranked_raw
results_list = reranked.get("reranked_results", [])
# Price post-filter: apply after reranking so semantic relevance isn't starved.
# If too few results survive the price filter, keep the full set and let the
# explanation agent inform the user about the price gap.
price_filter_relaxed = False
# Only apply price filtering if the user explicitly mentioned price in their query
import re
_user_mentioned_price = bool(re.search(r'(\$\d|under|below|less than|cheap|budget|affordable|\d+\s*dollars)', query.lower()))
if max_price_filter and results_list and _user_mentioned_price:
price_filtered = [r for r in results_list if (r.get("price") or 0) <= max_price_filter]
if len(price_filtered) >= 3:
results_list = price_filtered
else:
# Not enough results within budget β€” keep full set, flag for explanation
price_filter_relaxed = True
print(f" [price] Only {len(price_filtered)} results under ${max_price_filter:.0f}, showing all with explanation")
# Build final output
final_results = {
"results": [
{"rank": i + 1, "product_id": r.get("id"), "name": r.get("name"), "price": r.get("price"), "score": r.get("rerank_score")}
for i, r in enumerate(results_list[:7])
],
"enriched_query": enriched_query,
"personalization_summary": (
f"Use context: {use_ctx or 'none'}, "
f"materials: {preferred_materials or 'none'}, "
f"filtered aversions: {raw_aversions}"
) if (raw_aversions or use_ctx or preferred_materials) else "No personalization applied",
}
ranking_result = json.dumps(final_results, indent=2, default=str)
ranking_duration = time.time() - start
pipeline_trace["agents"]["ranking"] = {
"duration_ms": int(ranking_duration * 1000),
"output": ranking_result,
}
print(f" Done ({ranking_duration:.1f}s)")
# Combine for working memory
combined_payload = json.dumps({**enrichment, "search_results": results_list}, default=str)
# --- Async: write working memory for observability (fire-and-forget) ---
def _write_working_memory_async():
try:
from search_personalization.agentic_memory.memory_tools import write_working_memory
write_working_memory(persona_id, session_id, combined_payload)
except Exception:
pass
threading.Thread(target=_write_working_memory_async, daemon=True).start()
# --- Async: Results Explanation Agent (runs in parallel, doesn't block) ---
from concurrent.futures import Future
explanation_future: Future = Future()
def _run_explanation_async():
try:
from search_personalization.agentic_memory.agents.results_explanation_agent import (
invoke_results_explanation_agent,
)
explanation = invoke_results_explanation_agent(
query=query,
results=results_list[:5],
price_filter_relaxed=price_filter_relaxed,
original_max_price=max_price_filter if price_filter_relaxed else None,
inferred_attributes=attrs,
)
explanation_future.set_result(explanation)
except Exception:
explanation_future.set_result(None)
explanation_thread = threading.Thread(target=_run_explanation_async, daemon=True)
explanation_thread.start()
pipeline_trace["total_duration_ms"] = int((time.time() - pipeline_start) * 1000)
pipeline_trace["opensearch_query"] = actual_opensearch_query
print(f"\n Total pipeline: {pipeline_trace['total_duration_ms']}ms")
# Price relaxation explanation is synchronous (no LLM needed, just a string).
# LLM-based explanations run fully async β€” the future is returned in the trace
# so the caller can resolve it without blocking the pipeline.
if price_filter_relaxed and max_price_filter:
pipeline_trace["agents"]["explanation"] = {
"output": f"Not enough options under ${max_price_filter:.0f} β€” showing best matches across all price ranges."
}
else:
pipeline_trace["agents"]["explanation"] = {"output": None, "_future": explanation_future}
# --- Post-pipeline: session memory write (async fire-and-forget) ---
def _write_session_memory_async():
try:
top_results = ", ".join(
r.get("name", "") for r in results_list[:3]
)
session_content = (
f"User searched: \"{query}\". "
f"Category: {attrs.get('category') or 'general'}. "
f"Top results: {top_results}."
)
write_session_memory(persona_id, session_id, session_content)
except Exception:
pass
threading.Thread(target=_write_session_memory_async, daemon=True).start()
# History write can remain async (not needed for multi-turn)
def _write_history_async():
try:
write_history(persona_id, session_id,
f"Session {session_id} | Query: \"{query}\" | "
f"Persona: {persona_name} | Timestamp: {pipeline_trace['timestamp']}")
except Exception:
pass
threading.Thread(target=_write_history_async, daemon=True).start()
return pipeline_trace
def run_comparison_demo(query: str):
"""Run the same query through both personas side by side."""
print("\n" + "=" * 70)
print(" AGENTIC MEMORY DEMO β€” PERSONALIZED SEARCH")
print("=" * 70)
print(f"\n Query: \"{query}\"")
print(f" Personas: Sarah (classic/professional style) vs Alex (casual/streetwear style)")
print(f" Architecture: Cache β†’ Query Understanding β†’ Ranking (3 agents)")
demo_session_prefix = f"demo-{uuid.uuid4().hex[:6]}"
sarah_trace = run_pipeline_for_persona(
query=query, persona_name="sarah", session_id=f"{demo_session_prefix}-sarah",
)
alex_trace = run_pipeline_for_persona(
query=query, persona_name="alex", session_id=f"{demo_session_prefix}-alex",
)
print("\n" + "=" * 70)
print(" COMPARISON SUMMARY")
print("=" * 70)
print(f"\n Same query: \"{query}\"")
print(f"\n Sarah's results:")
print(f" {_extract_summary(sarah_trace['agents'].get('ranking', {}).get('output', ''))}")
print(f"\n Alex's results:")
print(f" {_extract_summary(alex_trace['agents'].get('ranking', {}).get('output', ''))}")
print(f"\n Key insight: Same query β€” memory + decoupled ranking is the difference.")
print("=" * 70)
return {"sarah": sarah_trace, "alex": alex_trace}
def _extract_summary(output: str) -> str:
if not output:
return "(no output)"
clean = output.replace("\n", " ").strip()
return clean[:300] + "..." if len(clean) > 300 else clean
def run_interactive():
"""Interactive mode."""
print("\n" + "=" * 70)
print(" AGENTIC MEMORY DEMO β€” INTERACTIVE MODE")
print("=" * 70)
print("\n Type a query to run through both personas")
print(" 'sarah: <query>' or 'alex: <query>' for one persona")
print(" 'quit' to stop\n")
session_ids = {
"sarah": f"interactive-{uuid.uuid4().hex[:6]}-sarah",
"alex": f"interactive-{uuid.uuid4().hex[:6]}-alex",
}
while True:
try:
user_input = input("Query: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
if not user_input or user_input.lower() in ("quit", "exit"):
break
if user_input.lower().startswith("sarah:"):
run_pipeline_for_persona(user_input[6:].strip(), "sarah", session_ids["sarah"])
elif user_input.lower().startswith("alex:"):
run_pipeline_for_persona(user_input[5:].strip(), "alex", session_ids["alex"])
else:
run_comparison_demo(user_input)
print()
def main():
import argparse
parser = argparse.ArgumentParser(description="Run the agentic memory demo pipeline")
parser.add_argument("--query", type=str, default=DEFAULT_QUERY)
parser.add_argument("--interactive", action="store_true")
parser.add_argument("--persona", type=str, choices=["sarah", "alex"])
args = parser.parse_args()
# Refresh schema cache and start daily scheduler (7am)
from search_personalization.agentic_memory.schema_cache import refresh_cache, start_scheduler
try:
print(" Refreshing schema cache...")
refresh_cache()
except Exception as e:
print(f" Schema cache refresh failed (will use defaults): {e}")
start_scheduler(hour=7, minute=0)
if args.interactive:
run_interactive()
elif args.persona:
run_pipeline_for_persona(query=args.query, persona_name=args.persona)
else:
run_comparison_demo(args.query)
if __name__ == "__main__":
main()