Back to Hello Agents

Main

Co-creation-projects/jack6249-GiftGeniusAgent/main.ipynb

1.0.223.8 KB
Original Source

GiftGeniusAgent——你的送礼智能Agent

项目简介

本项目演示一个基于HelloAgents框架的智能送礼Agent

作者信息

  • 姓名:张善祺
  • GitHub:@jack6249
  • 日期:2025-11-21

第1部分:环境配置

python
#导入库和参数配置
from hello_agents import SimpleAgent, HelloAgentsLLM, ReflectionAgent, ToolRegistry
from hello_agents.tools import Tool, ToolParameter
from typing import Dict, Any, List
from tavily import TavilyClient
import os
import json
import re
import numpy as np 
from dotenv import load_dotenv
import asyncio
import nest_asyncio
from mcp.client.sse import sse_client
from mcp.client.session import ClientSession

load_dotenv()

#LLM参数
LLM_MODEL_ID = os.getenv("LLM_MODEL_ID")
LLM_API_KEY = os.getenv("LLM_API_KEY")
LLM_BASE_URL = os.getenv("LLM_BASE_URL")
#Tavily参数
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY","")
#百度MCP参数
BAIDU_TOKEN = os.getenv("BAIDU_MCP_TOKEN","")
#输入json路径配置
INPUT_FILENAME = "data/test_cases.json"

# 搜索源配置
# 可选值: "tavily" (通用/海外) 或 "baidu" (电商/国内)
os.environ["SEARCH_PROVIDER"] = "baidu" 

print("✅ 环境配置完成")

第2部分:定义工具

python
# [Cell 2 终极版] 定义统一搜索工具 (兼容 Tavily 和 Baidu)
# 允许 Jupyter 运行异步
nest_asyncio.apply()

class BatchSearchTool(Tool):
    def __init__(self):
        super().__init__(
            name="batch_search",
            description="统一搜索工具,支持 Tavily 和 Baidu 切换。"
        )
        self.provider = os.environ.get("SEARCH_PROVIDER", "tavily").lower()

    def run(self, parameters: Any) -> str:
        return "请使用 Python 代码直接调用 search_raw 方法获取数据。"

    def search_raw(self, query: str) -> List[Dict]:
        if self.provider == "baidu":
            return self._search_baidu(query)
        else:
            return self._search_tavily(query)

    # --- 引擎 A: Tavily ---
    def _search_tavily(self, query: str) -> List[Dict]:
        api_key = os.environ.get("TAVILY_API_KEY")
        if not api_key: return []
        print(f"    🚀 [Tavily] 正在搜索: {query} ...")
        try:
            tavily = TavilyClient(api_key=api_key)
            response = tavily.search(query, max_results=5, include_images=True)
            results = []
            if 'results' in response:
                for r in response['results']:
                    results.append({
                        "title": r['title'], "url": r['url'], "content": r['content'], 
                        "type": "text", "img": "" # Tavily 文本通常不带图
                    })
            if 'images' in response and response['images']:
                results.append({"images": response['images'][:3], "type": "image"})
            return results
        except Exception as e:
            print(f"      ⚠️ Tavily 异常: {e}")
            return []

    # --- 引擎 B: Baidu MCP ---
    def _search_baidu(self, query: str) -> List[Dict]:
        token = os.environ.get("BAIDU_MCP_TOKEN")
        if not token: return []
        print(f"    🐼 [百度优选] 正在搜索: {query} ...")
        try:
            raw_json_str = asyncio.run(self._async_baidu_call(query, token))
            print(f"      🔍 原始 JSON 响应: {raw_json_str}")
            return self._parse_baidu_response(raw_json_str)
        except Exception as e:
            print(f"      ⚠️ 百度 MCP 异常: {e}")
            return []

    async def _async_baidu_call(self, query: str, token: str) -> str:
        sse_url = f"https://mcp-youxuan.baidu.com/mcp/sse?key={token}"
        async with sse_client(sse_url) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                result = await session.call_tool("goods_search", arguments={"query": query})
                return result.content[0].text if result.content else ""

    def _parse_baidu_response(self, json_str: str) -> List[Dict]:
        results = []; images = []
        try:
            data = json.loads(json_str)
            items = data if isinstance(data, list) else []
            
            for item in items[:5]:
                title = item.get("goodsName") or item.get("title") or "未知商品"
                price = item.get("price") or item.get("minPrice") or ""
                shop = item.get("shopName") or item.get("mall") or ""
                url = item.get("detailUrl") or item.get("url") or item.get("ori_url") or "#"
                img = item.get("imgUrl") or item.get("picUrl") or item.get("img")
                
                content = f"价格: {price}元。店铺: {shop}。商品详情: {title}"
                
                # 📝【修复点】直接在 text 类型结果里绑定 img
                results.append({
                    "title": title, "url": url, "content": content, 
                    "type": "text", "img": img 
                })
                if img: images.append(img)
            
            if images: results.append({"images": images[:3], "type": "image"})
                
        except json.JSONDecodeError:
            print("      ⚠️ 百度返回非 JSON 数据")
        return results

    def get_parameters(self):
        return [ToolParameter(name="query", type="string", description="关键词")]

