Commit 897b5ca9eb04b92d884a8c02861513580673c875

Authored by tangwang
1 parent 50fcfb9d

perf: 前端性能优化 + 搜索统一实现 + ref_id 与命名统一

一、命名与引用统一
-----------------
- 全文将 [SEARCH_REF:ref_id] 统一为 [SEARCH_RESULTS_REF:ref_id]。
- README_prompts.md: 文档中两处 SEARCH_REF 改为 SEARCH_RESULTS_REF。
- app/agents/shopping_agent.py: 系统提示与注释中的 SEARCH_REF、make_search_products_tool 改为 SEARCH_RESULTS_REF、search_products。
- app/search_registry.py: 注释与 docstring 中的 SEARCH_REF 改为 SEARCH_RESULTS_REF。
- app.py: SEARCH_REF_PATTERN 重命名为 SEARCH_RESULTS_REF_PATTERN,正则与注释同步。
- app/tools/search_tools.py: 工具返回与注释中的 SEARCH_REF 改为 SEARCH_RESULTS_REF。

二、search_registry:ref_id 改为会话内自增
-----------------------------------------
- 移除 new_ref_id()(原 uuid 短码);ref_id 改为按 session 自增:sr_1, sr_2, ...
- SearchResultRegistry 新增 _session_counter 与 next_ref_id(session_id)。
- register 逻辑不变;clear_session 时同时清除 _session_counter。

三、search_tools:搜索逻辑统一、去除重复
---------------------------------------
- 新增 _call_search_api(query, size):仅调用搜索 API,返回 (raw_results, total_hits) 或 None。
- 新增 _raw_to_product_items(raw_results, labels=None):将 API 原始结果转为 list[ProductItem];有 labels 时按 Relevant/Partially Relevant 过滤并打标,否则全部打 Partially Relevant。
- 新增 search_products_impl(query, limit, *, assess_quality=True, session_id=None, registry=None):唯一搜索实现;返回 (ref_id_or_none, products, assessed_count)。assess_quality 且提供 session_id/registry 时执行 LLM 评估并写入 registry,否则仅调 API 并返回产品列表。
- make_search_products_tool 内 search_products 改为调用 search_products_impl(..., assess_quality=True, session_id, registry),根据返回值拼工具说明文案;使用 registry.next_ref_id(session_id)。
- 新增 search_products_api_only(query, limit=12):薄封装,调用 search_products_impl(..., assess_quality=False),返回 list[ProductItem],供前端「找相似」侧栏使用(仅 API,不做 LLM 评估)。

四、app.py:前端性能与交互优化
-----------------------------
1) 图片缓存
   - 模块级 _IMAGE_CACHE (OrderedDict),最多 100 条;key 为 image_url 或 "local:{path}"。
   - _load_product_image:先查缓存,命中则 move_to_end 并返回;未命中则请求/读本地后写入缓存并做 LRU 淘汰。

2) 减少全量 rerun(fragment)
   - render_referenced_products_in_input、render_bottom_actions_bar 加 @st.fragment,删除引用 / 点 Ask·Compare 时只重跑对应片段。
   - 侧栏内容用 @st.fragment 的 _sidebar_fragment() 包裹,在 with st.sidebar 内调用,Clear Chat 时只重跑侧栏。

3) 「找相似」:先 loading,再仅调搜索 API
   - 点击 Similar products 时只设置 side_panel.payload = { query, loading: True } 并 rerun,侧栏先显示「加载中…」。
   - main() 中若 side_panel.mode=="similar" 且 payload.loading,则调用 search_products_api_only(payload["query"], 12),将结果写入 payload.products、loading=False,再 rerun。
   - render_side_drawer:payload.loading 显示加载中;payload.products 存在则用该列表渲染卡片;否则保留基于 ref_id + registry 的兼容逻辑。
   - 删除 _run_similar_search(原完整 search tool + LLM 评估),改为上述流程。

4) 长对话渲染
   - 仅渲染最近 50 条消息(MAX_MESSAGES=50),msg_index 用 start_idx + i 保持 widget key 稳定;超过 50 条时顶部显示「仅显示最近 50 条,共 N 条消息」。

Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -43,7 +43,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊 @@ -43,7 +43,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊
43 43
44 44
45 45
46 -请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: 46 +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_RESULTS_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
47 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 47 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。
48 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 48 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。
49 下方也悬浮两个菜单,一个ask,一个compare。 49 下方也悬浮两个菜单,一个ask,一个compare。
@@ -66,7 +66,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊 @@ -66,7 +66,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊
66 66
67 67
68 68
69 -请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西: 69 +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_RESULTS_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
70 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。 70 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。
71 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。 71 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。
72 下方也悬浮两个菜单,一个ask,一个compare。 72 下方也悬浮两个菜单,一个ask,一个compare。
@@ -7,6 +7,7 @@ import html @@ -7,6 +7,7 @@ import html
7 import logging 7 import logging
8 import re 8 import re
9 import uuid 9 import uuid
  10 +from collections import OrderedDict
