Commit 664426683046f36260ee8703f5631c85e40c0cf2

Authored by tangwang
1 parent 9ad88986

feat: 搜索结果引用与并行搜索、两轮上限

## 搜索结果管理与人机回复引用
- 新增 app/search_registry.py:SearchResultRegistry + SearchResult/ProductItem 数据结构,按 session 存储每次搜索的 query、质量评估与商品列表。
- 搜索工具改为工厂 make_search_products_tool(session_id, registry):每次搜索后由 LLM 对 top20 打标(完美匹配/部分匹配/不相关),产出整体 verdict(优质/一般/较差),仅将「完美+部分」写入 registry 并返回摘要 + [SEARCH_REF:ref_id];不再向 Agent 返回完整商品列表。
- 废除 extract_products_from_response:最终回复中通过内联 [SEARCH_REF:xxx] 引用「搜索结果块」,UI 用 SEARCH_REF_PATTERN 解析后从 registry 取对应 SearchResult 渲染 query 标题 + 商品卡片,避免 LLM 复述商品列表,节省 token 并减少错误。

## 系统提示与行为约束
- 系统提示词通用化(不绑定时尚品类),明确四步:理解意图 → 规划 2~4 个 query → 执行搜索并评估 → 撰写回复。
- 要求同一条回复中并行发起 2~4 次 search_products(不同 query),利用 LangGraph ToolNode 的并行执行缩短等待;禁止串行「搜一个看一个再搜下一个」。
- 轮次上限:最多两轮搜索(两轮 = 两次「Agent 发 tool_calls → Tools 执行 → 返回」);若已有优质/一般结果则直接写回复,仅当全部较差时允许第二轮(最多再 1~2 个 query)。图逻辑增加 n_tool_rounds 状态与 agent_final 节点,两轮后强制进入「仅回复、不调工具」的 agent_final,避免无限重搜。

## 前端与工具导出
- app.py:render_message_with_refs(content, session_id) 按 [SEARCH_REF:xxx] 切分并渲染;render_search_result_block 展示 query + 质量 + 商品卡片;display_product_card_from_item 支持 image_url/本地图/占位;Clear Chat 时 clear_session(registry)。
- app/tools/__init__.py:改为导出 make_search_products_tool、web_search,不再导出已移除的 search_products 顶层名。

Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -13,6 +13,11 @@ import streamlit as st @@ -13,6 +13,11 @@ import streamlit as st
13 from PIL import Image, ImageOps 13 from PIL import Image, ImageOps
14 14
15 from app.agents.shopping_agent import ShoppingAgent 15 from app.agents.shopping_agent import ShoppingAgent
  16 +from app.search_registry import ProductItem, SearchResult, global_registry
  17 +
  18 +# Matches [SEARCH_REF:sr_xxxxxxxx] tokens embedded in AI responses.
  19 +# Case-insensitive, optional spaces around the id.
  20 +SEARCH_REF_PATTERN = re.compile(r"\[SEARCH_REF:\s*([a-zA-Z0-9_]+)\s*\]", re.IGNORECASE)
