""" Router Agent — Entry point of the graph. Classifies the user's intent and dispatches the request to the appropriate sub-agent. How it works: 1. Receives the user's query 2. IMAGE CHECK: detects an image path in the message (for visual recommending) - If present: extracts the path → direct agent_visual (image = visual intent) 3. RECOMMENDATION CHECK: detects the RECOMMEND:ASIN pattern (click on product card) - If present: direct route to agent_recommender with the ASIN as anchor 4. GREETINGS CHECK: pattern matching against a list of common greetings (no FAISS, no LLM) 5. TRANSLATION + CONTEXT RESOLUTION (1 LLM call): - First turn: translates the query to English (IT→EN) - Subsequent turns: translates + rewrites as a self-contained sentence with context - Output: complete English query saved in `resolved_query` - Used both for the kNN (English FAISS index) and for Search (English catalog) 6. INTENT CLASSIFICATION: 2-level distance-weighted kNN voting on the translated query a) First level — kNN on the 5 original dataset classes (product, sales, support, account, bugs): - Searches the top-k (k=15) examples in the few-shot FAISS index - Each example votes for its own original class, with weight = 1 / L2 distance - Keeping the 5 classes avoids artificial imbalance (merging support+account+bugs into 'default' = 60% of the dataset) b) Second level — aggregation into macro-groups: - Sums the weights of the "product" (product + sales) and "support" (support + account + bugs) classes - If support > product -> agent_default - If product > support -> agent_search (always, the recommender activates only on click) Classification is entirely NLP (embedding + L2 distance + weighted kNN), without any LLM call. The only LLM call is for translation/context resolution. Possible destinations: - agent_search -> any product query ("cerco scarpe rosse", "consigliami un regalo") - agent_recommender -> click on product card (RECOMMEND:ASIN from the frontend) - agent_visual -> image (file path in the message) - agent_greeting -> greetings ("ciao", "grazie") - agent_default -> out of scope ("come faccio un reso?") Uses the factory pattern: create_router_node(llm, router_retriever) -> node function. """ import os import re from collections import defaultdict from langchain_core.messages import SystemMessage, HumanMessage from utils.config import ROUTER_SEARCH_K # --- Greeting patterns (pre-FAISS check, without LLM) --- # Split into 3 groups to generate contextualized LLM responses GREETING_HELLO = { "ciao", "buongiorno", "buonasera", "salve", "hey", "hello", "hi", "hola", "buonanotte", } GREETING_THANKS = { "grazie", "grazie mille", "thanks", "thank you", } GREETING_GOODBYE = { "arrivederci", "addio", "a presto", "ci vediamo", "bye", "good morning", "good evening", } GREETING_ALL = GREETING_HELLO | GREETING_THANKS | GREETING_GOODBYE # Pattern to detect a recommendation request from the web interface (click on product card) _RECOMMEND_PATTERN = re.compile(r'^RECOMMEND:([A-Z0-9]+)$') # --- Macro-groups for weight aggregation --- # Classes that indicate interest in products (search or recommendation) PRODUCT_CLASSES = {"product", "sales"} # Out-of-scope classes (support, account, bug) DEFAULT_CLASSES = {"support", "account", "bugs"} # Image extensions supported for visual recommending IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} # Pattern to detect an image file path in the message # Captures absolute paths (/path/to/image.jpg) and relative ones (./image.jpg, image.jpg) # also with spaces if enclosed in quotes _IMAGE_PATH_PATTERN = re.compile( r'(?:"([^"]+\.(?:jpg|jpeg|png|gif|webp|bmp|tiff))"' # quoted path r'|(/\S+\.(?:jpg|jpeg|png|gif|webp|bmp|tiff))' # absolute path without quotes r'|(\./\S+\.(?:jpg|jpeg|png|gif|webp|bmp|tiff)))', # relative path ./ re.IGNORECASE ) def _extract_image_path(message): """Extracts an image path from the user message. Searches for file path patterns with an image extension. Verifies that the file actually exists on disk. Returns (image_path, text_without_path) or (None, original_message). """ match = _IMAGE_PATH_PATTERN.search(message) if not match: return None, message # Take the first non-None group among the regex alternatives image_path = match.group(1) or match.group(2) or match.group(3) if not os.path.isfile(image_path): return None, message # Remove the path from the message to get the plain text text_part = message[:match.start()] + message[match.end():] text_part = text_part.strip() return image_path, text_part # --- Router node factory --- def create_router_node(llm, router_retriever): """Creates the Router node with injected dependencies. Args: llm: ChatOpenAI instance for context resolution (multi-turn) router_retriever: FAISS store with labeled few-shot examples (5 original classes) """ def router_node(state): print(f"\n--- [ROUTER] Semantic analysis in progress... ---") last_msg = state["messages"][-1].content # --- PHASE 0: Image check (visual recommending) --- # Detects whether the user included an image path in the message. # If present, the intent is always visual: the user wants to find # products similar to the image. The accompanying text (if any) # is saved in resolved_query for context, but does not change routing. image_path, text_part = _extract_image_path(last_msg) if image_path: print(f" Image detected: {image_path}") if text_part: print(f" Remaining text: '{text_part}'") print(f" Decision: AGENT_VISUAL (image present)") return {"decision": "agent_visual", "image_path": image_path, "resolved_query": text_part, "products": [], "anchor": None, "recommend_asin": ""} # --- PHASE 0b: Recommendation check (click on product card) --- # The web interface sends "RECOMMEND:ASIN" when the user clicks on a product. # Direct route to the Recommender with the ASIN as anchor. rec_match = _RECOMMEND_PATTERN.match(last_msg.strip()) if rec_match: rec_asin = rec_match.group(1) print(f" Recommendation requested for ASIN: {rec_asin}") print(f" Decision: AGENT_RECOMMENDER (click on product)") return {"decision": "agent_recommender", "recommend_asin": rec_asin, "resolved_query": "", "products": [], "anchor": None} # --- PHASE 1: Greetings check (pattern matching, 3 groups) --- msg_lower = last_msg.strip().lower() if msg_lower in GREETING_ALL: if msg_lower in GREETING_HELLO: greeting_type = "greeting" elif msg_lower in GREETING_THANKS: greeting_type = "thanks" else: greeting_type = "goodbye" print(f" User Query: '{last_msg}'") print(f" Decision: AGENT_GREETING (pattern match, type: {greeting_type})") return {"decision": "agent_greeting", "resolved_query": last_msg, "greeting_type": greeting_type, "products": [], "anchor": None, "recommend_asin": ""} # --- PHASE 2: Translation (any language → English) + Context resolution (1 LLM call) --- # The router FAISS index and the product catalog are in English. # The LLM translates the user's query (any language) to English AND, if multi-turn, # resolves the conversational context in the same call. # Output: complete English query used for kNN + Search/Recommender. human_count = sum(1 for m in state["messages"] if isinstance(m, HumanMessage)) is_multi_turn = human_count > 1 try: if is_multi_turn: # Multi-turn: translation + context resolution # Extract only the user messages to give clear context to the LLM user_messages = [m.content for m in state["messages"] if isinstance(m, HumanMessage)] context_str = " -> ".join(f'"{m}"' for m in user_messages) resolve_prompt = f"""Rewrite the user's LAST message as a SINGLE complete English sentence using conversation context. Conversation: {context_str} Last message: "{last_msg}" KEY RULE: If the last message references something from before (pronouns like "le/li/lo", "them", "those", or lacks a noun/product type), you MUST carry over the product type AND all previously specified attributes (color, size, material, style, length, gender, age group, occasion, etc.) from the earlier messages. Combine them with the new attribute(s) in the last message. Examples (attribute addition - carry ALL previous attributes): - "voglio delle sneakers" -> "le vorrei rosse" => I want red sneakers - "cerco scarpe da donna" -> "le vorrei rosse" => I'm looking for red women's shoes - "cerco pantaloni lunghi blu" -> "mi servono da uomo" => I need men's blue long pants - "voglio una borsa in pelle" -> "nera" => I want a black leather bag - "cerco scarpe rosse" -> "taglia 39" => I'm looking for red shoes in size 39 - "voglio un vestito elegante" -> "per una cerimonia" => I want an elegant dress for a ceremony - "cerco jeans da uomo blu" -> "taglia 32" => I'm looking for men's blue jeans in size 32 - "consigliami un regalo" -> "per mia sorella" => recommend me a gift for my sister Examples (new product replaces previous one - DO NOT carry over): - "voglio delle sneakers" -> "cerco dei pantaloni verdi" => I'm looking for green pants - "consigliami scarpe rosse" -> "voglio una borsa nera" => I want a black bag IMPORTANT: "le vorrei" = "I would like THEM" — "them" refers to the product from before. ALWAYS replace "them" with the actual product name. If the last message already contains a NEW product type (e.g. "pantaloni", "borsa", "vestito"), translate only the last message (replacing the old product, not extending it). If the last message is about support/logistics (orders, returns, shipping, account, tracking, delivery, refund), translate it literally WITHOUT adding product names from context. Example: "quando mi arriva l'ordine?" after talking about dresses => "When will my order arrive?" (NOT "When will my dress order arrive?"). Output ONLY the English sentence, nothing else. English:""" resolved_msg = llm.invoke([SystemMessage(content=resolve_prompt)]) else: # First turn: translation only resolve_prompt = f"""Translate the following text to English. If it's already in English, return it unchanged. Preserve the intent: "cerco"/"voglio" -> "I'm looking for", "consigliami" -> "recommend me". Output ONLY the English sentence, nothing else. Text: "{last_msg}" English:""" resolved_msg = llm.invoke([SystemMessage(content=resolve_prompt)]) resolved_query = resolved_msg.content.strip().strip('"').strip("'") if is_multi_turn: print(f" Translation + context: '{last_msg}' -> '{resolved_query}'") else: print(f" Translation: '{last_msg}' -> '{resolved_query}'") except Exception: resolved_query = last_msg # --- PHASE 3: Intent classification (distance-weighted kNN, 2 levels) --- # Uses the resolved_query (not the raw message) for classification. # This way "di rosse?" becomes "scarpe rosse" and the kNN classifies correctly. query_for_knn = resolved_query decision = "agent_default" # fallback if router_retriever: try: # similarity_search_with_score returns (Document, L2_distance) results = router_retriever.similarity_search_with_score(query_for_knn, k=ROUTER_SEARCH_K) # Level 1: kNN on the 5 original classes # weight = 1 / (distance + epsilon), epsilon avoids division by zero epsilon = 1e-6 weighted_votes = defaultdict(float) for doc, distance in results: intent_class = doc.metadata["intent_class"] weight = 1.0 / (distance + epsilon) weighted_votes[intent_class] += weight # Level 2: aggregation into macro-groups # Sum the weights of the "product" and "support" classes product_weight = sum(weighted_votes.get(c, 0) for c in PRODUCT_CLASSES) default_weight = sum(weighted_votes.get(c, 0) for c in DEFAULT_CLASSES) if product_weight > default_weight: # Product query → always search (the recommender activates from the card click) decision = "agent_search" else: # Support/account/bug query → out of scope decision = "agent_default" # Detailed log print(f" Query for kNN: '{query_for_knn}'") print(f" FAISS top-{len(results)} examples (distance-weighted kNN):") for doc, dist in results: hint = " [REC]" if doc.metadata.get("recommender_hint") else "" print(f" - [{dist:.3f}] '{doc.page_content[:50]}' -> {doc.metadata['intent_class']}{hint}") print(f" Weights per original class:") max_len = max(len(cls) for cls in weighted_votes) for cls, weight in sorted(weighted_votes.items(), key=lambda x: -x[1]): print(f" {cls:<{max_len}}: {weight:.3f}") print(f" Macro-groups: PRODUCT={product_weight:.3f} vs SUPPORT={default_weight:.3f}") print(f" Decision: {decision.upper()}") except Exception as e: print(f" FAISS error ({e}), falling back to Default.") return {"decision": "agent_default", "resolved_query": resolved_query, "products": [], "anchor": None, "recommend_asin": ""} return {"decision": decision, "resolved_query": resolved_query, "products": [], "anchor": None, "recommend_asin": ""} return router_node