tool_registry = ToolRegistry()
tool_registry.register_tool(BatchSearchTool())

print("✅ 统一搜索工具已加载!")
print(f"当前模式: {'百度优选 (电商)' if os.environ.get('SEARCH_PROVIDER') == 'baidu' else 'Tavily (通用)'}")

第3部分:创建智能体

python
# 初始化大模型
llm = HelloAgentsLLM()

# --- 1. 军师 (Profiler) - 已升级支持多维度画像 ---
PROFILER_PROMPT = """
你是一个精通 MBTI 人格分析与消费市场趋势的 "送礼军师"。
你的任务是根据用户提供的多维度画像,制定 3 个**极度精准**的搜索关键词。

【⚠️ 时效性死命令 (CRITICAL)】
当前时间视作 **2025年11月**。
1. **严禁过时**:绝对不要推荐 2024 年或更早的旧款(除非是经典恒久款如黑胶唱片)。
2. **价格尺度**:给出的价格单位是人民币元。请严格遵照范围进行联想,禁止超出预算范围。

为了确保推荐质量,请参考以下的【优秀思考范例】:

### 范例 1
**用户画像**: 
- 女, 26岁, ISFP (探险家), 金牛座
- 预算: 500-1000元
- 场景: 情人节
- 自定义: 喜欢有质感的生活小物
**军师分析**: 
ISFP 重视审美和感官体验,金牛座喜欢实实在在的质感。情人节需要浪漫。
**生成策略**:
1. 观夏 (To Summer) 昆仑煮雪 晶石香薰 (符合质感与审美)
2. 野兽派 2025 情人节限定 睡衣礼盒 (金牛座喜欢的舒适)
3. 富士 Instax mini Evo 拍立得 (记录生活瞬间)

### 范例 2
**用户画像**: 
- 男, 30岁, INTJ (建筑师), 处女座
- 预算: 1000元以上
- 场景: 生日
- 自定义: 程序员,喜欢整洁
**军师分析**: 
INTJ 追求极致的逻辑和效率,处女座有洁癖,喜欢桌面整洁。
**生成策略**:
1. Keychron Q1 Pro 机械键盘 铝坨坨 (符合极客对工具的追求)
2. 明基 (BenQ) ScreenBar Halo 屏幕挂灯 (极致护眼与桌面美学)
3. 赫曼米勒 (Herman Miller) 显示器支架 (人体工学)

### 范例 3
**用户画像**: 
- 女, 20岁, ENFP (竞选者), 狮子座
- 预算: 300元以内
- 场景: 圣诞节
- 自定义: 喜欢二次元,痛包
**军师分析**: 
ENFP 热情奔放,狮子座喜欢张扬、闪亮的东西。预算有限但要素多。
**生成策略**:
1. 泡泡玛特 圣诞系列 盲盒整端 (符合节日气氛和二次元)
2. WEGO 痛包 镭射款 (符合自定义需求,狮子座喜欢的亮眼)
3.  Chiikawa 吉伊卡哇 圣诞公仔 (当下顶流二次元IP)

---

**现在的任务**:
请根据以下【当前用户画像】进行分析,模仿上述范例的深度,制定搜索策略。

【当前用户画像】
{user_profile_text}

【关键词生成要求】
1. **必须具体**:格式为 `[品牌] + [产品名/系列] + [限定/属性]`。
2. **拒绝大词**:严禁搜索 "礼物"、"口红"、"玩具" 这种泛词。
3. **必须包含品牌**:根据预算推断合适的品牌(如:预算低选名创优品/泡泡玛特,预算高选Dior/索尼)。

【输出格式】
只输出 3 行搜索关键词,每行一个。不要输出分析过程,不要序号。
"""

