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>
README_prompts.md
... ... @@ -43,7 +43,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊
43 43  
44 44  
45 45  
46   -请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
  46 +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_RESULTS_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
47 47 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。
48 48 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。
49 49 下方也悬浮两个菜单,一个ask,一个compare。
... ... @@ -66,7 +66,7 @@ graphRAG在商品搜索中如何使用?我想将他用于,对商品的模糊
66 66  
67 67  
68 68  
69   -请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
  69 +请帮我补充一个功能:前端,对于渲染的每一个商品([SEARCH_RESULTS_REF:xxx]渲染的搜索结果),鼠标hover上去的时候,悬浮可供点击的两个东西:
70 70 1. Similar products: 点击后,从右侧拉出、覆盖大部分页面、保留部分背景(我不知道这种技术叫什么,实现类似效果即可)展现的内容是,以该商品未标题,发起商品搜索,页面展示搜索结果。
71 71 2. 一个勾选框,点击后为勾选状态。可以勾选多个商品。
72 72 下方也悬浮两个菜单,一个ask,一个compare。
... ...
... ... @@ -7,6 +7,7 @@ import html
7 7 import logging
8 8 import re
9 9 import uuid
  10 +from collections import OrderedDict
10 11 from pathlib import Path
11 12 from typing import Any, Optional
12 13  
... ... @@ -17,9 +18,9 @@ from PIL import Image, ImageOps
17 18 from app.agents.shopping_agent import ShoppingAgent
18 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 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 25 # Configure logging
25 26 logging.basicConfig(
... ... @@ -28,6 +29,10 @@ logging.basicConfig(
28 29 )
29 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 36 # Page config
32 37 st.set_page_config(
33 38 page_title="ShopAgent",
... ... @@ -455,6 +460,7 @@ def _build_reference_prefix(products: list[dict]) -&gt; str:
455 460 return "\n".join(lines)
456 461  
457 462  
  463 +@st.fragment
458 464 def render_referenced_products_in_input() -> None:
459 465 """Render referenced products above chat input, each with remove button."""
460 466 refs = st.session_state.get("referenced_products", [])
... ... @@ -485,46 +491,45 @@ def render_referenced_products_in_input() -&gt; None:
485 491  
486 492  
487 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 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 501 try:
  502 + import io
491 503 import requests
492 504 resp = requests.get(product.image_url, timeout=10)
493 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 512 except Exception as e:
497 513 logger.debug(f"Remote image fetch failed for {product.spu_id}: {e}")
498 514  
499 515 local = Path(f"data/images/{product.spu_id}.jpg")
500 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 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 528 except Exception as e:
504 529 logger.debug(f"Local image load failed {local}: {e}")
505 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 533 def display_product_card_from_item(
529 534 product: ProductItem,
530 535 ref_id: str,
... ... @@ -584,19 +589,11 @@ def display_product_card_from_item(
584 589  
585 590 if similar_clicked:
586 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 597 st.rerun()
601 598  
602 599 if is_checked:
... ... @@ -608,7 +605,7 @@ def display_product_card_from_item(
608 605  
609 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 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 646 msg_index: int = 0,
650 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 651 msg_index: message index in chat, used to keep widget keys unique across messages.
655 652 """
656 653 fallback_refs = fallback_refs or {}
657   - parts = SEARCH_REF_PATTERN.split(content)
  654 + parts = SEARCH_RESULTS_REF_PATTERN.split(content)
658 655  
659 656 for i, segment in enumerate(parts):
660 657 if i % 2 == 0:
... ... @@ -730,7 +727,7 @@ def display_message(message: dict, msg_index: int = 0):
730 727  
731 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 731 session_id = st.session_state.get("session_id", "")
735 732 render_message_with_refs(
736 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 736 st.markdown("</div>", unsafe_allow_html=True)
740 737  
741 738  
  739 +@st.fragment
742 740 def render_bottom_actions_bar() -> None:
743 741 """Show Ask and Compare when there are selected products. Disabled when none selected."""
744 742 selected = st.session_state.selected_products
... ... @@ -784,53 +782,72 @@ def render_side_drawer() -&gt; None:
784 782 body_html = ""
785 783  
786 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 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 851 else:
835 852 items = payload if isinstance(payload, list) else []
836 853 if items:
... ... @@ -953,6 +970,20 @@ def main():
953 970 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None}
954 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 987 # Drawer rendered early so fixed positioning works from top of DOM
957 988 render_side_drawer()
958 989  
... ... @@ -968,13 +999,12 @@ def main():
968 999 )
969 1000  
970 1001 # Sidebar (collapsed by default, but accessible)
971   - with st.sidebar:
  1002 + @st.fragment
  1003 + def _sidebar_fragment():
972 1004 st.markdown("### ⚙️ Settings")
973   -
974 1005 if st.button("🗑️ Clear Chat", width="stretch"):
975 1006 if "shopping_agent" in st.session_state:
976 1007 st.session_state.shopping_agent.clear_history()
977   - # Clear search result registry for this session
978 1008 session_id = st.session_state.get("session_id", "")
979 1009 if session_id:
980 1010 global_registry.clear_session(session_id)
... ... @@ -984,8 +1014,6 @@ def main():
984 1014 st.session_state.referenced_products = []
985 1015 st.session_state.side_panel = {"visible": False, "mode": None, "payload": None}
986 1016 st.rerun()
987   -
988   - # Debug toggle
989 1017 st.markdown("---")
990 1018 st.checkbox(
991 1019 "显示调试过程 (debug)",
... ... @@ -993,17 +1021,25 @@ def main():
993 1021 value=True,
994 1022 help="展开后可查看中间思考过程及工具调用详情",
995 1023 )
996   -
997 1024 st.markdown("---")
998 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 1031 messages_container = st.container()
1001 1032 with messages_container:
1002 1033 if not st.session_state.messages:
1003 1034 display_welcome()
1004 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 1043 render_bottom_actions_bar()
1008 1044  
1009 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 4 Architecture:
5 5 - ReAct-style agent: plan → search → evaluate → re-plan or respond
6 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 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 34 # Key design decisions:
35 35 # 1. Guides multi-query search planning with explicit evaluate-and-decide loop
36 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 38 SYSTEM_PROMPT = f"""角色定义
39 39 你是我们店铺的一名专业的电商导购,是一个善于倾听、主动引导、懂得搭配的“时尚顾问”,通过有温度的对话,给用户提供有价值的信息,包括需求引导、方案推荐、搜索结果推荐,最终促成满意的购物决策或转化行为。
40 40 作为我们店铺的一名专业的销售,除了本店铺的商品的推荐,你可以给用户提供有帮助的信息,但是不要虚构商品、提供本商店搜索结果以外的商品。
... ... @@ -49,12 +49,12 @@ SYSTEM_PROMPT = f&quot;&quot;&quot;角色定义
49 49 4. 选项驱动式澄清:推荐几个清晰的方向,呈现方案或商品搜索结果,再做澄清
50 50 5. 单轮对话最好只提一个问题,最多两个,禁止多问题堆叠。
51 51 6. 站在用户立场思考:比如询问用户期待的效果或感觉、使用的场合、想解决的问题,而不是询问具体的款式、参数,你需要将用户表达的需求翻译为具体可检索的商品特征(版型、材质、设计元素、风格标签等),并据此筛选商品、组织推荐逻辑。
52   -2. 如何使用make_search_products_tool
  52 +2. 如何使用search_products
53 53 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。
54 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 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 72 2. 如何使用make_search_products_tool:
73 73 1. 可以生成多个query进行搜索:在需要搜索商品的时候,可以将需求分解为 2-4 个搜索查询,每个 query 聚焦一个明确的商品子类或搜索角度。
74 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 226  
227 227 Returns:
228 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 230 tool_calls – list of {name, args, result_preview}
231 231 debug_steps – detailed per-node step log
232 232 search_refs – dict[ref_id → SearchResult] for all searches this turn
... ...
app/search_registry.py
... ... @@ -2,20 +2,16 @@
2 2 Search Result Registry
3 3  
4 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 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 11 from dataclasses import dataclass, field
11 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 15 @dataclass
20 16 class ProductItem:
21 17 """A single product extracted from a search result, enriched with a match label."""
... ... @@ -38,7 +34,7 @@ class SearchResult:
38 34 """
39 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 38 Stores the query, LLM quality assessment, and the curated product list
43 39 (only "Relevant" and "Partially Relevant" items — "Irrelevant" are discarded).
44 40 """
... ... @@ -68,11 +64,17 @@ class SearchResultRegistry:
68 64  
69 65 Lives as a global singleton in the process; Streamlit reruns preserve it
70 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 70 def __init__(self) -> None:
75 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 79 def register(self, session_id: str, result: SearchResult) -> str:
78 80 """Store a SearchResult and return its ref_id."""
... ... @@ -92,6 +94,7 @@ class SearchResultRegistry:
92 94 def clear_session(self, session_id: str) -> None:
93 95 """Remove all search results for a session (e.g. on chat clear)."""
94 96 self._store.pop(session_id, None)
  97 + self._session_counter.pop(session_id, None)
95 98  
96 99  
97 100 # ── Global singleton ──────────────────────────────────────────────────────────
... ...
app/tools/search_tools.py
... ... @@ -3,7 +3,7 @@ Search Tools for Product Discovery
3 3  
4 4 - search_products is created via make_search_products_tool(session_id, registry).
5 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 9 import base64
... ... @@ -23,7 +23,6 @@ from app.search_registry import (
23 23 SearchResult,
24 24 SearchResultRegistry,
25 25 global_registry,
26   - new_ref_id,
27 26 )
28 27  
29 28 logger = logging.getLogger(__name__)
... ... @@ -123,6 +122,125 @@ labels 数组长度必须等于 {n}。&quot;&quot;&quot;
123 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 244 # ── Tool factory ───────────────────────────────────────────────────────────────
127 245  
128 246 def make_search_products_tool(
... ... @@ -131,12 +249,7 @@ def make_search_products_tool(
131 249 ):
132 250 """
133 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 255 @tool
... ... @@ -147,100 +260,34 @@ def make_search_products_tool(
147 260 query: 自然语言商品描述
148 261  
149 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 265 try:
153 266 limit = min(max(settings.search_products_limit, 1), 20)
154 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 284 results_list = "\n".join(f"{i}. {t}" for i, t in enumerate(top10_titles, 1))
236   -
237 285 return (
238 286 f"【搜索完成】query='{query}'\n"
239   - f"结果引用:[SEARCH_REF:{ref_id}]\n"
  287 + f"结果引用:[SEARCH_RESULTS_REF:{ref_id}]\n"
240 288 f"搜索结果质量情况:评估总条数{assessed_n}条,Relevant {perfect_count} 条,Partially Relevant {partial_count} 条。\n"
241 289 f"results list:\n{results_list}"
242 290 )
243   -
244 291 except requests.exceptions.RequestException as e:
245 292 logger.error(f"[{session_id}] Search network error: {e}", exc_info=True)
246 293 return f"搜索失败(网络错误):{e}"
... ... @@ -251,6 +298,12 @@ def make_search_products_tool(
251 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 307 # ── Standalone tools (no session binding needed) ───────────────────────────────
255 308  
256 309 @tool
... ...