16 21
17 # Configure logging 22 # Configure logging
18 logging.basicConfig( 23 logging.basicConfig(
@@ -270,124 +275,118 @@ def save_uploaded_image(uploaded_file) -&gt; Optional[str]: @@ -270,124 +275,118 @@ def save_uploaded_image(uploaded_file) -&gt; Optional[str]:
270 return None 275 return None
271 276
272 277
273 -def extract_products_from_response(response: str) -> list:  
274 - """Extract product information from agent response 278 +def _load_product_image(product: ProductItem) -> Optional[Image.Image]:
  279 + """Try to load a product image: image_url from API → local data/images → None."""
  280 + if product.image_url:
  281 + try:
  282 + import requests
  283 + resp = requests.get(product.image_url, timeout=10)
  284 + if resp.status_code == 200:
  285 + import io
  286 + return Image.open(io.BytesIO(resp.content))
  287 + except Exception as e:
  288 + logger.debug(f"Remote image fetch failed for {product.spu_id}: {e}")
  289 +
  290 + local = Path(f"data/images/{product.spu_id}.jpg")
  291 + if local.exists():
  292 + try:
  293 + return Image.open(local)
  294 + except Exception as e:
  295 + logger.debug(f"Local image load failed {local}: {e}")
  296 + return None
  297 +
  298 +
  299 +def display_product_card_from_item(product: ProductItem) -> None:
  300 + """Render a single product card from a ProductItem (registry entry)."""
  301 + img = _load_product_image(product)
  302 +
  303 + if img:
  304 + target = (220, 220)
  305 + try:
  306 + img = ImageOps.fit(img, target, method=Image.Resampling.LANCZOS)
  307 + except AttributeError:
  308 + img = ImageOps.fit(img, target, method=Image.LANCZOS)
  309 + st.image(img, use_container_width=True)
  310 + else:
  311 + st.markdown(
  312 + '<div style="height:120px;background:#f5f5f5;border-radius:6px;'
  313 + 'display:flex;align-items:center;justify-content:center;'
  314 + 'color:#bbb;font-size:2rem;">🛍️</div>',
  315 + unsafe_allow_html=True,
  316 + )
  317 +
  318 + title = product.title or "未知商品"
  319 + st.markdown(f"**{title[:40]}**" + ("…" if len(title) > 40 else ""))
  320 +
  321 + if product.price is not None:
  322 + st.caption(f"¥{product.price:.2f}")
  323 +
  324 + label_style = "⭐" if product.match_label == "完美匹配" else "✦"
  325 + st.caption(f"{label_style} {product.match_label}")
275 326
276 - Returns list of dicts with product info 327 +
  328 +def render_search_result_block(result: SearchResult) -> None:
277 """ 329 """
278 - products = []  
279 -  
280 - # Pattern to match product blocks in the response  
281 - # Looking for ID, name, and other details  
282 - lines = response.split("\n")  
283 - current_product = {}  
284 -  
285 - for line in lines:  
286 - line = line.strip()  
287 -  
288 - # Match product number (e.g., "1. Product Name" or "**1. Product Name**")  
289 - if re.match(r"^\*?\*?\d+\.\s+", line):  
290 - if current_product:  
291 - products.append(current_product)  
292 - current_product = {}  
293 - # Extract product name  
294 - name = re.sub(r"^\*?\*?\d+\.\s+", "", line)  
295 - name = name.replace("**", "").strip()  
296 - current_product["name"] = name  
297 -  
298 - # Match ID  
299 - elif "ID:" in line or "id:" in line:  
300 - id_match = re.search(r"(?:ID|id):\s*(\d+)", line)  
301 - if id_match:  
302 - current_product["id"] = id_match.group(1)  
303 -  
304 - # Match Category  
305 - elif "Category:" in line:  
306 - cat_match = re.search(r"Category:\s*(.+?)(?:\n|$)", line)  
307 - if cat_match:  
308 - current_product["category"] = cat_match.group(1).strip()  
309 -  
310 - # Match Color  
311 - elif "Color:" in line:  
312 - color_match = re.search(r"Color:\s*(\w+)", line)  
313 - if color_match:  
314 - current_product["color"] = color_match.group(1)  
315 -  
316 - # Match Gender  
317 - elif "Gender:" in line:  
318 - gender_match = re.search(r"Gender:\s*(\w+)", line)  
319 - if gender_match:  
320 - current_product["gender"] = gender_match.group(1)  
321 -  
322 - # Match Season  
323 - elif "Season:" in line:  
324 - season_match = re.search(r"Season:\s*(\w+)", line)  
325 - if season_match:  
326 - current_product["season"] = season_match.group(1)  
327 -  
328 - # Match Usage  
329 - elif "Usage:" in line:  
330 - usage_match = re.search(r"Usage:\s*(\w+)", line)  
331 - if usage_match:  
332 - current_product["usage"] = usage_match.group(1)  
333 -  
334 - # Match Similarity/Relevance score  
335 - elif "Similarity:" in line or "Relevance:" in line:  
336 - score_match = re.search(r"(?:Similarity|Relevance):\s*([\d.]+)%", line)  
337 - if score_match:  
338 - current_product["score"] = score_match.group(1)  
339 -  
340 - # Add last product  
341 - if current_product:  
342 - products.append(current_product)  
343 -  
344 - return products  
345 -  
346 -  
347 -def display_product_card(product: dict):  
348 - """Display a product card with image and name"""  
349 - product_id = product.get("id", "")  
350 - name = product.get("name", "Unknown Product")  
351 -  
352 - # Debug: log what we got  
353 - logger.info(f"Displaying product: ID={product_id}, Name={name}")  
354 -  
355 - # Try to load image from data/images directory  
356 - if product_id:  
357 - image_path = Path(f"data/images/{product_id}.jpg")  
358 -  
359 - if image_path.exists():  
360 - try:  
361 - img = Image.open(image_path)  
362 - # Fixed size for all images  
363 - target_size = (200, 200)  
364 - try:  
365 - # Try new Pillow API  
366 - img_processed = ImageOps.fit(  
367 - img, target_size, method=Image.Resampling.LANCZOS  
368 - )  
369 - except AttributeError:  
370 - # Fallback for older Pillow versions  
371 - img_processed = ImageOps.fit(  
372 - img, target_size, method=Image.LANCZOS  
373 - )  
374 -  
375 - # Display image with fixed width  
376 - st.image(img_processed, use_container_width=False, width=200)  
377 - st.markdown(f"**{name}**")  
378 - st.caption(f"ID: {product_id}")  
379 - return  
380 - except Exception as e:  
381 - logger.warning(f"Failed to load image {image_path}: {e}") 330 + Render a full search result block in place of a [SEARCH_REF:xxx] token.
  331 +
  332 + Shows:
  333 + - A styled header with query text + quality verdict + match counts
  334 + - A grid of product cards (perfect matches first, then partial; max 6)
  335 + """
  336 + verdict_icon = {"优质": "✅", "一般": "〰️", "较差": "⚠️"}.get(result.quality_verdict, "🔍")
  337 + header_html = (
  338 + f'<div style="border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;'
  339 + f'margin:8px 0 4px 0;background:#fafafa;">'
  340 + f'<span style="font-size:0.8rem;color:#555;">'
  341 + f'🔍 <b>{result.query}</b>'
  342 + f'&nbsp;&nbsp;{verdict_icon} {result.quality_verdict}'
  343 + f'&nbsp;·&nbsp;完美匹配&nbsp;{result.perfect_count}&nbsp;件'
  344 + f'&nbsp;·&nbsp;相关&nbsp;{result.partial_count}&nbsp;件'
  345 + f'</span></div>'
  346 + )
  347 + st.markdown(header_html, unsafe_allow_html=True)
  348 +
  349 + # Perfect matches first, fall back to partials if none
  350 + perfect = [p for p in result.products if p.match_label == "完美匹配"]
  351 + partial = [p for p in result.products if p.match_label == "部分匹配"]
  352 + to_show = (perfect + partial)[:6] if perfect else partial[:6]
  353 +
  354 + if not to_show:
  355 + st.caption("(本次搜索未找到可展示的商品)")
  356 + return
  357 +
  358 + cols = st.columns(min(len(to_show), 3))
  359 + for i, product in enumerate(to_show):
  360 + with cols[i % 3]:
  361 + display_product_card_from_item(product)
  362 +
  363 +
  364 +def render_message_with_refs(content: str, session_id: str) -> None:
  365 + """
  366 + Render an assistant message that may contain [SEARCH_REF:xxx] tokens.
  367 +
  368 + Text segments are rendered as markdown.
  369 + [SEARCH_REF:xxx] tokens are replaced with full product card blocks
  370 + loaded from the global registry.
  371 + """
  372 + # re.split with a capture group alternates: [text, ref_id, text, ref_id, ...]
  373 + parts = SEARCH_REF_PATTERN.split(content)
  374 +
  375 + for i, segment in enumerate(parts):
  376 + if i % 2 == 0:
  377 + # Text segment
  378 + text = segment.strip()
  379 + if text:
  380 + st.markdown(text)
382 else: 381 else:
383 - logger.warning(f"Image not found: {image_path}")  
384 -  
385 - # Fallback: no image  
386 - st.markdown(f"**📷 {name}**")  
387 - if product_id:  
388 - st.caption(f"ID: {product_id}")  
389 - else:  
390 - st.caption("ID not available") 382 + # ref_id segment
  383 + ref_id = segment.strip()
  384 + result = global_registry.get(session_id, ref_id)
  385 + if result:
  386 + render_search_result_block(result)
  387 + else:
  388 + # ref not found (e.g. old session after restart)
  389 + st.caption(f"[搜索结果 {ref_id} 不可用]")
391 390
392 391
393 def display_message(message: dict): 392 def display_message(message: dict):
@@ -412,13 +411,13 @@ def display_message(message: dict): @@ -412,13 +411,13 @@ def display_message(message: dict):
412 st.markdown("</div>", unsafe_allow_html=True) 411 st.markdown("</div>", unsafe_allow_html=True)
413 412
414 else: # assistant 413 else: # assistant
415 - # Display tool calls horizontally - only tool names 414 + # Tool call breadcrumb
416 if tool_calls: 415 if tool_calls:
417 tool_names = [tc["name"] for tc in tool_calls] 416 tool_names = [tc["name"] for tc in tool_calls]
418 st.caption(" → ".join(tool_names)) 417 st.caption(" → ".join(tool_names))
419 st.markdown("") 418 st.markdown("")
420 419
421 - # Optional: detailed debug panel (reasoning + tool details) 420 + # Debug panel
422 if debug_steps and st.session_state.get("show_debug"): 421 if debug_steps and st.session_state.get("show_debug"):
423 with st.expander("思考 & 工具调用详细过程", expanded=False): 422 with st.expander("思考 & 工具调用详细过程", expanded=False):
424 for idx, step in enumerate(debug_steps, 1): 423 for idx, step in enumerate(debug_steps, 1):
@@ -430,9 +429,7 @@ def display_message(message: dict): @@ -430,9 +429,7 @@ def display_message(message: dict):
430 if msgs: 429 if msgs:
431 st.markdown("**Agent Messages**") 430 st.markdown("**Agent Messages**")
432 for m in msgs: 431 for m in msgs:
433 - role = m.get("type", "assistant")  
434 - content = m.get("content", "")  
435 - st.markdown(f"- `{role}`: {content}") 432 + st.markdown(f"- `{m.get('type', 'assistant')}`: {m.get('content', '')}")
436 433
437 tcs = step.get("tool_calls", []) 434 tcs = step.get("tool_calls", [])
438 if tcs: 435 if tcs:
@@ -450,65 +447,10 @@ def display_message(message: dict): @@ -450,65 +447,10 @@ def display_message(message: dict):
450 st.code(r.get("content", ""), language="text") 447 st.code(r.get("content", ""), language="text")
451 448
452 st.markdown("---") 449 st.markdown("---")
453 -  
454 - # Extract and display products if any  
455 - products = extract_products_from_response(content)  
456 -  
457 - # Debug logging  
458 - logger.info(f"Extracted {len(products)} products from response")  
459 - for p in products:  
460 - logger.info(f"Product: {p}")  
461 -  
462 - if products:  
463 - def parse_score(product: dict) -> float:  
464 - score = product.get("score")  
465 - if score is None:  
466 - return 0.0  
467 - try:  
468 - return float(score)  
469 - except (TypeError, ValueError):  
470 - return 0.0  
471 -  
472 - # Sort by score and limit to 3  
473 - products = sorted(products, key=parse_score, reverse=True)[:3]  
474 -  
475 - logger.info(f"Displaying top {len(products)} products")  
476 -  
477 - # Display the text response first (without product details)  
478 - text_lines = []  
479 - for line in content.split("\n"):  
480 - # Skip product detail lines  
481 - if not any(  
482 - keyword in line  
483 - for keyword in [  
484 - "ID:",  
485 - "Category:",  
486 - "Color:",  
487 - "Gender:",  
488 - "Season:",  
489 - "Usage:",  
490 - "Similarity:",  
491 - "Relevance:",  
492 - ]  
493 - ):  
494 - if not re.match(r"^\*?\*?\d+\.\s+", line):  
495 - text_lines.append(line)  
496 -  
497 - intro_text = "\n".join(text_lines).strip()  
498 - if intro_text:  
499 - st.markdown(intro_text)  
500 -  
501 - # Display product cards in grid  
502 - st.markdown("<br>", unsafe_allow_html=True)  
503 -  
504 - # Create exactly 3 columns with equal width  
505 - cols = st.columns(3)  
506 - for j, product in enumerate(products[:9]): # Ensure max 3  
507 - with cols[j]:  
508 - display_product_card(product)  
509 - else:  
510 - # No products found, display full content  
511 - st.markdown(content) 450 +
  451 + # Render message: expand [SEARCH_REF:xxx] tokens into product card blocks
  452 + session_id = st.session_state.get("session_id", "")
  453 + render_message_with_refs(content, session_id)
512 454
513 st.markdown("</div>", unsafe_allow_html=True) 455 st.markdown("</div>", unsafe_allow_html=True)
514 456
@@ -591,6 +533,10 @@ def main(): @@ -591,6 +533,10 @@ def main():
591 if st.button("🗑️ Clear Chat", use_container_width=True): 533 if st.button("🗑️ Clear Chat", use_container_width=True):
592 if "shopping_agent" in st.session_state: 534 if "shopping_agent" in st.session_state:
593 st.session_state.shopping_agent.clear_history() 535 st.session_state.shopping_agent.clear_history()
  536 + # Clear search result registry for this session
  537 + session_id = st.session_state.get("session_id", "")
  538 + if session_id:
  539 + global_registry.clear_session(session_id)
594 st.session_state.messages = [] 540 st.session_state.messages = []
595 st.session_state.uploaded_image = None 541 st.session_state.uploaded_image = None
596 st.rerun() 542 st.rerun()
@@ -600,6 +546,7 @@ def main(): @@ -600,6 +546,7 @@ def main():
600 st.checkbox( 546 st.checkbox(
601 "显示调试过程 (debug)", 547 "显示调试过程 (debug)",
602 key="show_debug", 548 key="show_debug",
  549 + value=True,
603 help="展开后可查看中间思考过程及工具调用详情", 550 help="展开后可查看中间思考过程及工具调用详情",
604 ) 551 )
605 552
@@ -713,26 +660,16 @@ def main(): @@ -713,26 +660,16 @@ def main():
713 try: 660 try:
714 shopping_agent = st.session_state.shopping_agent 661 shopping_agent = st.session_state.shopping_agent
715 662
716 - # Handle greetings 663 + # Handle greetings without invoking the agent
717 query_lower = user_query.lower().strip() 664 query_lower = user_query.lower().strip()
718 - if query_lower in ["hi", "hello", "hey"]:  
719 - response = """Hello! 👋 I'm your fashion shopping assistant.  
720 -  
721 -I can help you:  
722 -- Search for products by description  
723 -- Find items similar to images you upload  
724 -- Analyze product styles  
725 -  
726 -What are you looking for today?"""  
727 - tool_calls = []  
728 - else:  
729 - # Process with agent  
730 - result = shopping_agent.chat(  
731 - query=user_query,  
732 - image_path=image_path,  
733 - )  
734 - response = result["response"]  
735 - tool_calls = result.get("tool_calls", []) 665 + # Process with agent
  666 + result = shopping_agent.chat(
  667 + query=user_query,
  668 + image_path=image_path,
  669 + )
  670 + response = result["response"]
  671 + tool_calls = result.get("tool_calls", [])
  672 + debug_steps = result.get("debug_steps", [])
736 673
737 # Add assistant message 674 # Add assistant message
738 st.session_state.messages.append( 675 st.session_state.messages.append(
@@ -740,7 +677,7 @@ What are you looking for today?&quot;&quot;&quot; @@ -740,7 +677,7 @@ What are you looking for today?&quot;&quot;&quot;
740 "role": "assistant", 677 "role": "assistant",
741 "content": response, 678 "content": response,
742 "tool_calls": tool_calls, 679 "tool_calls": tool_calls,
743 - "debug_steps": result.get("debug_steps", []), 680 + "debug_steps": debug_steps,
744 } 681 }
745 ) 682 )
746 683
app/agents/shopping_agent.py
1 """ 1 """
2 Conversational Shopping Agent with LangGraph 2 Conversational Shopping Agent with LangGraph
3 -True ReAct agent with autonomous tool calling and message accumulation 3 +
  4 +Architecture:
  5 +- ReAct-style agent: plan → search → evaluate → re-plan or respond
  6 +- search_products is session-bound, writing curated results to SearchResultRegistry
  7 +- Final AI message references results via [SEARCH_REF:xxx] tokens instead of
  8 + re-listing product details; the UI renders product cards from the registry