10 from pathlib import Path 11 from pathlib import Path
11 from typing import Any, Optional 12 from typing import Any, Optional
12 13
@@ -17,9 +18,9 @@ from PIL import Image, ImageOps @@ -17,9 +18,9 @@ from PIL import Image, ImageOps
17 from app.agents.shopping_agent import ShoppingAgent 18 from app.agents.shopping_agent import ShoppingAgent
18 from app.search_registry import ProductItem, SearchResult, global_registry 19 from app.search_registry import ProductItem, SearchResult, global_registry
19 20
20 -# Matches [SEARCH_REF:sr_xxxxxxxx] tokens embedded in AI responses. 21 +# Matches [SEARCH_RESULTS_REF:sr_xxxxxxxx] tokens embedded in AI responses.
21 # Case-insensitive, optional spaces around the id. 22 # Case-insensitive, optional spaces around the id.
22 -SEARCH_REF_PATTERN = re.compile(r"\[SEARCH_REF:\s*([a-zA-Z0-9_]+)\s*\]", re.IGNORECASE) 23 +SEARCH_RESULTS_REF_PATTERN = re.compile(r"\[SEARCH_RESULTS_REF:\s*([a-zA-Z0-9_]+)\s*\]", re.IGNORECASE)
23 24
24 # Configure logging 25 # Configure logging
25 logging.basicConfig( 26 logging.basicConfig(
@@ -28,6 +29,10 @@ logging.basicConfig( @@ -28,6 +29,10 @@ logging.basicConfig(
28 ) 29 )
29 logger = logging.getLogger(__name__) 30 logger = logging.getLogger(__name__)
30 31
  32 +# In-memory image cache (url or "local:path" -> PIL Image), max 100 entries
  33 +_IMAGE_CACHE: OrderedDict = OrderedDict()
  34 +_IMAGE_CACHE_MAX = 100
  35 +
31 # Page config 36 # Page config
32 st.set_page_config( 37 st.set_page_config(
33 page_title="ShopAgent", 38 page_title="ShopAgent",
@@ -455,6 +460,7 @@ def _build_reference_prefix(products: list[dict]) -&gt; str: @@ -455,6 +460,7 @@ def _build_reference_prefix(products: list[dict]) -&gt; str:
455 return "\n".join(lines) 460 return "\n".join(lines)
456 461
457 462
  463 +@st.fragment
458 def render_referenced_products_in_input() -> None: 464 def render_referenced_products_in_input() -> None:
459 """Render referenced products above chat input, each with remove button.""" 465 """Render referenced products above chat input, each with remove button."""
460 refs = st.session_state.get("referenced_products", []) 466 refs = st.session_state.get("referenced_products", [])
@@ -485,46 +491,45 @@ def render_referenced_products_in_input() -&gt; None: @@ -485,46 +491,45 @@ def render_referenced_products_in_input() -&gt; None:
485 491
486 492
487 def _load_product_image(product: ProductItem) -> Optional[Image.Image]: 493 def _load_product_image(product: ProductItem) -> Optional[Image.Image]:
488 - """Try to load a product image: image_url from API (normalized when stored) → local data/images → None.""" 494 + """Load product image with cache: image_url or local data/images. Cache key = url or 'local:path'."""
  495 + cache_key: Optional[str] = None
489 if product.image_url: 496 if product.image_url:
  497 + cache_key = product.image_url
  498 + if cache_key in _IMAGE_CACHE:
  499 + _IMAGE_CACHE.move_to_end(cache_key)
  500 + return _IMAGE_CACHE[cache_key]
490 try: 501 try:
  502 + import io
491 import requests 503 import requests
492 resp = requests.get(product.image_url, timeout=10) 504 resp = requests.get(product.image_url, timeout=10)
493 if resp.status_code == 200: 505 if resp.status_code == 200:
494 - import io  
495 - return Image.open(io.BytesIO(resp.content)) 506 + img = Image.open(io.BytesIO(resp.content))
  507 + _IMAGE_CACHE[cache_key] = img
  508 + _IMAGE_CACHE.move_to_end(cache_key)
  509 + if len(_IMAGE_CACHE) > _IMAGE_CACHE_MAX:
  510 + _IMAGE_CACHE.popitem(last=False)
  511 + return img
496 except Exception as e: 512 except Exception as e:
497 logger.debug(f"Remote image fetch failed for {product.spu_id}: {e}") 513 logger.debug(f"Remote image fetch failed for {product.spu_id}: {e}")
498 514
499 local = Path(f"data/images/{product.spu_id}.jpg") 515 local = Path(f"data/images/{product.spu_id}.jpg")
500 if local.exists(): 516 if local.exists():
  517 + cache_key = f"local:{local}"
  518 + if cache_key in _IMAGE_CACHE:
  519 + _IMAGE_CACHE.move_to_end(cache_key)
  520 + return _IMAGE_CACHE[cache_key]
501 try: 521 try:
502 - return Image.open(local) 522 + img = Image.open(local)
  523 + _IMAGE_CACHE[cache_key] = img
  524 + _IMAGE_CACHE.move_to_end(cache_key)
  525 + if len(_IMAGE_CACHE) > _IMAGE_CACHE_MAX:
  526 + _IMAGE_CACHE.popitem(last=False)
  527 + return img
503 except Exception as e: 528 except Exception as e:
504 logger.debug(f"Local image load failed {local}: {e}") 529 logger.debug(f"Local image load failed {local}: {e}")
505 return None 530 return None
506 531
507 532
508 -def _run_similar_search(query: str) -> Optional[str]:  
509 - """Run product search with query, register result, return new ref_id or None."""  
510 - if not query or not query.strip():  
511 - return None  
512 - from app.tools.search_tools import make_search_products_tool  
513 -  
514 - session_id = st.session_state.get("session_id", "")  
515 - if not session_id:  
516 - return None  
517 - tool = make_search_products_tool(session_id, global_registry)  
518 - try:  
519 - out = tool.invoke({"query": query.strip()})  
520 - match = SEARCH_REF_PATTERN.search(out)  
521 - if match:  
522 - return match.group(1).strip()  
523 - except Exception as e:  
524 - logger.warning(f"Similar search failed: {e}")  
525 - return None  
526 -  
527 -  
528 def display_product_card_from_item( 533 def display_product_card_from_item(
529 product: ProductItem, 534 product: ProductItem,
530 ref_id: str, 535 ref_id: str,
@@ -584,19 +589,11 @@ def display_product_card_from_item( @@ -584,19 +589,11 @@ def display_product_card_from_item(
584 589
585 if similar_clicked: 590 if similar_clicked:
586 search_query = (product.title or "").strip() or "商品" 591 search_query = (product.title or "").strip() or "商品"
587 - new_ref = _run_similar_search(search_query)  
588 - if new_ref:  
589 - st.session_state.side_panel = {  
590 - "visible": True,  
591 - "mode": "similar",  
592 - "payload": {"ref_id": new_ref, "query": search_query},  
593 - }  
594 - else:  
595 - st.session_state.side_panel = {  
596 - "visible": True,  
597 - "mode": "similar",  
598 - "payload": {"ref_id": None, "query": search_query, "error": True},  
599 - } 592 + st.session_state.side_panel = {
  593 + "visible": True,
  594 + "mode": "similar",
  595 + "payload": {"query": search_query, "loading": True},
  596 + }
600 st.rerun() 597 st.rerun()
601 598
602 if is_checked: 599 if is_checked:
@@ -608,7 +605,7 @@ def display_product_card_from_item( @@ -608,7 +605,7 @@ def display_product_card_from_item(
608 605
609 def render_search_result_block(result: SearchResult, widget_prefix: str = "") -> None: 606 def render_search_result_block(result: SearchResult, widget_prefix: str = "") -> None:
610 """ 607 """
611 - Render a full search result block in place of a [SEARCH_REF:ref_id] token. 608 + Render a full search result block in place of a [SEARCH_RESULTS_REF:ref_id] token.
612 609
613 widget_prefix: unique per (message, ref block) so Streamlit widget keys stay unique. 610 widget_prefix: unique per (message, ref block) so Streamlit widget keys stay unique.
614 """ 611 """
@@ -649,12 +646,12 @@ def render_message_with_refs( @@ -649,12 +646,12 @@ def render_message_with_refs(
649 msg_index: int = 0, 646 msg_index: int = 0,
650 ) -> None: 647 ) -> None:
651 """ 648 """
652 - Render an assistant message that may contain [SEARCH_REF:ref_id] tokens. 649 + Render an assistant message that may contain [SEARCH_RESULTS_REF:ref_id] tokens.
653 650
654 msg_index: message index in chat, used to keep widget keys unique across messages. 651 msg_index: message index in chat, used to keep widget keys unique across messages.
655 """ 652 """
656 fallback_refs = fallback_refs or {} 653 fallback_refs = fallback_refs or {}
657 - parts = SEARCH_REF_PATTERN.split(content) 654 + parts = SEARCH_RESULTS_REF_PATTERN.split(content)
658 655
659 for i, segment in enumerate(parts): 656 for i, segment in enumerate(parts):
660 if i % 2 == 0: 657 if i % 2 == 0:
@@ -730,7 +727,7 @@ def display_message(message: dict, msg_index: int = 0): @@ -730,7 +727,7 @@ def display_message(message: dict, msg_index: int = 0):
730 727
731 st.markdown("---") 728 st.markdown("---")
732 729
733 - # Render message: expand [SEARCH_REF:ref_id] tokens into product card blocks 730 + # Render message: expand [SEARCH_RESULTS_REF:ref_id] tokens into product card blocks
734 session_id = st.session_state.get("session_id", "") 731 session_id = st.session_state.get("session_id", "")
735 render_message_with_refs( 732 render_message_with_refs(
736 content, session_id, fallback_refs=message.get("search_refs"), msg_index=msg_index 733 content, session_id, fallback_refs=message.get("search_refs"), msg_index=msg_index
@@ -739,6 +736,7 @@ def display_message(message: dict, msg_index: int = 0): @@ -739,6 +736,7 @@ def display_message(message: dict, msg_index: int = 0):
739 st.markdown("</div>", unsafe_allow_html=True) 736 st.markdown("</div>", unsafe_allow_html=True)
740 737
741 738
  739 +@st.fragment
742 def render_bottom_actions_bar() -> None: 740 def render_bottom_actions_bar() -> None:
743 """Show Ask and Compare when there are selected products. Disabled when none selected.""" 741 """Show Ask and Compare when there are selected products. Disabled when none selected."""
744 selected = st.session_state.selected_products 742 selected = st.session_state.selected_products
@@ -784,53 +782,72 @@ def render_side_drawer() -&gt; None: @@ -784,53 +782,72 @@ def render_side_drawer() -&gt; None:
784 body_html = "" 782 body_html = ""
785 783
786 if mode == "similar": 784 if mode == "similar":
787 - ref_id = payload.get("ref_id")  
788 - query = html.escape(payload.get("query", ""))  
789 - if payload.get("error") or not ref_id:  
790 - body_html = '<p style="color:#666;">搜索失败或暂无结果。</p>' 785 + query = html.escape((payload.get("query") or ""))
  786 + if payload.get("loading"):
  787 + body_html = '<p style="color:#666;">加载中…</p>'
  788 + elif payload.get("products") is not None:
  789 + to_show = payload["products"][:12]
  790 + cards = []
  791 + for product in to_show:
  792 + p_title = html.escape((product.title or "未知商品")[:80])
  793 + price = (
  794 + f"¥{product.price:.2f}"
  795 + if product.price is not None
  796 + else "价格待更新"
  797 + )
  798 + image_html = (
  799 + f'<img src="{html.escape(product.image_url)}" alt="{p_title}" '
  800 + 'style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid #eee;" />'
  801 + if product.image_url
  802 + else '<div style="width:64px;height:64px;background:#f5f5f5;border-radius:8px;'
  803 + 'display:flex;align-items:center;justify-content:center;color:#bbb;">🛍️</div>'
  804 + )
  805 + cards.append(
  806 + '<div style="display:flex;gap:10px;border:1px solid #eee;border-radius:10px;'
  807 + 'padding:10px;background:#fff;">'
  808 + f"{image_html}"
  809 + '<div style="flex:1;min-width:0;">'
  810 + f'<div style="font-weight:600;color:#111;line-height:1.35;">{p_title}</div>'
  811 + f'<div style="font-size:0.9rem;color:#555;margin-top:4px;">{price}</div>'
  812 + "</div></div>"
  813 + )
  814 + cards_html = "".join(cards) if cards else '<p style="color:#666;">(未找到可展示的商品)</p>'
  815 + body_html = (
  816 + f'<div style="font-size:0.92rem;color:#555;margin-bottom:10px;">'
  817 + f'基于「{query}」的搜索结果:</div>'
  818 + '<div style="display:grid;gap:10px;">' + cards_html + "</div>"
  819 + )
791 else: 820 else:
792 - result = global_registry.get(session_id, ref_id)  
793 - if not result:  
794 - body_html = f'<p style="color:#666;">[搜索结果 {html.escape(ref_id)} 不可用]</p>'  
795 - else:  
796 - perfect = [p for p in result.products if p.match_label == "Relevant"]  
797 - partial = [p for p in result.products if p.match_label == "Partially Relevant"]  
798 - to_show = (perfect + partial)[:12] if perfect else partial[:12]  
799 - cards = []  
800 - for product in to_show:  
801 - p_title = html.escape((product.title or "未知商品")[:80])  
802 - p_label = html.escape(product.match_label or "Partially Relevant")  
803 - price = (  
804 - f"¥{product.price:.2f}"  
805 - if product.price is not None  
806 - else "价格待更新"  
807 - )  
808 - image_html = (  
809 - f'<img src="{html.escape(product.image_url)}" alt="{p_title}" '  
810 - 'style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid #eee;" />'  
811 - if product.image_url  
812 - else '<div style="width:64px;height:64px;background:#f5f5f5;border-radius:8px;'  
813 - 'display:flex;align-items:center;justify-content:center;color:#bbb;">🛍️</div>' 821 + # Legacy: ref_id from registry (e.g. from chat)
  822 + ref_id = payload.get("ref_id")
  823 + if ref_id:
  824 + result = global_registry.get(session_id, ref_id)
  825 + if result:
  826 + to_show = (result.products or [])[:12]
  827 + cards = []
  828 + for product in to_show:
  829 + p_title = html.escape((product.title or "未知商品")[:80])
  830 + price = f"¥{product.price:.2f}" if product.price is not None else "价格待更新"
  831 + image_html = (
  832 + f'<img src="{html.escape(product.image_url)}" alt="{p_title}" '
  833 + 'style="width:64px;height:64px;object-fit:cover;border-radius:8px;border:1px solid #eee;" />'
  834 + if product.image_url
  835 + else '<div style="width:64px;height:64px;background:#f5f5f5;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#bbb;">🛍️</div>'
  836 + )
  837 + cards.append(
  838 + '<div style="display:flex;gap:10px;border:1px solid #eee;border-radius:10px;padding:10px;background:#fff;">'
  839 + f"{image_html}"
  840 + f'<div style="flex:1;min-width:0;"><div style="font-weight:600;color:#111;">{p_title}</div>'
  841 + f'<div style="font-size:0.9rem;color:#555;">{price}</div></div></div>'
  842 + )
  843 + body_html = (
  844 + f'<div style="font-size:0.92rem;color:#555;margin-bottom:10px;">基于「{query}」的搜索结果:</div>'
  845 + '<div style="display:grid;gap:10px;">' + "".join(cards) + "</div>"
814 ) 846 )
815 - cards.append(  
816 - '<div style="display:flex;gap:10px;border:1px solid #eee;border-radius:10px;'  
817 - 'padding:10px;background:#fff;">'  
818 - f"{image_html}"  
819 - '<div style="flex:1;min-width:0;">'  
820 - f'<div style="font-weight:600;color:#111;line-height:1.35;">{p_title}</div>'  
821 - f'<div style="font-size:0.9rem;color:#555;margin-top:4px;">{price}</div>'  
822 - f'<div style="font-size:0.8rem;color:#777;margin-top:4px;">{p_label}</div>'  
823 - "</div></div>"  
824 - )  
825 -  
826 - cards_html = "".join(cards) if cards else '<p style="color:#666;">(未找到可展示的商品)</p>'  
827 - body_html = (  
828 - f'<div style="font-size:0.92rem;color:#555;margin-bottom:10px;">'  
829 - f'基于「{query}」的搜索结果:</div>'  
830 - '<div style="display:grid;gap:10px;">'  
831 - f"{cards_html}"  
832 - "</div>"  
833 - ) 847 + else:
  848 + body_html = f'<p style="color:#666;">[搜索结果 {html.escape(ref_id)} 不可用]</p>'
  849 + else:
  850 + body_html = '<p style="color:#666;">搜索失败或暂无结果。</p>'
834 else: 851 else:
835 items = payload if isinstance(payload, list) else [] 852 items = payload if isinstance(payload, list) else []
836 if items: 853 if items:
@@ -953,6 +970,20 @@ def main(): @@ -953,6 +970,20 @@ def main():
953 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} 970 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None}
954 st.query_params.clear() 971 st.query_params.clear()
955 972
  973 + # "Similar" panel: if loading, run API-only search and rerun
  974 + panel = st.session_state.side_panel
  975 + if panel.get("visible") and panel.get("mode") == "similar":
  976 + payload = panel.get("payload") or {}
  977 + if payload.get("loading") and payload.get("query"):
  978 + from app.tools.search_tools import search_products_api_only
  979 + products = search_products_api_only(payload["query"], limit=12)
  980 + st.session_state.side_panel["payload"] = {
  981 + "query": payload["query"],
  982 + "products": products,
  983 + "loading": False,
  984 + }
  985 + st.rerun()
  986 +
956 # Drawer rendered early so fixed positioning works from top of DOM 987 # Drawer rendered early so fixed positioning works from top of DOM
957 render_side_drawer() 988 render_side_drawer()
958 989
@@ -968,13 +999,12 @@ def main(): @@ -968,13 +999,12 @@ def main():
968 ) 999 )
969 1000
970 # Sidebar (collapsed by default, but accessible) 1001 # Sidebar (collapsed by default, but accessible)
971 - with st.sidebar: 1002 + @st.fragment
  1003 + def _sidebar_fragment():
972 st.markdown("### ⚙️ Settings") 1004 st.markdown("### ⚙️ Settings")
973 -  
974 if st.button("🗑️ Clear Chat", width="stretch"): 1005 if st.button("🗑️ Clear Chat", width="stretch"):
975 if "shopping_agent" in st.session_state: 1006 if "shopping_agent" in st.session_state:
976 st.session_state.shopping_agent.clear_history() 1007 st.session_state.shopping_agent.clear_history()
977 - # Clear search result registry for this session  
978 session_id = st.session_state.get("session_id", "") 1008 session_id = st.session_state.get("session_id", "")
979 if session_id: 1009 if session_id:
980 global_registry.clear_session(session_id) 1010 global_registry.clear_session(session_id)
@@ -984,8 +1014,6 @@ def main(): @@ -984,8 +1014,6 @@ def main():
984 st.session_state.referenced_products = [] 1014 st.session_state.referenced_products = []
985 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None} 1015 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None}
986 st.rerun() 1016 st.rerun()
987 -  
988 - # Debug toggle  
989 st.markdown("---") 1017 st.markdown("---")
990 st.checkbox( 1018 st.checkbox(
991 "显示调试过程 (debug)", 1019 "显示调试过程 (debug)",
@@ -993,17 +1021,25 @@ def main(): @@ -993,17 +1021,25 @@ def main():
993 value=True, 1021 value=True,
994 help="展开后可查看中间思考过程及工具调用详情", 1022 help="展开后可查看中间思考过程及工具调用详情",
995 ) 1023 )
996 -  
997 st.markdown("---") 1024 st.markdown("---")
998 st.caption(f"Session: `{st.session_state.session_id[:8]}...`") 1025 st.caption(f"Session: `{st.session_state.session_id[:8]}...`")
999 1026
  1027 + with st.sidebar:
  1028 + _sidebar_fragment()
  1029 +
  1030 + MAX_MESSAGES = 50
1000 messages_container = st.container() 1031 messages_container = st.container()
1001 with messages_container: 1032 with messages_container:
1002 if not st.session_state.messages: 1033 if not st.session_state.messages:
1003 display_welcome() 1034 display_welcome()
1004 else: 1035 else:
1005 - for msg_idx, message in enumerate(st.session_state.messages):  
1006 - display_message(message, msg_index=msg_idx) 1036 + messages = st.session_state.messages
  1037 + start_idx = max(0, len(messages) - MAX_MESSAGES)
  1038 + to_show = messages[start_idx:]
  1039 + if len(messages) > MAX_MESSAGES:
  1040 + st.caption(f"(仅显示最近 {MAX_MESSAGES} 条,共 {len(messages)} 条消息)")
  1041 + for i, message in enumerate(to_show):
  1042 + display_message(message, msg_index=start_idx + i)
1007 render_bottom_actions_bar() 1043 render_bottom_actions_bar()
1008 1044
1009 # Fixed input area at bottom (using container to simulate fixed position) 1045 # Fixed input area at bottom (using container to simulate fixed position)
app/agents/shopping_agent.py
@@ -4,7 +4,7 @@ Conversational Shopping Agent with LangGraph @@ -4,7 +4,7 @@ Conversational Shopping Agent with LangGraph
4 Architecture: 4 Architecture:
5 - ReAct-style agent: plan → search → evaluate → re-plan or respond 5 - ReAct-style agent: plan → search → evaluate → re-plan or respond
6 - search_products is session-bound, writing curated results to SearchResultRegistry 6 - search_products is session-bound, writing curated results to SearchResultRegistry
7 -- Final AI message references results via [SEARCH_REF:ref_id] tokens instead of 7 +- Final AI message references results via [SEARCH_RESULTS_REF:ref_id] tokens instead of
8 re-listing product details; the UI renders product cards from the registry 8 re-listing product details; the UI renders product cards from the registry
9 """ 9 """
10 10
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
34 # Key design decisions: 34 # Key design decisions:
35 # 1. Guides multi-query search planning with explicit evaluate-and-decide loop 35 # 1. Guides multi-query search planning with explicit evaluate-and-decide loop
36 # 2. Forbids re-listing product details in the final response 36 # 2. Forbids re-listing product details in the final response
37 -# 3. Mandates [SEARCH_REF:ref_id] inline citation as the only product presentation mechanism 37 +# 3. Mandates [SEARCH_RESULTS_REF:ref_id] inline citation as the only product presentation mechanism
38 SYSTEM_PROMPT = f"""角色定义 38 SYSTEM_PROMPT = f"""角色定义
39 你是我们店铺的一名专业的电商导购,是一个善于倾听、主动引导、懂得搭配的“时尚顾问”,通过有温度的对话,给用户提供有价值的信息,包括需求引导、方案推荐、搜索结果推荐,最终促成满意的购物决策或转化行为。 39 你是我们店铺的一名专业的电商导购,是一个善于倾听、主动引导、懂得搭配的“时尚顾问”,通过有温度的对话,给用户提供有价值的信息,包括需求引导、方案推荐、搜索结果推荐,最终促成满意的购物决策或转化行为。
40 作为我们店铺的一名专业的销售,除了本店铺的商品的推荐,你可以给用户提供有帮助的信息,但是不要虚构商品、提供本商店搜索结果以外的商品。 40 作为我们店铺的一名专业的销售,除了本店铺的商品的推荐,你可以给用户提供有帮助的信息,但是不要虚构商品、提供本商店搜索结果以外的商品。
@@ -49,12 +49,12 @@ SYSTEM_PROMPT = f&quot;&quot;&quot;角色定义 @@ -49,12 +49,12 @@ SYSTEM_PROMPT = f&quot;&quot;&quot;角色定义
49 4. 选项驱动式澄清:推荐几个清晰的方向,呈现方案或商品搜索结果,再做澄清 49 4. 选项驱动式澄清:推荐几个清晰的方向,呈现方案或商品搜索结果,再做澄清
50 5. 单轮对话最好只提一个问题,最多两个,禁止多问题堆叠。 50 5. 单轮对话最好只提一个问题,最多两个,禁止多问题堆叠。
51 6. 站在用户立场思考:比如询问用户期待的效果或感觉、使用的场合、想解决的问题,而不是询问具体的款式、参数,你需要将用户表达的需求翻译为具体可检索的商品特征(版型、材质、设计元素、风格标签等),并据此筛选商品、组织推荐逻辑。 51 6. 站在用户立场思考:比如询问用户期待的效果或感觉、使用的场合、想解决的问题,而不是询问具体的款式、参数,你需要将用户表达的需求翻译为具体可检索的商品特征(版型、材质、设计元素、风格标签等),并据此筛选商品、组织推荐逻辑。
52 -2. 如何使用make_search_products_tool 52 +2. 如何使用search_products
53 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。 53 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。
54 2. 可以根据搜索结果调整搜索策略:每次调用 search_products 后,工具会返回搜索结果的相关性的判断、以及搜索结果的topN的title,你需要决策是否要调整搜索策略,比如结果质量太差,可能需要调整搜索词、或者加大试探的query数量(不要超过3-5个)。结果太差的原因有可能是你生成的query不合理、请根据你看到的商品名称的构成组织搜索关键词。 54 2. 可以根据搜索结果调整搜索策略:每次调用 search_products 后,工具会返回搜索结果的相关性的判断、以及搜索结果的topN的title,你需要决策是否要调整搜索策略,比如结果质量太差,可能需要调整搜索词、或者加大试探的query数量(不要超过3-5个)。结果太差的原因有可能是你生成的query不合理、请根据你看到的商品名称的构成组织搜索关键词。
55 -3. 在最终回复中使用 [SEARCH_REF:ref_id] 内联引用搜索结果:  
56 - 1. 搜索工具会返回一个结果引用标识[SEARCH_REF:ref_id],撰写最终答复的时候请直接引用 [SEARCH_REF:ref_id] ,系统会自动在该位置渲染对应的商品卡片列表,无需复述搜索结果。  
57 - 2. 因为系统会自动将[SEARCH_REF:ref_id]渲染为搜索结果,所以[SEARCH_REF:ref_id]必须独占一行,且只在需要渲染该query完整的搜索结果时才进行引用,同一个结果不要重复引用。 55 +3. 在最终回复中使用 [SEARCH_RESULTS_REF:ref_id] 内联引用搜索结果:
  56 + 1. 搜索工具会返回一个结果引用标识[SEARCH_RESULTS_REF:ref_id],撰写最终答复的时候请直接引用 [SEARCH_RESULTS_REF:ref_id] ,系统会自动在该位置渲染对应的商品卡片列表,无需复述搜索结果。
  57 + 2. 因为系统会自动将[SEARCH_RESULTS_REF:ref_id]渲染为搜索结果,所以[SEARCH_RESULTS_REF:ref_id]必须独占一行,且只在需要渲染该query完整的搜索结果时才进行引用,同一个结果不要重复引用。
58 4. 今天是{datetime.now().strftime("%Y-%m-%d")},所有与当前时间(比如天气、最新或即将发生的事件)相关的问题,都要使用web_search工具)。 58 4. 今天是{datetime.now().strftime("%Y-%m-%d")},所有与当前时间(比如天气、最新或即将发生的事件)相关的问题,都要使用web_search工具)。
59 """ 59 """
60 60
@@ -72,8 +72,8 @@ SYSTEM_PROMPT___2 = &quot;&quot;&quot; 角色定义 @@ -72,8 +72,8 @@ SYSTEM_PROMPT___2 = &quot;&quot;&quot; 角色定义
72 2. 如何使用make_search_products_tool: 72 2. 如何使用make_search_products_tool:
73 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。 73 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。
74 2. 可以根据搜索结果调整搜索策略:每次调用 search_products 后,工具会返回搜索结果的相关性的判断、以及搜索结果的topN的title,你需要决策是否要调整搜索策略,比如结果质量太差,可能需要调整搜索词、或者加大试探的query数量(不要超过3-5个)。 74 2. 可以根据搜索结果调整搜索策略:每次调用 search_products 后,工具会返回搜索结果的相关性的判断、以及搜索结果的topN的title,你需要决策是否要调整搜索策略,比如结果质量太差,可能需要调整搜索词、或者加大试探的query数量(不要超过3-5个)。
75 - 3. 使用 [SEARCH_REF:ref_id] 内联引用搜索结果:搜索工具会返回一个结果引用标识[SEARCH_REF:ref_id],撰写最终答复的时候可以直接引用将 [SEARCH_REF:ref_id] ,系统会自动在该位置渲染对应的商品卡片列表,无需复述搜索结果。  
76 - 4. 因为系统会自动将[SEARCH_REF:ref_id]渲染为搜索结果,所以只在需要渲染该query完整的搜索结果时才进行引用,同一个结果不要重复引用。 75 + 3. 使用 [SEARCH_RESULTS_REF:ref_id] 内联引用搜索结果:搜索工具会返回一个结果引用标识[SEARCH_RESULTS_REF:ref_id],撰写最终答复的时候可以直接引用将 [SEARCH_RESULTS_REF:ref_id] ,系统会自动在该位置渲染对应的商品卡片列表,无需复述搜索结果。
  76 + 4. 因为系统会自动将[SEARCH_RESULTS_REF:ref_id]渲染为搜索结果,所以只在需要渲染该query完整的搜索结果时才进行引用,同一个结果不要重复引用。
77 """ 77 """
78 78
79 79
@@ -226,7 +226,7 @@ class ShoppingAgent: @@ -226,7 +226,7 @@ class ShoppingAgent:
226 226
227 Returns: 227 Returns:
228 dict with keys: 228 dict with keys:
229 - response – final AI message text (may contain [SEARCH_REF:ref_id] tokens) 229 + response – final AI message text (may contain [SEARCH_RESULTS_REF:ref_id] tokens)
230 tool_calls – list of {name, args, result_preview} 230 tool_calls – list of {name, args, result_preview}
231 debug_steps – detailed per-node step log 231 debug_steps – detailed per-node step log
232 search_refs – dict[ref_id → SearchResult] for all searches this turn 232 search_refs – dict[ref_id → SearchResult] for all searches this turn
app/search_registry.py
@@ -2,20 +2,16 @@ @@ -2,20 +2,16 @@
2 Search Result Registry 2 Search Result Registry
3 3
4 Stores structured search results keyed by session and ref_id. 4 Stores structured search results keyed by session and ref_id.
5 -Each [SEARCH_REF:ref_id] in an AI response maps to a SearchResult stored here, 5 +Each [SEARCH_RESULTS_REF:ref_id] 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. 6 allowing the UI to render product cards without the LLM ever re-listing them.
  7 +
  8 +ref_id uses session-scoped auto-increment (sr_1, sr_2, ...).
7 """ 9 """
8 10
9 -import uuid  
10 from dataclasses import dataclass, field 11 from dataclasses import dataclass, field
11 from typing import Optional 12 from typing import Optional
12 13
13 14
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 15 @dataclass
20 class ProductItem: 16 class ProductItem:
21 """A single product extracted from a search result, enriched with a match label.""" 17 """A single product extracted from a search result, enriched with a match label."""
@@ -38,7 +34,7 @@ class SearchResult: @@ -38,7 +34,7 @@ class SearchResult:
38 """ 34 """
39 A complete, self-contained search result block. 35 A complete, self-contained search result block.
40 36
41 - Identified by ref_id (e.g. 'sr_3f9a1b2c'). 37 + Identified by ref_id (e.g. 'sr_1', 'sr_2' — session-scoped auto-increment).
42 Stores the query, LLM quality assessment, and the curated product list 38 Stores the query, LLM quality assessment, and the curated product list
43 (only "Relevant" and "Partially Relevant" items — "Irrelevant" are discarded). 39 (only "Relevant" and "Partially Relevant" items — "Irrelevant" are discarded).
44 """ 40 """
@@ -68,11 +64,17 @@ class SearchResultRegistry: @@ -68,11 +64,17 @@ class SearchResultRegistry:
68 64
69 Lives as a global singleton in the process; Streamlit reruns preserve it 65 Lives as a global singleton in the process; Streamlit reruns preserve it
70 as long as the worker process is alive. Session isolation is maintained 66 as long as the worker process is alive. Session isolation is maintained
71 - by keying on session_id. 67 + by keying on session_id. ref_id is per-session auto-increment (sr_1, sr_2, ...).
72 """ 68 """
73 69
74 def __init__(self) -> None: 70 def __init__(self) -> None:
75 self._store: dict[str, dict[str, SearchResult]] = {} 71 self._store: dict[str, dict[str, SearchResult]] = {}
  72 + self._session_counter: dict[str, int] = {}
  73 +
  74 + def next_ref_id(self, session_id: str) -> str:
  75 + """Return next ref_id for this session (sr_1, sr_2, ...)."""
  76 + self._session_counter[session_id] = self._session_counter.get(session_id, 0) + 1
  77 + return f"sr_{self._session_counter[session_id]}"
76 78
77 def register(self, session_id: str, result: SearchResult) -> str: 79 def register(self, session_id: str, result: SearchResult) -> str:
78 """Store a SearchResult and return its ref_id.""" 80 """Store a SearchResult and return its ref_id."""
@@ -92,6 +94,7 @@ class SearchResultRegistry: @@ -92,6 +94,7 @@ class SearchResultRegistry:
92 def clear_session(self, session_id: str) -> None: 94 def clear_session(self, session_id: str) -> None:
93 """Remove all search results for a session (e.g. on chat clear).""" 95 """Remove all search results for a session (e.g. on chat clear)."""
94 self._store.pop(session_id, None) 96 self._store.pop(session_id, None)
  97 + self._session_counter.pop(session_id, None)
95 98
96 99
97 # ── Global singleton ────────────────────────────────────────────────────────── 100 # ── Global singleton ──────────────────────────────────────────────────────────
app/tools/search_tools.py
@@ -3,7 +3,7 @@ Search Tools for Product Discovery @@ -3,7 +3,7 @@ Search Tools for Product Discovery
3 3
4 - search_products is created via make_search_products_tool(session_id, registry). 4 - search_products is created via make_search_products_tool(session_id, registry).
5 - After search API, an LLM labels each result as Relevant / Partially Relevant / Irrelevant; we count and 5 - After search API, an LLM labels each result as Relevant / Partially Relevant / Irrelevant; we count and
6 - store the curated list in the registry, return [SEARCH_REF:ref_id] + quality counts + top10 titles. 6 + store the curated list in the registry, return [SEARCH_RESULTS_REF:ref_id] + quality counts + top10 titles.
7 """ 7 """
8 8
9 import base64 9 import base64
@@ -23,7 +23,6 @@ from app.search_registry import ( @@ -23,7 +23,6 @@ from app.search_registry import (
23 SearchResult, 23 SearchResult,
24 SearchResultRegistry, 24 SearchResultRegistry,
25 global_registry, 25 global_registry,
26 - new_ref_id,  
27 ) 26 )
28 27
29 logger = logging.getLogger(__name__) 28 logger = logging.getLogger(__name__)
@@ -123,6 +122,125 @@ labels 数组长度必须等于 {n}。&quot;&quot;&quot; @@ -123,6 +122,125 @@ labels 数组长度必须等于 {n}。&quot;&quot;&quot;
123 return ["Partially Relevant"] * n, "" 122 return ["Partially Relevant"] * n, ""
124 123
125 124
  125 +# ── Shared search implementation ──────────────────────────────────────────────
  126 +
  127 +def _call_search_api(query: str, size: int) -> Optional[tuple[list, int]]:
  128 + """Call product search API. Returns (raw_results, total_hits) or None on failure."""
  129 + if not query or not query.strip():
  130 + return None
  131 + try:
  132 + url = f"{settings.search_api_base_url.rstrip('/')}/search/"
  133 + headers = {
  134 + "Content-Type": "application/json",
  135 + "X-Tenant-ID": settings.search_api_tenant_id,
  136 + }
  137 + payload = {
  138 + "query": query.strip(),
  139 + "size": min(max(size, 1), 20),
  140 + "from": 0,
  141 + "language": "zh",
  142 + "enable_rerank": True,
  143 + "rerank_query_template": query.strip(),
  144 + "rerank_doc_template": "{title}",
  145 + }
  146 + resp = requests.post(url, json=payload, headers=headers, timeout=60)
  147 + if resp.status_code != 200:
  148 + logger.warning(f"Search API {resp.status_code}: {resp.text[:200]}")
  149 + return None
  150 + data = resp.json()
  151 + raw_results: list = data.get("results", [])
  152 + total_hits: int = data.get("total", 0)
  153 + return (raw_results, total_hits)
  154 + except Exception as e:
  155 + logger.warning(f"Search API error: {e}")
  156 + return None
  157 +
  158 +
  159 +def _raw_to_product_items(
  160 + raw_results: list, labels: Optional[list[str]] = None
  161 +) -> list[ProductItem]:
  162 + """Build ProductItem list from API raw results. If labels given, filter by Relevant/Partially Relevant."""
  163 + if labels is not None:
  164 + valid = {"Relevant", "Partially Relevant"}
  165 + return [
  166 + ProductItem(
  167 + spu_id=str(r.get("spu_id", "")),
  168 + title=r.get("title") or "",
  169 + price=r.get("price"),
  170 + category_path=r.get("category_path") or r.get("category_name"),
  171 + vendor=r.get("vendor"),
  172 + image_url=_normalize_image_url(r.get("image_url")),
  173 + relevance_score=r.get("relevance_score"),
  174 + match_label=label,
  175 + tags=r.get("tags") or [],
  176 + specifications=r.get("specifications") or [],
  177 + )
  178 + for r, label in zip(raw_results, labels)
  179 + if label in valid
  180 + ]
  181 + return [
  182 + ProductItem(
  183 + spu_id=str(r.get("spu_id", "")),
  184 + title=r.get("title") or "",
  185 + price=r.get("price"),
  186 + category_path=r.get("category_path") or r.get("category_name"),
  187 + vendor=r.get("vendor"),
  188 + image_url=_normalize_image_url(r.get("image_url")),
  189 + relevance_score=r.get("relevance_score"),
  190 + match_label="Partially Relevant",
  191 + tags=r.get("tags") or [],
  192 + specifications=r.get("specifications") or [],
  193 + )
  194 + for r in raw_results
  195 + ]
  196 +
  197 +
  198 +def search_products_impl(
  199 + query: str,
  200 + limit: int = 20,
  201 + *,
  202 + assess_quality: bool = True,
  203 + session_id: Optional[str] = None,
  204 + registry: Optional[SearchResultRegistry] = None,
  205 +) -> tuple[Optional[str], list[ProductItem], int]:
  206 + """
  207 + Single implementation: call API, optionally run LLM assessment, optionally register.
  208 + Returns (ref_id_or_none, products, assessed_count). assessed_count = len(raw_results) when assess_quality else 0.
  209 + """
  210 + out = _call_search_api(query, limit)
  211 + if not out:
  212 + return (None, [], 0)
  213 + raw_results, total_hits = out
  214 + if not raw_results:
  215 + return (None, [], 0)
  216 +
  217 + if assess_quality and session_id and registry:
  218 + labels, quality_summary = _assess_search_quality(query, raw_results)
  219 + products = _raw_to_product_items(raw_results, labels)
  220 + perfect_count = sum(1 for l in labels if l == "Relevant")
  221 + partial_count = sum(1 for l in labels if l == "Partially Relevant")
  222 + ref_id = registry.next_ref_id(session_id)
  223 + result = SearchResult(
  224 + ref_id=ref_id,
  225 + query=query,
  226 + total_api_hits=total_hits,
  227 + returned_count=len(raw_results),
  228 + perfect_count=perfect_count,
  229 + partial_count=partial_count,
  230 + irrelevant_count=len(labels) - perfect_count - partial_count,
  231 + quality_summary=quality_summary,
  232 + products=products,
  233 + )
  234 + registry.register(session_id, result)
  235 + logger.info(
  236 + "[%s] Registered %s: query=%s perfect=%s partial=%s",
  237 + session_id, ref_id, query, perfect_count, partial_count,
  238 + )
  239 + return (ref_id, products, len(raw_results))
  240 + products = _raw_to_product_items(raw_results)
  241 + return (None, products, 0)
  242 +
  243 +
126 # ── Tool factory ─────────────────────────────────────────────────────────────── 244 # ── Tool factory ───────────────────────────────────────────────────────────────
127 245
128 def make_search_products_tool( 246 def make_search_products_tool(
@@ -131,12 +249,7 @@ def make_search_products_tool( @@ -131,12 +249,7 @@ def make_search_products_tool(
131 ): 249 ):
132 """ 250 """
133 Return a search_products tool bound to a specific session and registry. 251 Return a search_products tool bound to a specific session and registry.
134 -  
135 - The tool:  
136 - 1. Calls the product search API.  
137 - 2. Runs LLM quality assessment on up to 20 results.  
138 - 3. Stores a SearchResult in the registry.  
139 - 4. Returns a concise quality summary + [SEARCH_REF:ref_id]. 252 + Uses LLM assessment and registers result; returns [SEARCH_RESULTS_REF:ref_id] string.
140 """ 253 """
141 254
142 @tool 255 @tool
@@ -147,100 +260,34 @@ def make_search_products_tool( @@ -147,100 +260,34 @@ def make_search_products_tool(
147 query: 自然语言商品描述 260 query: 自然语言商品描述
148 261
149 Returns: 262 Returns:
150 - 【搜索完成】+ 结果引用 [SEARCH_REF:ref_id] + 质量情况(评估条数、Relevant/Partially Relevant 数)+ results list(top10 标题) 263 + 【搜索完成】+ 结果引用 [SEARCH_RESULTS_REF:ref_id] + 质量情况 + results list(top10 标题)
151 """ 264 """
152 try: 265 try:
153 limit = min(max(settings.search_products_limit, 1), 20) 266 limit = min(max(settings.search_products_limit, 1), 20)
154 logger.info(f"[{session_id}] search_products: query={query!r} limit={limit}") 267 logger.info(f"[{session_id}] search_products: query={query!r} limit={limit}")
155 -  
156 - url = f"{settings.search_api_base_url.rstrip('/')}/search/"  
157 - headers = {  
158 - "Content-Type": "application/json",  
159 - "X-Tenant-ID": settings.search_api_tenant_id,  
160 - }  
161 - payload = {  
162 - "query": query,  
163 - "size": limit,  
164 - "from": 0,  
165 - "language": "zh",  
166 - "enable_rerank": True,  
167 - "rerank_query_template": query,  
168 - "rerank_doc_template": "{title}",  
169 - }  
170 -  
171 - resp = requests.post(url, json=payload, headers=headers, timeout=60)  
172 - if resp.status_code != 200:  
173 - logger.error(f"Search API error {resp.status_code}: {resp.text[:300]}")  
174 - return f"搜索失败:API 返回状态码 {resp.status_code},请稍后重试。"  
175 -  
176 - data = resp.json()  
177 - raw_results: list = data.get("results", [])  
178 - total_hits: int = data.get("total", 0)  
179 -  
180 - if not raw_results:  
181 - return (  
182 - f"【搜索完成】query='{query}'\n"  
183 - "未找到匹配商品,建议换用更宽泛或不同角度的关键词重新搜索。"  
184 - )  
185 -  
186 - labels, quality_summary = _assess_search_quality(query, raw_results)  
187 - perfect_count = sum(1 for l in labels if l == "Relevant")  
188 - partial_count = sum(1 for l in labels if l == "Partially Relevant")  
189 - irrelevant_count = len(labels) - perfect_count - partial_count  
190 -  
191 - products: list[ProductItem] = []  
192 - for raw, label in zip(raw_results, labels):  
193 - if label not in ("Relevant", "Partially Relevant"):  
194 - continue  
195 - products.append(  
196 - ProductItem(  
197 - spu_id=str(raw.get("spu_id", "")),  
198 - title=raw.get("title") or "",  
199 - price=raw.get("price"),  
200 - category_path=(  
201 - raw.get("category_path") or raw.get("category_name")  
202 - ),  
203 - vendor=raw.get("vendor"),  
204 - image_url=_normalize_image_url(raw.get("image_url")),  
205 - relevance_score=raw.get("relevance_score"),  
206 - match_label=label,  
207 - tags=raw.get("tags") or [],  
208 - specifications=raw.get("specifications") or [],  
209 - )  
210 - )  
211 -  
212 - ref_id = new_ref_id()  
213 - result = SearchResult(  
214 - ref_id=ref_id,  
215 - query=query,  
216 - total_api_hits=total_hits,  
217 - returned_count=len(raw_results),  
218 - perfect_count=perfect_count,  
219 - partial_count=partial_count,  
220 - irrelevant_count=irrelevant_count,  
221 - quality_summary=quality_summary,  
222 - products=products,  
223 - )  
224 - registry.register(session_id, result)  
225 - assessed_n = len(raw_results)  
226 - logger.info(  
227 - "[%s] Registered %s: query=%s assessed=%s perfect=%s partial=%s",  
228 - session_id, ref_id, query, assessed_n, perfect_count, partial_count, 268 + ref_id, products, assessed_n = search_products_impl(
  269 + query, limit,
  270 + assess_quality=True,
  271 + session_id=session_id,
  272 + registry=registry,
229 ) 273 )
230 -  
231 - top10_titles = [  
232 - (raw.get("title") or "未知")[:80]  
233 - for raw in raw_results[:10]  
234 - ] 274 + if ref_id is None:
  275 + if not products:
  276 + return (
  277 + f"【搜索完成】query='{query}'\n"
  278 + "未找到匹配商品,建议换用更宽泛或不同角度的关键词重新搜索。"
  279 + )
  280 + return f"搜索失败:API 返回异常,请稍后重试。"
  281 + perfect_count = sum(1 for p in products if p.match_label == "Relevant")
  282 + partial_count = sum(1 for p in products if p.match_label == "Partially Relevant")
  283 + top10_titles = [(p.title or "未知")[:80] for p in products[:10]]
235 results_list = "\n".join(f"{i}. {t}" for i, t in enumerate(top10_titles, 1)) 284 results_list = "\n".join(f"{i}. {t}" for i, t in enumerate(top10_titles, 1))
236 -  
237 return ( 285 return (
238 f"【搜索完成】query='{query}'\n" 286 f"【搜索完成】query='{query}'\n"
239 - f"结果引用:[SEARCH_REF:{ref_id}]\n" 287 + f"结果引用:[SEARCH_RESULTS_REF:{ref_id}]\n"
240 f"搜索结果质量情况:评估总条数{assessed_n}条,Relevant {perfect_count} 条,Partially Relevant {partial_count} 条。\n" 288 f"搜索结果质量情况:评估总条数{assessed_n}条,Relevant {perfect_count} 条,Partially Relevant {partial_count} 条。\n"
241 f"results list:\n{results_list}" 289 f"results list:\n{results_list}"
242 ) 290 )
243 -  
244 except requests.exceptions.RequestException as e: 291 except requests.exceptions.RequestException as e:
245 logger.error(f"[{session_id}] Search network error: {e}", exc_info=True) 292 logger.error(f"[{session_id}] Search network error: {e}", exc_info=True)
246 return f"搜索失败(网络错误):{e}" 293 return f"搜索失败(网络错误):{e}"
@@ -251,6 +298,12 @@ def make_search_products_tool( @@ -251,6 +298,12 @@ def make_search_products_tool(
251 return search_products 298 return search_products
252 299
253 300
  301 +def search_products_api_only(query: str, limit: int = 12) -> list[ProductItem]:
  302 + """API-only search (no LLM assessment). For 'Similar products' side panel."""
  303 + _, products, _ = search_products_impl(query, limit, assess_quality=False)
  304 + return products
  305 +
  306 +
254 # ── Standalone tools (no session binding needed) ─────────────────────────────── 307 # ── Standalone tools (no session binding needed) ───────────────────────────────
255 308
256 @tool 309 @tool