profiler_agent = SimpleAgent(
    llm=llm,
    name="Agent_Profiler",
    system_prompt=PROFILER_PROMPT
)

# ==============================================================================
# 2. 种草达人 (Pitcher) - 文案创作 (加入风格指导)
# ==============================================================================
PITCHER_PROMPT = """
你是一个 **金牌种草文案**。
用户会给你一个 **【商品名称】**。

### 🎯 关键要点 (Few Points)
1.  **痛点直击**: 一句话说清楚为什么买它(限定?显白?绝美?)。
2.  **情绪价值**: 使用 "绝绝子", "氛围感", "心动" 等高频热词。
3.  **字数限制**: 严格控制在 **40字以内**,短小精悍。
4.  **Emoji**: 必须包含 1-2 个 emoji。

### 🌟 创作范例 (Few-Shot)
**输入**: Dior 999 烈艳蓝金
**输出**: 💄本宫不死终是妃!Dior 999 传奇正红,显白更有气场,送女友绝对没错!

**输入**: 泡泡玛特 Labubu 坐坐派对
**输出**: ✨太可爱了吧!Labubu 坐坐派对系列,每一个都丑萌到心巴上,摆在桌上超治愈~

**输入**: 罗技 MX Master 3S
**输出**: 🖱️打工人本命!罗技 Master 3S 静音又顺滑,人体工学设计,手腕再也不累了。

---

**当前任务**:
请为【{input}】写一句朋友圈风格种草语。
"""
pitcher_agent = SimpleAgent(
    llm=llm,
    name="Agent_Pitcher",
    system_prompt=PITCHER_PROMPT
)

print("✅ 智能体初始化完成!")

第4部分:读取数据

python
def load_user_profile(filename):
    # 1. 检查文件是否存在
    if not os.path.exists(filename):
        print(f"⚠️ 未找到配置文件: {filename}")
        # 如果没有文件,将默认数据写入文件
        default_data = {
            "性别": "女",
            "年龄": "24岁",
            "MBTI": "ENFP",
            "星座": "天秤座",
            "预算": "500元以内",
            "节日": "恋爱一周年纪念日",
            "自定义": "喜欢二次元,平时喜欢喝咖啡,不要送太实用的家电"
        }
        with open(filename, "w", encoding="utf-8") as f:
            json.dump(default_data, f, ensure_ascii=False, indent=4)
        print(f"✅ 已自动生成默认配置文件,请修改 {filename} 后再次运行。")
        return default_data

    # 2. 读取文件内容
    try:
        with open(filename, "r", encoding="utf-8") as f:
            data = json.load(f)
        print(f"✅ 成功加载用户画像: {filename}")
        print(f"📋 内容预览: {json.dumps(data, ensure_ascii=False)}")
        return data
    except Exception as e:
        print(f"❌ 读取 JSON 失败: {e}")
        return {}

# 加载数据
user_input_data = load_user_profile(INPUT_FILENAME)

第5部分:生成礼物计划

python
def parse_budget_range(budget_str):
    """解析用户预算字符串,返回 (min, max)"""
    nums = [float(x) for x in re.findall(r'\d+', str(budget_str).replace(',', ''))]
    if not nums: return 0, 999999 
    if "以内" in budget_str or "以下" in budget_str: return 0, nums[0]
    if "以上" in budget_str: return nums[0], 999999
    if len(nums) >= 2: return min(nums), max(nums)
    return 0, nums[0]