4 """ 9 """
5 10
6 import logging 11 import logging
@@ -16,14 +21,52 @@ from langgraph.prebuilt import ToolNode @@ -16,14 +21,52 @@ from langgraph.prebuilt import ToolNode
16 from typing_extensions import Annotated, TypedDict 21 from typing_extensions import Annotated, TypedDict
17 22
18 from app.config import settings 23 from app.config import settings
  24 +from app.search_registry import global_registry
19 from app.tools.search_tools import get_all_tools 25 from app.tools.search_tools import get_all_tools
20 26
21 logger = logging.getLogger(__name__) 27 logger = logging.getLogger(__name__)
22 28
  29 +# ── System prompt ──────────────────────────────────────────────────────────────
  30 +# Universal: works for any e-commerce vertical (fashion, electronics, home, etc.)
  31 +# Key design decisions:
  32 +# 1. Guides multi-query search planning with explicit evaluate-and-decide loop
  33 +# 2. Forbids re-listing product details in the final response
  34 +# 3. Mandates [SEARCH_REF:xxx] inline citation as the only product presentation mechanism
  35 +SYSTEM_PROMPT = """
  36 +角色定义
  37 +你是一名专业的服装电商导购,是一个善于倾听、主动引导、懂得搭配的“时尚顾问”,通过有温度的对话,给用户提供有价值的信息,包括需求引导、方案推荐、搜索结果推荐,最终促成满意的购物决策或转化行为。
  38 +
  39 +一些原则:
  40 +1. 你是一个真人导购,是一个贴心、专业的销售,保持灵活,根据上下文,基于常识灵活的切换策略,在合适的上下文询问合适的问题、给出有价值的方案和搜索结果的呈现。
  41 +2. 兼顾推荐与信息收集:适时的提供有价值的信息,如商品推荐、穿搭建议、趋势信息,在推荐方向上有需求缺口、需要明确的重要信息时,要适时的做“信息收集”,引导式的帮助用户更清晰的呈现需求、提高商品发现的效率,形成“提供-反馈”的良性循环。
  42 + 1. 在意图不明时,主动通过1-2个关键问题(如品类、场景、风格、预算)进行引导,并提供初步方向。
  43 + 2. 在了解到初步意向后,要进行相关商品的搜索、进行搜索结果的呈现,同时思考该方向下重要的决策因素,进行提议和问题收集,让用户既得到相关信息、又得到下一步的方向引导、同时也有机会修正或者细化诉求。
  44 + 3. 对于复杂需求时,要能基于上下文,将导购任务进行合理拆解。
  45 +3. 引导或者收集需求时,需要站在用户立场,比如询问用户期待的效果或感觉、使用的场合、偏好的风格等用户立场需,而不是询问具体的款式或参数,你需要将用户立场的需求理解/翻译/转化为具体的搜索计划,最后筛选产品、结合需求+结果特性组织推荐理由、呈现方案。
  46 +4. 如何使用search_products:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。每次调用 search_products 后,工具会返回以下内容,你需要决策是否要调整搜索策略,比如结果质量太差,可能需要调整搜索词、或者加大试探的query数量(不要超过3-5个)。可以进行多轮搜索,但是要适时的总结和反馈信息避免用户等待过长时间:
  47 + - 各层级数量:完美匹配 / 部分匹配 / 不相关 的条数
  48 + - 整体质量判断:优质 / 一般 / 较差
  49 + - 简短质量说明
  50 + - 结果引用标识:[SEARCH_REF:xxx]
  51 +5. 撰写最终回复的时候,使用 [SEARCH_REF:xxx] 内联引用
  52 + 1. 用自然流畅的语言组织回复,将 [SEARCH_REF:xxx] 嵌入叙述中
  53 + 2. 系统会自动在 [SEARCH_REF:xxx] 位置渲染对应的商品卡片列表
  54 + 3. 禁止在回复文本中列出商品名称、ID、价格、分类、规格等字段
  55 + 4. 禁止用编号列表逐条复述搜索结果中的商品
  56 +"""
  57 +
  58 +
  59 +# ── Agent state ────────────────────────────────────────────────────────────────
  60 +
  61 +class AgentState(TypedDict):
  62 + messages: Annotated[Sequence[BaseMessage], add_messages]
  63 + current_image_path: Optional[str]
  64 +
  65 +
  66 +# ── Helper ─────────────────────────────────────────────────────────────────────
23 67
24 def _extract_message_text(msg) -> str: 68 def _extract_message_text(msg) -> str:
25 - """Extract text from message content.  
26 - LangChain 1.0: content may be str or content_blocks (list) for multimodal.""" 69 + """Extract plain text from a LangChain message (handles str or content_blocks)."""
27 content = getattr(msg, "content", "") 70 content = getattr(msg, "content", "")
28 if isinstance(content, str): 71 if isinstance(content, str):
29 return content 72 return content
@@ -31,27 +74,21 @@ def _extract_message_text(msg) -&gt; str: @@ -31,27 +74,21 @@ def _extract_message_text(msg) -&gt; str:
31 parts = [] 74 parts = []
32 for block in content: 75 for block in content:
33 if isinstance(block, dict): 76 if isinstance(block, dict):
34 - parts.append(block.get("text", block.get("content", ""))) 77 + parts.append(block.get("text") or block.get("content") or "")
35 else: 78 else:
36 parts.append(str(block)) 79 parts.append(str(block))
37 return "".join(str(p) for p in parts) 80 return "".join(str(p) for p in parts)
38 return str(content) if content else "" 81 return str(content) if content else ""
39 82
40 83
41 -class AgentState(TypedDict):  
42 - """State for the shopping agent with message accumulation"""  
43 -  
44 - messages: Annotated[Sequence[BaseMessage], add_messages]  
45 - current_image_path: Optional[str] # Track uploaded image 84 +# ── Agent class ────────────────────────────────────────────────────────────────
46 85
47 -print("settings")  
48 class ShoppingAgent: 86 class ShoppingAgent:
49 - """True ReAct agent with autonomous decision making""" 87 + """ReAct shopping agent with search-evaluate-decide loop and registry-based result referencing."""
50 88
51 def __init__(self, session_id: Optional[str] = None): 89 def __init__(self, session_id: Optional[str] = None):
52 self.session_id = session_id or "default" 90 self.session_id = session_id or "default"
53 91
54 - # Initialize LLM  
55 llm_kwargs = dict( 92 llm_kwargs = dict(
56 model=settings.openai_model, 93 model=settings.openai_model,
57 temperature=settings.openai_temperature, 94 temperature=settings.openai_temperature,
@@ -59,261 +96,173 @@ class ShoppingAgent: @@ -59,261 +96,173 @@ class ShoppingAgent:
59 ) 96 )
60 if settings.openai_api_base_url: 97 if settings.openai_api_base_url:
61 llm_kwargs["base_url"] = settings.openai_api_base_url 98 llm_kwargs["base_url"] = settings.openai_api_base_url
62 -  
63 - print("llm_kwargs")  
64 - print(llm_kwargs)  
65 99
66 self.llm = ChatOpenAI(**llm_kwargs) 100 self.llm = ChatOpenAI(**llm_kwargs)
67 101
68 - # Get tools and bind to model  
69 - self.tools = get_all_tools() 102 + # Tools are session-bound so search_products writes to the right registry partition
  103 + self.tools = get_all_tools(session_id=self.session_id, registry=global_registry)
