""" Tool 3: search_episode_data — BM25 search over episode request/response history. Uses rank_bm25 for keyword matching over all indexed data from curl_exec calls. Falls back to simple keyword matching when rank_bm25 is not available. """ import re import math from collections import Counter # --------------------------------------------------------------------------- # Simple BM25 implementation (no external dependencies) # --------------------------------------------------------------------------- def _tokenize(text: str) -> list[str]: """Tokenize text into words.""" return re.findall(r'[a-zA-Z0-9_./{}:]+', text.lower()) class SimpleBM25: """ Minimal BM25 implementation for episode data search. No external dependencies — pure Python. """ def __init__(self, k1: float = 1.5, b: float = 0.75): self.k1 = k1 self.b = b self.corpus: list[str] = [] self.tokenized: list[list[str]] = [] self.doc_len: list[int] = [] self.avgdl: float = 0 self.idf: dict[str, float] = {} self.n_docs: int = 0 def index(self, documents: list[str]): """Build BM25 index from documents.""" self.corpus = documents self.tokenized = [_tokenize(d) for d in documents] self.doc_len = [len(t) for t in self.tokenized] self.n_docs = len(documents) self.avgdl = sum(self.doc_len) / max(self.n_docs, 1) # Compute IDF df = Counter() for tokens in self.tokenized: for t in set(tokens): df[t] += 1 self.idf = {} for term, freq in df.items(): # Standard BM25 IDF self.idf[term] = math.log( (self.n_docs - freq + 0.5) / (freq + 0.5) + 1 ) def add_documents(self, new_docs: list[str]): """Incrementally add documents and rebuild index.""" self.corpus.extend(new_docs) new_tokenized = [_tokenize(d) for d in new_docs] self.tokenized.extend(new_tokenized) self.doc_len.extend(len(t) for t in new_tokenized) self.n_docs = len(self.corpus) self.avgdl = sum(self.doc_len) / max(self.n_docs, 1) # Recompute IDF df = Counter() for tokens in self.tokenized: for t in set(tokens): df[t] += 1 self.idf = { term: math.log((self.n_docs - freq + 0.5) / (freq + 0.5) + 1) for term, freq in df.items() } def search(self, query: str, top_k: int = 5) -> list[tuple[int, float, str]]: """ Search for query in corpus. Returns: list of (doc_index, score, document) tuples, sorted by score descending. """ query_tokens = _tokenize(query) scores = [] for i, doc_tokens in enumerate(self.tokenized): score = 0.0 tf = Counter(doc_tokens) dl = self.doc_len[i] for qt in query_tokens: if qt not in self.idf: continue term_freq = tf.get(qt, 0) idf = self.idf[qt] numerator = term_freq * (self.k1 + 1) denominator = term_freq + self.k1 * (1 - self.b + self.b * dl / self.avgdl) score += idf * numerator / denominator scores.append((i, score, self.corpus[i])) scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_k] # --------------------------------------------------------------------------- # Episode data store # --------------------------------------------------------------------------- class EpisodeDataStore: """ Per-episode BM25 index over all request/response bodies. Initialized empty at episode start, grows with each curl_exec call. Discarded at episode end. """ def __init__(self): self.bm25 = SimpleBM25() self.bm25.index([]) # Initialize empty def add_documents(self, docs: list[str]): """Add new documents (from a curl_exec call) to the index.""" self.bm25.add_documents(docs) def search(self, query: str, top_k: int = 5) -> list[str]: """ Search episode data by keyword query. Returns top-k matching documents as strings. """ if self.bm25.n_docs == 0: return [] results = self.bm25.search(query, top_k) return [doc for _, score, doc in results if score > 0] def search_with_scores(self, query: str, top_k: int = 5) -> list[tuple[float, str]]: """Search with scores for debugging.""" results = self.bm25.search(query, top_k) return [(score, doc) for _, score, doc in results] @property def doc_count(self) -> int: return self.bm25.n_docs def reset(self): """Clear all data (called at episode end).""" self.bm25 = SimpleBM25() self.bm25.index([]) # --------------------------------------------------------------------------- # Test # --------------------------------------------------------------------------- if __name__ == "__main__": print("=" * 70) print("TEST: search_episode_data with simulated episode") print("=" * 70) from tool_curl_exec import build_index_documents import json store = EpisodeDataStore() # Simulate episode: 4 curl_exec calls building up the index # Step 1: GET /categories docs = build_index_documents( step=1, method="GET", path="/rest/V1/categories", request_body=None, response_body=json.dumps({ "id": 1, "name": "Root", "children_data": [ {"id": 2, "name": "Default Category"}, {"id": 3, "name": "Beauty & Personal Care"} ] }), status_code=200 ) store.add_documents(docs) print(f"\nStep 1: indexed {len(docs)} docs from GET /categories") # Step 2: GET /products (with array items) docs = build_index_documents( step=2, method="GET", path="/rest/V1/products", request_body=None, response_body=json.dumps({ "items": [ {"sku": "MH01", "name": "Radiant Tee", "price": 22.0}, {"sku": "MH02", "name": "Breathe-Easy Tank", "price": 34.0}, {"sku": "MH03", "name": "Stellar Solar Jacket", "price": 75.0}, {"sku": "MH04", "name": "Argus All-Weather Tank", "price": 22.0}, {"sku": "WS01", "name": "Iris Workout Top", "price": 29.0}, ], "total_count": 5 }), status_code=200 ) store.add_documents(docs) print(f"Step 2: indexed {len(docs)} docs from GET /products (5 items)") # Step 3: POST /guest-carts docs = build_index_documents( step=3, method="POST", path="/rest/V1/guest-carts", request_body=None, response_body='"cart-mock-abc123"', status_code=200 ) store.add_documents(docs) print(f"Step 3: indexed {len(docs)} docs from POST /guest-carts") # Step 4: POST /guest-carts/.../items docs = build_index_documents( step=4, method="POST", path="/rest/V1/guest-carts/cart-mock-abc123/items", request_body={"cartItem": {"sku": "MH01", "qty": 1, "quote_id": "cart-mock-abc123"}}, response_body=json.dumps({ "item_id": 5, "sku": "MH01", "qty": 1, "name": "Radiant Tee", "price": 22.0 }), status_code=200 ) store.add_documents(docs) print(f"Step 4: indexed {len(docs)} docs from POST /guest-carts/.../items") print(f"\nTotal documents in episode index: {store.doc_count}") # Test searches print(f"\n--- Search Tests ---\n") queries = [ ("Radiant Tee sku", "Should find MH01 product"), ("Stellar Solar Jacket price", "Should find MH03 at $75"), ("cart-mock-abc123", "Should find cart ID"), ("Beauty Personal Care", "Should find category"), ("item_id 5", "Should find add-to-cart result"), ("Iris Workout Top", "Should find WS01 product"), ] all_passed = True for query, description in queries: results = store.search_with_scores(query, top_k=3) print(f"Query: \"{query}\" ({description})") if results: for score, doc in results: print(f" [{score:.3f}] {doc[:120]}...") else: print(f" [NO RESULTS]") all_passed = False print() # Verify specific lookups print("--- Specific Value Lookups ---\n") # Can we find the product SKU from a name? results = store.search("Radiant Tee", top_k=1) found_sku = "MH01" in results[0] if results else False print(f" Find 'Radiant Tee' SKU: {'PASS' if found_sku else 'FAIL'} ({'MH01' if found_sku else 'not found'})") # Can we find the cart ID? results = store.search("cart guest-carts", top_k=3) found_cart = any("cart-mock-abc123" in r for r in results) print(f" Find cart ID: {'PASS' if found_cart else 'FAIL'}") # Can we find from which step data came? results = store.search("Radiant Tee", top_k=1) found_step = "step:2" in results[0] if results else False print(f" Step annotation present: {'PASS' if found_step else 'FAIL'}") # Test reset store.reset() assert store.doc_count == 0 print(f"\n Episode reset: doc_count = {store.doc_count} [PASS]") print("\n[PASS] search_episode_data tool tests completed successfully")