def extract_all_prices(raw_results):
    """从搜索结果列表中提取所有有效的价格"""
    prices = []
    for res in raw_results:
        # 只处理文本类型的结果
        if res.get('type') == 'text':
            text = res.get('title', '') + " " + res.get('content', '')
            # 匹配 ¥, $, 元 等格式
            matches = re.findall(r'(?:¥|¥|\$|HK\$|NT\$)\s*(\d+(?:,\d{3})*(?:\.\d+)?)', text)
            for m in matches:
                val = float(m.replace(',', ''))
                # 过滤掉像年份(2025)或过小/过大的异常值
                if 10 < val < 100000 and val not in [2024, 2025, 2026]:
                    prices.append(val)
            # 备用正则:匹配 "xxx元"
            matches_yuan = re.findall(r'(\d+(?:,\d{3})*(?:\.\d+)?)\s*元', text)
            for m in matches_yuan:
                val = float(m.replace(',', ''))
                if 10 < val < 100000 and val not in [2024, 2025, 2026]:
                    prices.append(val)
    return prices


def find_best_product(hunter, profiler_agent, keyword, budget_min, budget_max):
    limit_upper = budget_max * 1.2
    limit_lower = budget_min * 0.8
    
    all_candidates = []
    current_kw = keyword
    
    # --- Round 1: 首次搜索 ---
    print(f"       🕵️ 第1次搜索: {current_kw} 价格")
    results_1 = hunter.search_raw(f"{current_kw} 价格")
    
    fallback_img = ""
    for r in results_1:
        if r.get('images'): 
            fallback_img = r['images'][0]
            break

    has_valid_info = False
    for res in results_1:
        if res.get('type') == 'text':
            has_valid_info = True
            p_vals = extract_all_prices([res])
            if p_vals:
                res['price_val'] = p_vals[0]
                # 📝【核心修复点1】记录当前结果所属的关键词
                res['source_kw'] = current_kw 
                all_candidates.append(res)
                
                if limit_lower <= p_vals[0] <= limit_upper:
                    if not res.get('img') and fallback_img: res['img'] = fallback_img
                    return res, f"约 {p_vals[0]}元", current_kw

    # --- 机制 3: 无数据防御 ---
    if not has_valid_info:
        print(f"       ⚠️ [机制3触发] 首次搜索无有效信息。")
        correction_prompt = f"原策略 '{current_kw}' 搜索结果为空,请推荐一个同品类但更热门的具体商品型号。只输出关键词。"
        new_kw = profiler_agent.run(correction_prompt).strip()
        print(f"       🔄 军师换词: {new_kw}")
        current_kw = new_kw
        
        results = hunter.search_raw(f"{current_kw} 价格")
        
        # 更新 fallback_img
        fallback_img = "" 
        for r in results:
            if r.get('images'): 
                fallback_img = r['images'][0]
                break
                
        for res in results:
            if res.get('type') == 'text':
                p_vals = extract_all_prices([res])
                if p_vals:
                    res['price_val'] = p_vals[0]
                    # 📝【核心修复点1】记录关键词
                    res['source_kw'] = current_kw
                    all_candidates.append(res)

    # --- 机制 1 & 2: 价格修正 ---
    avg_price = np.mean([c['price_val'] for c in all_candidates]) if all_candidates else 0
    
    if avg_price > 0:
        correction_prompt = ""
        if avg_price > limit_upper:
            print(f"       💸 [机制1触发] 均价 {int(avg_price)} > 上限 {int(limit_upper)},找平替...")
            correction_prompt = f"原策略 '{current_kw}' 均价约 {int(avg_price)}元,超预算 ({budget_max}元)。请推荐一个同品类更便宜的具体型号(平替)。只输出关键词。"
        elif avg_price < limit_lower:
            print(f"       📉 [机制2触发] 均价 {int(avg_price)} < 下限 {int(limit_lower)},找升级款...")
            correction_prompt = f"原策略 '{current_kw}' 均价约 {int(avg_price)}元,低于预算下限 ({budget_min}元)。请推荐一个同品类更高端的型号。只输出关键词。"
            
        if correction_prompt:
            new_kw = profiler_agent.run(correction_prompt).strip()
            print(f"       🔄 军师修正: {new_kw}")
            current_kw = new_kw
            
            results_2 = hunter.search_raw(f"{new_kw} 价格")
            
            # 更新 fallback_img
            fallback_img = "" 
            for r in results_2:
                if r.get('images'): 
                    fallback_img = r['images'][0]
                    break
            
            for res in results_2:
                if res.get('type') == 'text':
                    p_vals = extract_all_prices([res])
                    if p_vals:
                        res['price_val'] = p_vals[0]
                        # 📝【核心修复点1】记录关键词
                        res['source_kw'] = current_kw
                        all_candidates.append(res) 
                        
                        if limit_lower <= p_vals[0] <= limit_upper:
                            if not res.get('img') and fallback_img: res['img'] = fallback_img
                            tag = "(平替)" if avg_price > limit_upper else "(升级)"
                            return res, f"约 {p_vals[0]}{tag}", current_kw

    # --- 机制 4: 兜底防御 ---
    print("       ⚠️ [机制4触发] 启用强制兜底模式...")
    best_fallback = None
    status_msg = "暂无报价"
    
    if all_candidates:
        # 选离预算最近的
        target = (budget_min + budget_max) / 2
        best_fallback = sorted(all_candidates, key=lambda x: abs(x['price_val'] - target))[0]
        p = best_fallback['price_val']
        
        if p > limit_upper: status_msg = f"约 {p}元 (⚠️超预算)"
        elif p < limit_lower: status_msg = f"约 {p}元 (📉低于预算)"
        else: status_msg = f"约 {p}元"
        
    elif results_1:
        # 实在没数据,硬取第一条
        for res in results_1:
            if res.get('type') == 'text': 
                best_fallback = res
                # 兜底时如果也没价格,就用原始关键词
                best_fallback['source_kw'] = keyword 
                break
    
    if best_fallback:
        if not best_fallback.get('img') and fallback_img:
            best_fallback['img'] = fallback_img
        
        # 📝【核心修复点2】返回结果里记录的那个 source_kw,而不是当前的 current_kw
        final_name_to_use = best_fallback.get('source_kw', current_kw)
        
        return best_fallback, status_msg, final_name_to_use
        
    return None, "搜索失败", keyword