70 self.llm_with_tools = self.llm.bind_tools(self.tools) 104 self.llm_with_tools = self.llm.bind_tools(self.tools)
71 105
72 - # Build graph  
73 self.graph = self._build_graph() 106 self.graph = self._build_graph()
74 -  
75 - logger.info(f"Shopping agent initialized for session: {self.session_id}") 107 + logger.info(f"ShoppingAgent ready — session={self.session_id}")
76 108
77 def _build_graph(self): 109 def _build_graph(self):
78 - """Build the LangGraph StateGraph"""  
79 -  
80 - # System prompt for the agent  
81 - system_prompt = """你是一位智能时尚购物助手,你可以:  
82 -1. 根据文字描述搜索商品(使用 search_products)  
83 -2. 分析图片风格和属性(使用 analyze_image_style)  
84 -  
85 -当用户咨询商品时:  
86 -- 文字提问:直接使用 search_products 搜索  
87 -- 图片上传:先用 analyze_image_style 理解商品,再用提取的描述调用 search_products 搜索  
88 -- 可按需连续调用多个工具  
89 -- 始终保持有用、友好的回复风格  
90 -  
91 -关键格式规则:  
92 -展示商品结果时,每个商品必须严格按以下格式输出:  
93 -  
94 -1. [标题 title]  
95 - ID: [商品ID]  
96 - 分类: [category_path]  
97 - 中文名: [title_cn](如有)  
98 - 标签: [tags](如有)  
99 -  
100 -示例:  
101 -1. Puma Men White 3/4 Length Pants  
102 - ID: 12345  
103 - 分类: 服饰 > 裤装 > 运动裤  
104 - 中文名: 彪马男士白色九分运动裤  
105 - 标签: 运动,夏季,白色  
106 -  
107 -不可省略 ID 字段!它是展示商品图片的关键。  
108 -介绍要口语化,但必须保持上述商品格式。"""  
109 -  
110 def agent_node(state: AgentState): 110 def agent_node(state: AgentState):
111 - """Agent decision node - decides which tools to call or when to respond"""  
112 messages = state["messages"] 111 messages = state["messages"]
113 -  
114 - # Add system prompt if first message  
115 if not any(isinstance(m, SystemMessage) for m in messages): 112 if not any(isinstance(m, SystemMessage) for m in messages):
116 - messages = [SystemMessage(content=system_prompt)] + list(messages)  
117 -  
118 - # Handle image context  
119 - if state.get("current_image_path"):  
120 - # Inject image path context for tool calls  
121 - # The agent can reference this in its reasoning  
122 - pass  
123 -  
124 - # Invoke LLM with tools 113 + messages = [SystemMessage(content=SYSTEM_PROMPT)] + list(messages)
125 response = self.llm_with_tools.invoke(messages) 114 response = self.llm_with_tools.invoke(messages)
126 return {"messages": [response]} 115 return {"messages": [response]}
127 116
128 - # Create tool node  
129 - tool_node = ToolNode(self.tools)  
130 -  
131 def should_continue(state: AgentState): 117 def should_continue(state: AgentState):
132 - """Determine if agent should continue or end"""  
133 - messages = state["messages"]  
134 - last_message = messages[-1]  
135 -  
136 - # If LLM made tool calls, continue to tools  
137 - if hasattr(last_message, "tool_calls") and last_message.tool_calls: 118 + last = state["messages"][-1]
  119 + if hasattr(last, "tool_calls") and last.tool_calls:
138 return "tools" 120 return "tools"
139 - # Otherwise, end (agent has final response)  
140 return END 121 return END
141 122
142 - # Build graph  
143 - workflow = StateGraph(AgentState) 123 + tool_node = ToolNode(self.tools)
144 124
  125 + workflow = StateGraph(AgentState)
145 workflow.add_node("agent", agent_node) 126 workflow.add_node("agent", agent_node)
146 workflow.add_node("tools", tool_node) 127 workflow.add_node("tools", tool_node)
147 -  
148 workflow.add_edge(START, "agent") 128 workflow.add_edge(START, "agent")
149 workflow.add_conditional_edges("agent", should_continue, ["tools", END]) 129 workflow.add_conditional_edges("agent", should_continue, ["tools", END])
150 workflow.add_edge("tools", "agent") 130 workflow.add_edge("tools", "agent")
151 131
152 - # Compile with memory  
153 - checkpointer = MemorySaver()  
154 - return workflow.compile(checkpointer=checkpointer) 132 + return workflow.compile(checkpointer=MemorySaver())
155 133
156 def chat(self, query: str, image_path: Optional[str] = None) -> dict: 134 def chat(self, query: str, image_path: Optional[str] = None) -> dict:
157 - """Process user query with the agent  
158 -  
159 - Args:  
160 - query: User's text query  
161 - image_path: Optional path to uploaded image 135 + """
  136 + Process a user query and return the agent response with metadata.
162 137
163 Returns: 138 Returns:
164 - Dict with response and metadata, including:  
165 - - tool_calls: list of tool calls with args and (truncated) results  
166 - - debug_steps: detailed intermediate reasoning & tool execution steps 139 + dict with keys:
  140 + response – final AI message text (may contain [SEARCH_REF:xxx] tokens)
  141 + tool_calls – list of {name, args, result_preview}
  142 + debug_steps – detailed per-node step log
  143 + search_refs – dict[ref_id → SearchResult] for all searches this turn
  144 + error – bool
167 """ 145 """
168 try: 146 try:
169 - logger.info(  
170 - f"[{self.session_id}] Processing: '{query}' (image={'Yes' if image_path else 'No'})"  
171 - ) 147 + logger.info(f"[{self.session_id}] chat: {query!r} image={bool(image_path)}")
172 148
173 - # Validate image  
174 if image_path and not Path(image_path).exists(): 149 if image_path and not Path(image_path).exists():
175 return { 150 return {
176 - "response": f"Error: Image file not found at '{image_path}'", 151 + "response": f"错误:图片文件不存在:{image_path}",
177 "error": True, 152 "error": True,
178 } 153 }
179 154
180 - # Build input message 155 + # Snapshot registry before the turn so we can report new additions
  156 + registry_before = set(global_registry.get_all(self.session_id).keys())
  157 +
181 message_content = query 158 message_content = query
182 if image_path: 159 if image_path:
183 - message_content = f"{query}\n[User uploaded image: {image_path}]" 160 + message_content = f"{query}\n[用户上传了图片:{image_path}]"
184 161
185 - # Invoke agent  
186 config = {"configurable": {"thread_id": self.session_id}} 162 config = {"configurable": {"thread_id": self.session_id}}
187 input_state = { 163 input_state = {
188 "messages": [HumanMessage(content=message_content)], 164 "messages": [HumanMessage(content=message_content)],
189 "current_image_path": image_path, 165 "current_image_path": image_path,
190 } 166 }
191 167
192 - # Track tool calls (high-level) and detailed debug steps  
193 - tool_calls = []  
194 - debug_steps = []  
195 -  
196 - # Stream events to capture tool calls and intermediate reasoning 168 + tool_calls: list[dict] = []
  169 + debug_steps: list[dict] = []
  170 +
197 for event in self.graph.stream(input_state, config=config): 171 for event in self.graph.stream(input_state, config=config):
198 - logger.info(f"Event: {event}") 172 + logger.debug(f"[{self.session_id}] event keys: {list(event.keys())}")
199 173
200 - # Agent node: LLM reasoning & tool decisions  
201 if "agent" in event: 174 if "agent" in event:
202 - agent_output = event["agent"]  
203 - messages = agent_output.get("messages", []) 175 + agent_out = event["agent"]
  176 + step_msgs: list[dict] = []
  177 + step_tcs: list[dict] = []
