Commit 897b5ca9eb04b92d884a8c02861513580673c875
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>
Showing
5 changed files
with
299 additions
and
207 deletions
Show diff stats
README_prompts.md
| @@ -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]) -> str: | @@ -455,6 +460,7 @@ def _build_reference_prefix(products: list[dict]) -> 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() -> None: | @@ -485,46 +491,45 @@ def render_referenced_products_in_input() -> 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() -> None: | @@ -784,53 +782,72 @@ def render_side_drawer() -> 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"""角色定义 | @@ -49,12 +49,12 @@ SYSTEM_PROMPT = f"""角色定义 | ||
| 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 = """ 角色定义 | @@ -72,8 +72,8 @@ SYSTEM_PROMPT___2 = """ 角色定义 | ||
| 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}。""" | @@ -123,6 +122,125 @@ labels 数组长度必须等于 {n}。""" | ||
| 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 |