python
if not user_input_data:
    print("❌ 未加载用户数据")
else:
    # 0. 解析预算
    b_min, b_max = parse_budget_range(user_input_data.get('预算', ''))
    print(f"\n💰 预算范围: {b_min} - {b_max}元")

    # 1. 军师制定策略
    profile_text = "\n".join([f"- {k}: {v if v else '未知/不限'}" for k, v in user_input_data.items()])
    print(f"\n🚀 任务启动...\n{'-'*40}")
    print("\n🧠 [1/3] 军师正在制定初步策略...")
    search_strategy = profiler_agent.run(f"请根据以下用户画像制定搜索策略:\n\n{profile_text}")
    print(f"📝 策略: \n{search_strategy}")

    # 2. 准备循环
    keywords = [k.strip() for k in search_strategy.replace(",", ",").replace("\n", ",").split(',') if k.strip()]
    final_items = []
    hunter = BatchSearchTool()

    print(f"\n🔄 进入处理流程 (共 {len(keywords)} 个商品)...")

    for index, kw in enumerate(keywords):
        print(f"\n    👉 [商品 {index+1}/{len(keywords)}] 正在处理: {kw}")
        
        # 调用智能搜索函数 (传入 min 和 max)
        valid_result, price_status, final_kw = find_best_product(hunter, profiler_agent, kw, b_min, b_max)
        
        if not valid_result:
            print("       ❌ 彻底无数据,跳过。")
            continue
            
        # === 生成文案 ===
        product_name = valid_result.get('title', final_kw)
        
        print(f"       ✍️ 正在撰写文案: {product_name[:30]}...")
        pitch_prompt = f"""
        商品:{product_name}
        价格:{price_status}
        卖点片段:{valid_result.get('content', '')[:200]}...
        
        请写一句30字以内的种草文案。
        """
        pitch = pitcher_agent.run(pitch_prompt)
        
        final_items.append({
            "name": final_kw, 
            "title_full": product_name,
            "price": price_status,
            "desc": pitch.replace("\n", " ").strip(),
            "img": valid_result.get('img', ''),
            "link": valid_result.get('url', '')
        })
        print(f"       ✅ 已收录 (状态: {price_status})")