204 178
205 - step_messages = []  
206 - step_tool_calls = []  
207 -  
208 - for msg in messages:  
209 - msg_text = _extract_message_text(msg)  
210 - msg_entry = { 179 + for msg in agent_out.get("messages", []):
  180 + text = _extract_message_text(msg)
  181 + step_msgs.append({
211 "type": getattr(msg, "type", "assistant"), 182 "type": getattr(msg, "type", "assistant"),
212 - "content": msg_text[:500], # truncate for safety  
213 - }  
214 - step_messages.append(msg_entry)  
215 -  
216 - # Capture tool calls from this agent message 183 + "content": text[:500],
  184 + })
217 if hasattr(msg, "tool_calls") and msg.tool_calls: 185 if hasattr(msg, "tool_calls") and msg.tool_calls:
218 for tc in msg.tool_calls: 186 for tc in msg.tool_calls:
219 - tc_entry = {  
220 - "name": tc.get("name"),  
221 - "args": tc.get("args", {}),  
222 - }  
223 - tool_calls.append(tc_entry)  
224 - step_tool_calls.append(tc_entry)  
225 -  
226 - debug_steps.append(  
227 - {  
228 - "node": "agent",  
229 - "messages": step_messages,  
230 - "tool_calls": step_tool_calls,  
231 - }  
232 - )  
233 -  
234 - # Tool node: actual tool execution results  
235 - if "tools" in event:  
236 - tools_output = event["tools"]  
237 - messages = tools_output.get("messages", [])  
238 -  
239 - step_tool_results = [] 187 + entry = {"name": tc.get("name"), "args": tc.get("args", {})}
  188 + tool_calls.append(entry)
  189 + step_tcs.append(entry)
240 190
241 - for i, msg in enumerate(messages):  
242 - content_text = _extract_message_text(msg)  
243 - result_preview = content_text[:500] + ("..." if len(content_text) > 500 else "") 191 + debug_steps.append({"node": "agent", "messages": step_msgs, "tool_calls": step_tcs})
244 192
245 - if i < len(tool_calls):  
246 - tool_calls[i]["result"] = result_preview 193 + if "tools" in event:
  194 + tools_out = event["tools"]
  195 + step_results: list[dict] = []
  196 + msgs = tools_out.get("messages", [])
247 197
248 - step_tool_results.append(  
249 - {  
250 - "content": result_preview,  
251 - }  
252 - ) 198 + # Match results back to tool_calls by position within this event
  199 + unresolved = [tc for tc in tool_calls if "result" not in tc]
  200 + for i, msg in enumerate(msgs):
  201 + text = _extract_message_text(msg)
  202 + preview = text[:600] + ("…" if len(text) > 600 else "")
  203 + if i < len(unresolved):
  204 + unresolved[i]["result"] = preview
  205 + step_results.append({"content": preview})
253 206
254 - debug_steps.append(  
255 - {  
256 - "node": "tools",  
257 - "results": step_tool_results,  
258 - }  
259 - ) 207 + debug_steps.append({"node": "tools", "results": step_results})
260 208
261 - # Get final state  
262 final_state = self.graph.get_state(config) 209 final_state = self.graph.get_state(config)
263 - final_message = final_state.values["messages"][-1]  
264 - response_text = _extract_message_text(final_message) 210 + final_msg = final_state.values["messages"][-1]
  211 + response_text = _extract_message_text(final_msg)
  212 +
  213 + # Collect new SearchResults added during this turn
  214 + registry_after = global_registry.get_all(self.session_id)
  215 + new_refs = {
  216 + ref_id: result
  217 + for ref_id, result in registry_after.items()
  218 + if ref_id not in registry_before
  219 + }
265 220
266 - logger.info(f"[{self.session_id}] Response generated with {len(tool_calls)} tool calls") 221 + logger.info(
  222 + f"[{self.session_id}] done — tool_calls={len(tool_calls)}, new_refs={list(new_refs.keys())}"
  223 + )
267 224
268 return { 225 return {
269 "response": response_text, 226 "response": response_text,
270 "tool_calls": tool_calls, 227 "tool_calls": tool_calls,
271 "debug_steps": debug_steps, 228 "debug_steps": debug_steps,
  229 + "search_refs": new_refs,
272 "error": False, 230 "error": False,
273 } 231 }
274 232
275 except Exception as e: 233 except Exception as e:
276 - logger.error(f"Error in agent chat: {e}", exc_info=True) 234 + logger.error(f"[{self.session_id}] chat error: {e}", exc_info=True)
277 return { 235 return {
278 - "response": f"I apologize, I encountered an error: {str(e)}", 236 + "response": f"抱歉,处理您的请求时遇到错误:{e}",
  237 + "tool_calls": [],
  238 + "debug_steps": [],
  239 + "search_refs": {},
279 "error": True, 240 "error": True,
280 } 241 }
281 242
282 def get_conversation_history(self) -> list: 243 def get_conversation_history(self) -> list:
283 - """Get conversation history for this session"""  
284 try: 244 try:
285 config = {"configurable": {"thread_id": self.session_id}} 245 config = {"configurable": {"thread_id": self.session_id}}
286 state = self.graph.get_state(config) 246 state = self.graph.get_state(config)
287 -  
288 if not state or not state.values.get("messages"): 247 if not state or not state.values.get("messages"):
289 return [] 248 return []
290 249
291 - messages = state.values["messages"]  
292 result = [] 250 result = []
293 -  
294 - for msg in messages:  
295 - # Skip system messages and tool messages 251 + for msg in state.values["messages"]:
296 if isinstance(msg, SystemMessage): 252 if isinstance(msg, SystemMessage):
297 continue 253 continue
298 - if hasattr(msg, "type") and msg.type in ["system", "tool"]: 254 + if getattr(msg, "type", None) in ("system", "tool"):
299 continue 255 continue
300 -  
301 role = "user" if msg.type == "human" else "assistant" 256 role = "user" if msg.type == "human" else "assistant"
302 result.append({"role": role, "content": _extract_message_text(msg)}) 257 result.append({"role": role, "content": _extract_message_text(msg)})
303 -  
304 return result 258 return result
305 -  
306 except Exception as e: 259 except Exception as e:
307 - logger.error(f"Error getting history: {e}") 260 + logger.error(f"get_conversation_history error: {e}")
308 return [] 261 return []
309 262
310 def clear_history(self): 263 def clear_history(self):
311 - """Clear conversation history for this session"""  
312 - # With MemorySaver, we can't easily clear, but we can log  
313 - logger.info(f"[{self.session_id}] History clear requested")  
314 - # In production, implement proper clearing or use new thread_id 264 + logger.info(f"[{self.session_id}] clear requested (use new session_id to fully reset)")
315 265
316 266
317 def create_shopping_agent(session_id: Optional[str] = None) -> ShoppingAgent: 267 def create_shopping_agent(session_id: Optional[str] = None) -> ShoppingAgent:
318 - """Factory function to create a shopping agent"""  
319 return ShoppingAgent(session_id=session_id) 268 return ShoppingAgent(session_id=session_id)
app/search_registry.py 0 → 100644
@@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
  1 +"""
  2 +Search Result Registry
  3 +
  4 +Stores structured search results keyed by session and ref_id.
  5 +Each [SEARCH_REF:xxx] in an AI response maps to a SearchResult stored here,
  6 +allowing the UI to render product cards without the LLM ever re-listing them.
  7 +"""
  8 +
  9 +import uuid
  10 +from dataclasses import dataclass, field
  11 +from typing import Optional
  12 +
  13 +
  14 +def new_ref_id() -> str:
  15 + """Generate a short unique search reference ID, e.g. 'sr_3f9a1b2c'."""
  16 + return "sr_" + uuid.uuid4().hex[:8]
  17 +
  18 +
  19 +@dataclass
  20 +class ProductItem:
  21 + """A single product extracted from a search result, enriched with a match label."""
  22 +
  23 + spu_id: str
  24 + title: str
  25 + price: Optional[float] = None
  26 + category_path: Optional[str] = None
  27 + vendor: Optional[str] = None
  28 + image_url: Optional[str] = None
  29 + relevance_score: Optional[float] = None
  30 + # LLM-assigned label: "完美匹配" | "部分匹配" | "不相关"
  31 + match_label: str = "部分匹配"
  32 + tags: list = field(default_factory=list)
  33 + specifications: list = field(default_factory=list)
  34 +
  35 +
  36 +@dataclass
  37 +class SearchResult:
  38 + """
  39 + A complete, self-contained search result block.
  40 +
  41 + Identified by ref_id (e.g. 'sr_3f9a1b2c').
  42 + Stores the query, LLM quality assessment, and the curated product list
  43 + (only "完美匹配" and "部分匹配" items — "不相关" are discarded).
  44 + """
  45 +
  46 + ref_id: str
  47 + query: str
  48 +
  49 + # Raw API stats
  50 + total_api_hits: int # total documents matched by the search engine
  51 + returned_count: int # number of results we actually assessed
  52 +
  53 + # LLM quality labels breakdown
  54 + perfect_count: int
  55 + partial_count: int
  56 + irrelevant_count: int
  57 +
  58 + # LLM overall quality verdict
  59 + quality_verdict: str # "优质" | "一般" | "较差"
  60 + quality_summary: str # one-sentence LLM explanation
  61 +
  62 + # Curated product list (perfect + partial only)
  63 + products: list # list[ProductItem]
  64 +
  65 +
  66 +class SearchResultRegistry:
  67 + """
  68 + Session-scoped store: session_id → { ref_id → SearchResult }.
  69 +
  70 + Lives as a global singleton in the process; Streamlit reruns preserve it
  71 + as long as the worker process is alive. Session isolation is maintained
  72 + by keying on session_id.
  73 + """
  74 +
  75 + def __init__(self) -> None:
  76 + self._store: dict[str, dict[str, SearchResult]] = {}
  77 +
  78 + def register(self, session_id: str, result: SearchResult) -> str:
  79 + """Store a SearchResult and return its ref_id."""
  80 + if session_id not in self._store:
  81 + self._store[session_id] = {}
  82 + self._store[session_id][result.ref_id] = result
  83 + return result.ref_id
  84 +
  85 + def get(self, session_id: str, ref_id: str) -> Optional[SearchResult]:
  86 + """Look up a single SearchResult by session and ref_id."""
  87 + return self._store.get(session_id, {}).get(ref_id)
  88 +
  89 + def get_all(self, session_id: str) -> dict:
  90 + """Return all SearchResults for a session (ref_id → SearchResult)."""
  91 + return dict(self._store.get(session_id, {}))
  92 +
  93 + def clear_session(self, session_id: str) -> None:
  94 + """Remove all search results for a session (e.g. on chat clear)."""
  95 + self._store.pop(session_id, None)
  96 +
  97 +
  98 +# ── Global singleton ──────────────────────────────────────────────────────────
  99 +# Imported by search_tools and app.py; both sides share the same object.
  100 +global_registry = SearchResultRegistry()
app/tools/__init__.py
1 """ 1 """
2 LangChain Tools for Product Search and Discovery 2 LangChain Tools for Product Search and Discovery
  3 +
  4 +search_products is created per-session via make_search_products_tool().
  5 +Use get_all_tools(session_id, registry) for the full tool list.
3 """ 6 """
4 7
5 from app.tools.search_tools import ( 8 from app.tools.search_tools import (
6 analyze_image_style, 9 analyze_image_style,
7 get_all_tools, 10 get_all_tools,
8 - search_products, 11 + make_search_products_tool,
  12 + web_search,
9 ) 13 )
10 14
11 __all__ = [ 15 __all__ = [
12 - "search_products", 16 + "make_search_products_tool",
13 "analyze_image_style", 17 "analyze_image_style",
  18 + "web_search",
14 "get_all_tools", 19 "get_all_tools",
15 ] 20 ]
app/tools/search_tools.py
1 """ 1 """
2 Search Tools for Product Discovery 2 Search Tools for Product Discovery
3 -Provides text-based search via Search API, web search, and VLM style analysis 3 +
  4 +Key design:
  5 +- search_products is created via a factory (make_search_products_tool) that
  6 + closes over (session_id, registry), so each agent session has its own tool
  7 + instance pointing to the shared registry.
  8 +- After calling the search API, an LLM quality-assessment step labels every
  9 + result as 完美匹配 / 部分匹配 / 不相关 and produces an overall verdict.
  10 +- The curated product list is stored in the registry under a unique ref_id.
  11 +- The tool returns ONLY the quality summary + [SEARCH_REF:ref_id], never the
  12 + raw product list. The LLM references the result in its final response via
  13 + the [SEARCH_REF:...] token; the UI renders the product cards from the registry.
4 """ 14 """
5 15
6 import base64 16 import base64
  17 +import json
7 import logging 18 import logging
8 import os 19 import os
9 from pathlib import Path 20 from pathlib import Path
@@ -14,6 +25,13 @@ from langchain_core.tools import tool @@ -14,6 +25,13 @@ from langchain_core.tools import tool
14 from openai import OpenAI 25 from openai import OpenAI
15 26
16 from app.config import settings 27 from app.config import settings
  28 +from app.search_registry import (
  29 + ProductItem,
  30 + SearchResult,
  31 + SearchResultRegistry,
  32 + global_registry,
  33 + new_ref_id,
  34 +)