第6部分:输出礼物计划

python
# --- 4. 渲染与保存 ---
print(f"\n💾 正在生成最终报告...")

if not final_items:
    final_md = "很抱歉,网络搜索似乎出现了问题,未能获取到任何商品信息。"
else:
    table_header = "| 🎁 礼物名称 | 💰 价格 | ✨ 种草理由 | 🖼️ 图片/链接 |\n| :--- | :--- | :--- | :--- |\n"
    table_rows = []
    
    for item in final_items:
        # 1. 清洗文本字段 (防止 | 破坏表格)
        name = item.get('name', '未知').replace("|", "/")
        price = item.get('price', '暂无').replace("|", "/")
        desc = item.get('desc', '').replace("|", "/")
        
        # 2. 🚨【核心修复】清洗链接中的竖线
        # 百度/京东链接常包含 '|',必须替换为 '%7C',否则 Markdown 表格会炸
        raw_link = item.get('link', '#')
        safe_link = raw_link.replace("|", "%7C")
        
        raw_img = item.get('img', '')
        safe_img = raw_img.replace("|", "%7C")
        
        # 3. 构建媒体列
        if safe_img and safe_img.startswith("http"):
            # 图片链接套购买链接
            media = f"[![图]({safe_img})]({safe_link})"
        else:
            media = f"[点击购买]({safe_link})"
        
        # 4. 组装行 (注意名字上的链接也要用 safe_link)
        # 使用 strip() 去除可能的首尾空格
        row = f"| [{name}]({safe_link}) | {price} | {desc} | {media} |"
        table_rows.append(row)
        
    final_md = table_header + "\n".join(table_rows)
filename = "outputs/gift_plan_output.md"
# 确保输出目录存在
os.makedirs(os.path.dirname(filename), exist_ok=True)

with open(filename, "w", encoding="utf-8") as f:
    f.write(final_md)
print(f"🎉 任务完成!文件已保存: {os.path.abspath(filename)}")

第7部分:总结与展望

实现的功能

  • 基于用户输入的个人信息,生成符合预算的礼物建议
  • 支持用户自定义预算范围、节日、个人喜好等
  • 支持百度MCP和Tavily API双数据源,利用搜索引擎获取最新的商品信息和价格
  • 提供可视化的建议结果展示

遇到的挑战与解决方案

  • 大模型的“幻觉”问题(JSON格式错误/编造数据)
    • 解决方案:放弃让 LLM 直接生成最终数据。改为使用 Python 正则表达式 从搜索结果中暴力提取硬数据(价格、图片),仅让 LLM 负责生成文案。代码逻辑负责准确性,模型负责创造性。
  • 上下文过长导致提取失败
    • 解决方案:结合实际业务场景,分析各个阶段对上下文的要求,在搜索阶段限制返回长度。同时通过拆分 “硬数据流”(找参数)和 “软数据流”(找卖点),大幅降低单次上下文长度,提升响应速度。
  • 大模型推荐的礼品价格超出预算
    • 解决方案:引入检核机制。如果搜到的商品均价超预算,系统会自动呼叫“军师”重新制定“平替”策略,直到找到合适商品为止。
  • Agent传入的参数格式问题
    • 解决方案:在工具层兼容 Agent 传入的各种参数格式(JSON/字符串、逗号/换行符分隔),确保搜索指令不丢失。

未来改进方向

  • 前端交互:开发前端页面,替代目前的Notebook交互,提供更好的用户交互体验
  • 数据源深度集成:完全接入百度优选MCP 的比价与历史价格接口,获取更精准的实时价格和库存信息,实现“全网比价”
  • 丰富选项:增加更多的个人喜好选项,如喜欢的商品类型、品牌等