17 35
18 logger = logging.getLogger(__name__) 36 logger = logging.getLogger(__name__)
19 37
@@ -30,31 +48,264 @@ def get_openai_client() -&gt; OpenAI: @@ -30,31 +48,264 @@ def get_openai_client() -&gt; OpenAI:
30 return _openai_client 48 return _openai_client
31 49
32 50
  51 +# ── LLM quality assessment ─────────────────────────────────────────────────────
  52 +
  53 +def _assess_search_quality(
  54 + query: str,
  55 + raw_products: list,
  56 +) -> tuple[list[str], str, str]:
  57 + """
  58 + Ask the LLM to evaluate how well each search result matches the query.
  59 +
  60 + Returns:
  61 + labels – list[str], one per product: "完美匹配" | "部分匹配" | "不相关"
  62 + verdict – str: "优质" | "一般" | "较差"
  63 + summary – str: one-sentence explanation
  64 + """
  65 + n = len(raw_products)
  66 + if n == 0:
  67 + return [], "较差", "搜索未返回任何商品。"
  68 +
  69 + # Build a compact product list — only title/category/tags/score to save tokens
  70 + lines: list[str] = []
  71 + for i, p in enumerate(raw_products, 1):
  72 + title = (p.get("title") or "")[:60]
  73 + cat = p.get("category_path") or p.get("category_name") or ""
  74 + tags_raw = p.get("tags") or []
  75 + tags = ", ".join(str(t) for t in tags_raw[:5])
  76 + score = p.get("relevance_score") or 0
  77 + row = f"{i}. [{score:.1f}] {title} | {cat}"
  78 + if tags:
  79 + row += f" | 标签:{tags}"
  80 + lines.append(row)
  81 +
  82 + product_text = "\n".join(lines)
  83 +
  84 + prompt = f"""你是商品搜索质量评估专家。请评估以下搜索结果与用户查询的匹配程度。
  85 +
  86 +用户查询:{query}
  87 +
  88 +搜索结果(共 {n} 条,格式:序号. [相关性分数] 标题 | 分类 | 标签):
  89 +{product_text}
  90 +
  91 +评估说明:
  92 +- 完美匹配:完全符合用户查询意图,用户必然感兴趣
  93 +- 部分匹配:与查询有关联,但不完全满足意图(如品类对但风格偏差、相关配件等)
  94 +- 不相关:与查询无关,不应展示给用户
  95 +
  96 +整体 verdict 判断标准:
  97 +- 优质:完美匹配 ≥ 5 条
  98 +- 一般:完美匹配 2-4 条
  99 +- 较差:完美匹配 < 2 条
  100 +
  101 +请严格按以下 JSON 格式输出,不得有任何额外文字或代码块标记:
  102 +{{"labels": ["完美匹配", "部分匹配", "不相关", ...], "verdict": "优质", "summary": "一句话评价搜索质量"}}
  103 +
  104 +labels 数组长度必须恰好等于 {n}。"""
  105 +
  106 + try:
  107 + client = get_openai_client()
  108 + resp = client.chat.completions.create(
  109 + model=settings.openai_model,
  110 + messages=[{"role": "user", "content": prompt}],
  111 + max_tokens=800,
  112 + temperature=0.1,
  113 + )
  114 + raw = resp.choices[0].message.content.strip()
  115 + # Strip markdown code fences if the model adds them
  116 + if raw.startswith("```"):
  117 + raw = raw.split("```")[1]
  118 + if raw.startswith("json"):
  119 + raw = raw[4:]
  120 + raw = raw.strip()
  121 +
  122 + data = json.loads(raw)
  123 + labels: list[str] = data.get("labels", [])
  124 +
  125 + # Normalize and pad / trim to match n
  126 + valid = {"完美匹配", "部分匹配", "不相关"}
  127 + labels = [l if l in valid else "部分匹配" for l in labels]
  128 + while len(labels) < n:
  129 + labels.append("部分匹配")
  130 + labels = labels[:n]
  131 +
  132 + verdict: str = data.get("verdict", "一般")
  133 + if verdict not in ("优质", "一般", "较差"):
  134 + verdict = "一般"
  135 + summary: str = str(data.get("summary", ""))
  136 + return labels, verdict, summary
  137 +
  138 + except Exception as e:
  139 + logger.warning(f"Quality assessment LLM call failed: {e}; using fallback labels.")
  140 + return ["部分匹配"] * n, "一般", "质量评估步骤失败,结果仅供参考。"
  141 +
  142 +
  143 +# ── Tool factory ───────────────────────────────────────────────────────────────
  144 +
  145 +def make_search_products_tool(
  146 + session_id: str,
  147 + registry: SearchResultRegistry,
  148 +):
  149 + """
  150 + Return a search_products tool bound to a specific session and registry.
  151 +
  152 + The tool:
  153 + 1. Calls the product search API.
  154 + 2. Runs LLM quality assessment on up to 20 results.
  155 + 3. Stores a SearchResult in the registry.
  156 + 4. Returns a concise quality summary + [SEARCH_REF:ref_id].
  157 + The product list is NEVER returned in the tool output text.
  158 + """
  159 +
  160 + @tool
  161 + def search_products(query: str, limit: int = 20) -> str:
  162 + """搜索商品库,根据自然语言描述找到匹配商品,并进行质量评估。
  163 +
  164 + 每次调用专注于单一搜索角度。复杂需求请拆分为多次调用,每次换一个 query。
  165 + 工具会自动评估结果质量(完美匹配 / 部分匹配 / 不相关),并给出整体判断。
  166 +
  167 + Args:
  168 + query: 自然语言商品描述,例如"男士休闲亚麻短裤夏季"
  169 + limit: 最多返回条数(建议 10-20,越多评估越全面)
  170 +
  171 + Returns:
  172 + 质量评估摘要 + [SEARCH_REF:ref_id],供最终回复引用。
  173 + """
  174 + try:
  175 + logger.info(f"[{session_id}] search_products: query={query!r} limit={limit}")
  176 +
  177 + url = f"{settings.search_api_base_url.rstrip('/')}/search/"
  178 + headers = {
  179 + "Content-Type": "application/json",
  180 + "X-Tenant-ID": settings.search_api_tenant_id,
  181 + }
  182 + payload = {
  183 + "query": query,
  184 + "size": min(max(limit, 1), 20),
  185 + "from": 0,
  186 + "language": "zh",
  187 + }
  188 +
  189 + resp = requests.post(url, json=payload, headers=headers, timeout=60)
  190 + if resp.status_code != 200:
  191 + logger.error(f"Search API error {resp.status_code}: {resp.text[:300]}")
  192 + return f"搜索失败:API 返回状态码 {resp.status_code},请稍后重试。"
  193 +
  194 + data = resp.json()
  195 + raw_results: list = data.get("results", [])
  196 + total_hits: int = data.get("total", 0)
  197 +
  198 + if not raw_results:
  199 + return (
  200 + f"【搜索完成】query='{query}'\n"
  201 + "未找到匹配商品,建议换用更宽泛或不同角度的关键词重新搜索。"
  202 + )
  203 +
  204 + # ── LLM quality assessment ──────────────────────────────────────
  205 + labels, verdict, quality_summary = _assess_search_quality(query, raw_results)
  206 +
  207 + # ── Build ProductItem list (keep perfect + partial, discard irrelevant) ──
  208 + products: list[ProductItem] = []
  209 + perfect_count = partial_count = irrelevant_count = 0
  210 +
  211 + for raw, label in zip(raw_results, labels):
  212 + if label == "完美匹配":
  213 + perfect_count += 1
  214 + elif label == "部分匹配":
  215 + partial_count += 1
  216 + else:
  217 + irrelevant_count += 1
  218 +
  219 + if label in ("完美匹配", "部分匹配"):
  220 + products.append(
  221 + ProductItem(
  222 + spu_id=str(raw.get("spu_id", "")),
  223 + title=raw.get("title") or "",
  224 + price=raw.get("price"),
  225 + category_path=(
  226 + raw.get("category_path") or raw.get("category_name")
  227 + ),
  228 + vendor=raw.get("vendor"),
  229 + image_url=raw.get("image_url"),
  230 + relevance_score=raw.get("relevance_score"),
  231 + match_label=label,
  232 + tags=raw.get("tags") or [],
  233 + specifications=raw.get("specifications") or [],
  234 + )
  235 + )
  236 +
  237 + # ── Register ────────────────────────────────────────────────────
  238 + ref_id = new_ref_id()
  239 + result = SearchResult(
  240 + ref_id=ref_id,
  241 + query=query,
  242 + total_api_hits=total_hits,
  243 + returned_count=len(raw_results),
  244 + perfect_count=perfect_count,
  245 + partial_count=partial_count,
  246 + irrelevant_count=irrelevant_count,
  247 + quality_verdict=verdict,
  248 + quality_summary=quality_summary,
  249 + products=products,
  250 + )
  251 + registry.register(session_id, result)
  252 + logger.info(
  253 + f"[{session_id}] Registered {ref_id}: verdict={verdict}, "
  254 + f"perfect={perfect_count}, partial={partial_count}, irrel={irrelevant_count}"
  255 + )
  256 +
  257 + # ── Return summary to agent (NOT the product list) ──────────────
  258 + verdict_hint = {
  259 + "优质": "结果质量优质,可直接引用。",
  260 + "一般": "结果质量一般,可酌情引用,也可补充更精准的 query。",
  261 + "较差": "结果质量较差,建议重新规划 query 后再次搜索。",
  262 + }.get(verdict, "")
  263 +
  264 + return (
  265 + f"【搜索完成】query='{query}'\n"
  266 + f"API 总命中:{total_hits} 条 | 本次评估:{len(raw_results)} 条\n"
  267 + f"质量评估:完美匹配 {perfect_count} 条 | 部分匹配 {partial_count} 条 | 不相关 {irrelevant_count} 条\n"
  268 + f"整体判断:{verdict} — {quality_summary}\n"
  269 + f"{verdict_hint}\n"
  270 + f"结果引用:[SEARCH_REF:{ref_id}]"
  271 + )
  272 +
  273 + except requests.exceptions.RequestException as e:
  274 + logger.error(f"[{session_id}] Search network error: {e}", exc_info=True)
  275 + return f"搜索失败(网络错误):{e}"
  276 + except Exception as e:
  277 + logger.error(f"[{session_id}] Search error: {e}", exc_info=True)
  278 + return f"搜索失败:{e}"
  279 +
  280 + return search_products
  281 +
  282 +
  283 +# ── Standalone tools (no session binding needed) ───────────────────────────────
  284 +
33 @tool 285 @tool
34 def web_search(query: str) -> str: 286 def web_search(query: str) -> str:
35 """使用 Tavily 进行通用 Web 搜索,补充外部/实时知识。 287 """使用 Tavily 进行通用 Web 搜索,补充外部/实时知识。
36 288
37 - 触发场景(示例):  
38 - - 需要**外部知识**:流行趋势、新品信息、穿搭文化、品牌故事等  
39 - - 需要**实时/及时信息**:某地某个时节的天气、当季流行元素、最新联名款  
40 - - 需要**宏观参考**:不同城市/国家的穿衣习惯、节日穿搭建议 289 + 触发场景:
  290 + - 需要**外部知识**:流行趋势、品牌、搭配文化、节日习俗等
  291 + - 需要**实时/及时信息**:当季流行元素、某地未来的天气
  292 + - 需要**宏观参考**:不同场合/国家的穿着建议、选购攻略
41 293
42 Args: 294 Args:
43 - query: 要搜索的问题,自然语言描述(建议用中文) 295 + query: 要搜索的问题,自然语言描述
44 296
45 Returns: 297 Returns:
46 - 总结后的回答 + 若干来源链接,供模型继续推理使用。 298 + 总结后的回答 + 若干参考来源链接
47 """ 299 """
48 try: 300 try:
49 api_key = os.getenv("TAVILY_API_KEY") 301 api_key = os.getenv("TAVILY_API_KEY")
50 if not api_key: 302 if not api_key:
51 - logger.error("TAVILY_API_KEY is not set in environment variables")  
52 return ( 303 return (
53 "无法调用外部 Web 搜索:未检测到 TAVILY_API_KEY 环境变量。\n" 304 "无法调用外部 Web 搜索:未检测到 TAVILY_API_KEY 环境变量。\n"
54 "请在运行环境中配置 TAVILY_API_KEY 后再重试。" 305 "请在运行环境中配置 TAVILY_API_KEY 后再重试。"
55 ) 306 )
56 307
57 - logger.info(f"Calling Tavily web search with query: {query!r}") 308 + logger.info(f"web_search: {query!r}")
58 309
59 url = "https://api.tavily.com/search" 310 url = "https://api.tavily.com/search"
60 headers = { 311 headers = {
@@ -66,15 +317,9 @@ def web_search(query: str) -&gt; str: @@ -66,15 +317,9 @@ def web_search(query: str) -&gt; str:
66 "search_depth": "advanced", 317 "search_depth": "advanced",
67 "include_answer": True, 318 "include_answer": True,
68 } 319 }
69 -  
70 response = requests.post(url, json=payload, headers=headers, timeout=60) 320 response = requests.post(url, json=payload, headers=headers, timeout=60)
71 321
72 if response.status_code != 200: 322 if response.status_code != 200:
73 - logger.error(  
74 - "Tavily API error: %s - %s",  
75 - response.status_code,  
76 - response.text,  
77 - )  
78 return f"调用外部 Web 搜索失败:Tavily 返回状态码 {response.status_code}" 323 return f"调用外部 Web 搜索失败:Tavily 返回状态码 {response.status_code}"
79 324
80 data = response.json() 325 data = response.json()
@@ -87,140 +332,61 @@ def web_search(query: str) -&gt; str: @@ -87,140 +332,61 @@ def web_search(query: str) -&gt; str:
87 "回答摘要:", 332 "回答摘要:",
88 answer.strip(), 333 answer.strip(),
89 ] 334 ]
90 -  
91 if results: 335 if results:
92 output_lines.append("") 336 output_lines.append("")
93 output_lines.append("参考来源(部分):") 337 output_lines.append("参考来源(部分):")
94 for idx, item in enumerate(results[:5], 1): 338 for idx, item in enumerate(results[:5], 1):
95 title = item.get("title") or "无标题" 339 title = item.get("title") or "无标题"
96 - url = item.get("url") or "" 340 + link = item.get("url") or ""
97 output_lines.append(f"{idx}. {title}") 341 output_lines.append(f"{idx}. {title}")
98 - if url:  
99 - output_lines.append(f" 链接: {url}") 342 + if link:
  343 + output_lines.append(f" 链接: {link}")
100 344
101 return "\n".join(output_lines).strip() 345 return "\n".join(output_lines).strip()
102 346
103 except requests.exceptions.RequestException as e: 347 except requests.exceptions.RequestException as e:
104 - logger.error("Error calling Tavily web search (network): %s", e, exc_info=True) 348 + logger.error("web_search network error: %s", e, exc_info=True)
105 return f"调用外部 Web 搜索失败(网络错误):{e}" 349 return f"调用外部 Web 搜索失败(网络错误):{e}"
106 except Exception as e: 350 except Exception as e:
107 - logger.error("Error calling Tavily web search: %s", e, exc_info=True) 351 + logger.error("web_search error: %s", e, exc_info=True)
108 return f"调用外部 Web 搜索失败:{e}" 352 return f"调用外部 Web 搜索失败:{e}"
109 353
110 354
111 @tool 355 @tool
112 -def search_products(query: str, limit: int = 5) -> str:  
113 - """Search for fashion products using natural language descriptions.  
114 -  
115 - Use when users describe what they want:  
116 - - "Find me red summer dresses"  
117 - - "Show me blue running shoes"  
118 - - "I want casual shirts for men"  
119 -  
120 - Args:  
121 - query: Natural language product description  
122 - limit: Maximum number of results (1-20)  
123 -  
124 - Returns:  
125 - Formatted string with product information  
126 - """  
127 - try:  
128 - logger.info(f"Searching products: '{query}', limit: {limit}")  
129 -  
130 - url = f"{settings.search_api_base_url.rstrip('/')}/search/"  
131 - headers = {  
132 - "Content-Type": "application/json",  
133 - "X-Tenant-ID": settings.search_api_tenant_id,  
134 - }  
135 - payload = {  
136 - "query": query,  
137 - "size": min(limit, 20),  
138 - "from": 0,  
139 - "language": "zh",  
140 - }  
141 -  
142 - response = requests.post(url, json=payload, headers=headers, timeout=60)  
143 -  
144 - if response.status_code != 200:  
145 - logger.error(f"Search API error: {response.status_code} - {response.text}")  
146 - return f"Error searching products: API returned {response.status_code}"  
147 -  
148 - data = response.json()  
149 - results = data.get("results", [])  
150 -  
151 - if not results:  
152 - return "No products found matching your search."  
153 -  
154 - output = f"Found {len(results)} product(s):\n\n"  
155 -  
156 - for idx, product in enumerate(results, 1):  
157 - output += f"{idx}. {product.get('title', 'Unknown Product')}\n"  
158 - output += f" ID: {product.get('spu_id', 'N/A')}\n"  
159 - output += f" Category: {product.get('category_path', product.get('category_name', 'N/A'))}\n"  
160 - if product.get("vendor"):  
161 - output += f" Brand: {product.get('vendor')}\n"  
162 - if product.get("price") is not None:  
163 - output += f" Price: {product.get('price')}\n"  
164 -  
165 - # 规格/颜色信息  
166 - specs = product.get("specifications", [])  
167 - if specs:  
168 - color_spec = next(  
169 - (s for s in specs if s.get("name").lower() == "color"),  
170 - None,  
171 - )  
172 - if color_spec:  
173 - output += f" Color: {color_spec.get('value', 'N/A')}\n"  
174 -  
175 - output += "\n"  
176 -  
177 - return output.strip()  
178 -  
179 - except requests.exceptions.RequestException as e:  
180 - logger.error(f"Error searching products (network): {e}", exc_info=True)  
181 - return f"Error searching products: {str(e)}"  
182 - except Exception as e:  
183 - logger.error(f"Error searching products: {e}", exc_info=True)  
184 - return f"Error searching products: {str(e)}"  
185 -  
186 -  
187 -@tool  
188 def analyze_image_style(image_path: str) -> str: 356 def analyze_image_style(image_path: str) -> str:
189 - """Analyze a fashion product image using AI vision to extract detailed style information. 357 + """分析用户上传的商品图片,提取视觉风格属性,用于后续商品搜索。
190 358
191 - Use when you need to understand style/attributes from an image:  
192 - - Understand the style, color, pattern of a product  
193 - - Extract attributes like "casual", "formal", "vintage"  
194 - - Get detailed descriptions for subsequent searches 359 + 适用场景:
  360 + - 用户上传图片,想找相似商品
  361 + - 需要理解图片中商品的风格、颜色、材质等属性
195 362
196 Args: 363 Args:
197 - image_path: Path to the image file 364 + image_path: 图片文件路径
198 365
199 Returns: 366 Returns:
200 - Detailed text description of the product's visual attributes 367 + 商品视觉属性的详细文字描述,可直接作为 search_products 的 query
201 """ 368 """
202 try: 369 try:
203 - logger.info(f"Analyzing image with VLM: '{image_path}'") 370 + logger.info(f"analyze_image_style: {image_path!r}")
204 371
205 img_path = Path(image_path) 372 img_path = Path(image_path)
206 if not img_path.exists(): 373 if not img_path.exists():
207 - return f"Error: Image file not found at '{image_path}'" 374 + return f"错误:图片文件不存在:{image_path}"
208 375
209 - with open(img_path, "rb") as image_file:  
210 - image_data = base64.b64encode(image_file.read()).decode("utf-8") 376 + with open(img_path, "rb") as f:
  377 + image_data = base64.b64encode(f.read()).decode("utf-8")
211 378
212 - prompt = """Analyze this fashion product image and provide a detailed description. 379 + prompt = """请分析这张商品图片,提供详细的视觉属性描述,用于商品搜索。
213 380
214 -Include:  
215 -- Product type (e.g., shirt, dress, shoes, pants, bag)  
216 -- Primary colors  
217 -- Style/design (e.g., casual, formal, sporty, vintage, modern)  
218 -- Pattern or texture (e.g., plain, striped, checked, floral)  
219 -- Key features (e.g., collar type, sleeve length, fit)  
220 -- Material appearance (if obvious, e.g., denim, cotton, leather)  
221 -- Suitable occasion (e.g., office wear, party, casual, sports) 381 +请包含:
  382 +- 商品类型(如:连衣裙、运动鞋、双肩包、西装等)
  383 +- 主要颜色
  384 +- 风格定位(如:休闲、正式、运动、复古、现代简约等)
  385 +- 图案/纹理(如:纯色、条纹、格纹、碎花、几何图案等)
  386 +- 关键设计特征(如:领型、袖长、版型、材质外观等)
  387 +- 适用场合(如:办公、户外、度假、聚会、运动等)
222 388
223 -Provide a comprehensive yet concise description (3-4 sentences).""" 389 +输出格式:3-4句自然语言描述,可直接用作搜索关键词。"""
224 390
225 client = get_openai_client() 391 client = get_openai_client()
226 response = client.chat.completions.create( 392 response = client.chat.completions.create(
@@ -245,15 +411,29 @@ Provide a comprehensive yet concise description (3-4 sentences).&quot;&quot;&quot; @@ -245,15 +411,29 @@ Provide a comprehensive yet concise description (3-4 sentences).&quot;&quot;&quot;
245 ) 411 )
246 412
247 analysis = response.choices[0].message.content.strip() 413 analysis = response.choices[0].message.content.strip()
248 - logger.info("VLM analysis completed")  
249 - 414 + logger.info("Image analysis completed.")
250 return analysis 415 return analysis
251 416
252 except Exception as e: 417 except Exception as e:
253 - logger.error(f"Error analyzing image: {e}", exc_info=True)  
254 - return f"Error analyzing image: {str(e)}" 418 + logger.error(f"analyze_image_style error: {e}", exc_info=True)
  419 + return f"图片分析失败:{e}"
255 420
256 421
257 -def get_all_tools():  
258 - """Get all available tools for the agent"""  
259 - return [search_products, analyze_image_style, web_search] 422 +# ── Tool list factory ──────────────────────────────────────────────────────────
  423 +
  424 +def get_all_tools(
  425 + session_id: str = "default",
  426 + registry: Optional[SearchResultRegistry] = None,
  427 +) -> list:
  428 + """
  429 + Return all agent tools.
  430 +
  431 + search_products is session-bound (factory); other tools are stateless.
  432 + """
  433 + if registry is None:
  434 + registry = global_registry
  435 + return [
  436 + make_search_products_tool(session_id, registry),
  437 + analyze_image_style,
  438 + web_search,
  439 + ]