📘 **TELUS Agriculture & Consumer Goods** 如何通过 **Haystack Agents** 转变促销交易

使用工具发送新闻通讯的 Agent


🧑‍🍳 由 Stefano Fiorucci ( X, LinkedIn) 和 Tuana Celik ( X, LinkedIn) 演示

在这个食谱中,我们将构建一个带有 3 个工具的新闻邮件发送代理。

  • 一个从 Hacker News 获取热门帖子的工具。
  • 一个为特定受众创建新闻邮件的工具。
  • 一个可以发送电子邮件(通过 Gmail)的工具。

此笔记本已更新至 Haystack 2.9.0。此笔记本旧版本中的实验性功能已合并到 Haystack 核心包中。

📺 观看直播

安装依赖项

安装最新版本的 haystack-aitrafilatura

! pip install haystack-ai trafilatura

导入功能

在此演示中,我们使用了 Haystack 的最新功能:ToolToolInvoker,以及增强的 ChatMessageOpenAIChatGenerator

from typing import List
from trafilatura import fetch_url, extract
import requests
from getpass import getpass
import os

from haystack import Pipeline
from haystack.components.builders import ChatPromptBuilder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.components.tools import ToolInvoker
from haystack.tools import Tool

Hacker NewsFetcher 工具

在之前的文章和食谱中,我们展示了如何为 Haystack 创建一个名为 HackerNewsFetcher 的自定义组件。

在这里,我们做的事情非常相似,但我们将一个函数作为 Tool 来使用。

📚 使用自定义组件的 Hacker News 摘要

此工具需要 top_k 作为输入,并返回 Hacker News 上当前热门帖子的数量 🚀

def hacker_news_fetcher(top_k: int = 3):
    newest_list = requests.get(url='https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty')
    urls = []
    articles = []
    for id_ in newest_list.json()[0:top_k]:
        article = requests.get(url=f"https://hacker-news.firebaseio.com/v0/item/{id_}.json?print=pretty")
        if 'url' in article.json():
            urls.append(article.json()['url'])
        elif 'text' in article.json():
            articles.append(article.json()['text'])

    for url in urls:
        try:
            downloaded = fetch_url(url)
            text = extract(downloaded)
            if text is not None:
                articles.append(text[:500])
        except Exception as e:
            print(e)
            print(f"Couldn't download {url}, skipped")

    return articles
hacker_news_fetcher_tool = Tool(name="hacker_news_fetcher",
                                description="Fetch the top k articles from hacker news",
                                function=hacker_news_fetcher,
                                parameters={
                                    "type": "object",
                                    "properties": {
                                        "top_k": {
                                            "type": "integer",
                                            "description": "The number of articles to fetch"
                                        }
                                    },
                                })

新闻邮件生成管道和工具

对于新闻邮件生成工具,我们将创建一个 Haystack 管道,并让我们的管道本身成为一个工具。

我们的工具将需要以下输入:

  • articles:用于生成新闻邮件的内容。
  • target_people:我们要针对的受众,例如“工程师”可能是我们的目标受众。
  • n_words:我们希望将新闻邮件限制在的字数。
if not "OPENAI_API_KEY" in os.environ:
    os.environ["OPENAI_API_KEY"] = getpass("Enter your OpenAI API key: ")
Enter your OpenAI API key: ··········
template = [ChatMessage.from_user("""
Create a entertaining newsletter for {{target_people}} based on the following articles.
The newsletter should be well structured, with a unique angle and a maximum of {{n_words}} words.

Articles:
{% for article in articles %}
    {{ article }}
    ---
{% endfor %}
""")]

newsletter_pipe = Pipeline()
newsletter_pipe.add_component("prompt_builder", ChatPromptBuilder(template=template))
newsletter_pipe.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini"))
newsletter_pipe.connect("prompt_builder", "llm")
def newsletter_pipeline_func(articles: List[str], target_people: str = "programmers", n_words: int = 100):
    result = newsletter_pipe.run({"prompt_builder": {"articles": articles, "target_people": target_people, "n_words": n_words}})

    return {"reply": result["llm"]["replies"][0].text}

newsletter_tool = Tool(name="newsletter_generator",
                          description="Generate a newsletter based on some articles",
                            function=newsletter_pipeline_func,
                            parameters={
                                "type": "object",
                                "properties": {
                                    "articles": {
                                        "type": "array",
                                        "items": {
                                            "type": "string",
                                            "description": "The articles to base the newsletter on",
                                        }
                                    },
                                    "target_people": {
                                        "type": "string",
                                        "description": "The target audience for the newsletter",
                                    },
                                    "n_words": {
                                        "type": "integer",
                                        "description": "The number of words to summarize the newsletter to",
                                    }
                                },
                                "required": ["articles"],
                            })

发送电子邮件工具

在这里,我们创建了一个 Gmail 工具。您可以使用您的 Gmail 帐户登录,从而允许最终代理从您的电子邮件发送电子邮件给其他人。

⚠️ 注意:要使用 Gmail 工具,您必须为您的 Gmail 帐户创建一个应用密码,这将作为发件人。之后您可以删除此密码。

要配置我们的 email 工具,您需要提供以下关于发件人电子邮件帐户的信息 👇

if not "NAME" in os.environ:
    os.environ["NAME"] = input("What's your name? ")
if not "SENDER_EMAIL" in os.environ:
    os.environ["SENDER_EMAIL"] = getpass("Enter your Gmail e-mail: ")
if not "GMAIL_APP_PASSWORD" in os.environ:
    os.environ["GMAIL_APP_PASSWORD"] = getpass("Enter your Gmail App Password: ")

接下来,我们创建一个 Tool,它需要以下输入:

  • receiver:我们要发送电子邮件到的电子邮件地址。
  • body:电子邮件正文。
  • subject:电子邮件的主题行。
import smtplib, ssl
from email.mime.text import MIMEText

def send_email(receiver: str, body: str, subject: str):
  msg = MIMEText(body)
  sender_email = os.environ['SENDER_EMAIL']
  sender_name = os.environ['NAME']
  sender = f"{sender_name} <{sender_email}>"
  msg['Subject'] = subject
  msg['From'] = sender
  port = 465  # For SSL
  smtp_server = "smtp.gmail.com"
  password = os.environ["GMAIL_APP_PASSWORD"]
  context = ssl.create_default_context()
  with smtplib.SMTP_SSL(smtp_server, port, context=context) as server:
      server.login(sender_email, password)
      server.sendmail(sender_email, receiver, msg.as_string())
  return 'Email sent!'
email_tool = Tool(name="email",
                  description="Send emails with specific content",
                  function=send_email,
                  parameters={
                      "type": "object",
                      "properties": {
                          "receiver": {
                              "type": "string",
                              "description": "The email of the receiver"
                          },
                          "body": {
                              "type": "string",
                              "description": "The content of the email"
                          },
                          "subject": {
                              "type": "string",
                              "description": "The subject of the email"
                          }
                      },
                  })

新闻邮件发送聊天代理

现在,我们构建一个新闻邮件创建聊天代理,我们可以使用它来请求新闻邮件,以及将它们发送到指定的电子邮件地址。

chat_generator = OpenAIChatGenerator(tools=[hacker_news_fetcher_tool, newsletter_tool, email_tool])

tool_invoker = ToolInvoker(tools=[hacker_news_fetcher_tool, newsletter_tool, email_tool])

messages = [
        ChatMessage.from_system(
            """Prepare a tool call if needed, otherwise use your knowledge to respond to the user.
            If the invocation of a tool requires the result of another tool, prepare only one call at a time.

            Each time you receive the result of a tool call, ask yourself: "Am I done with the task?".
            If not and you need to invoke another tool, prepare the next tool call.
            If you are done, respond with just the final result."""
        )
    ]

while True:
    user_input = input("\n\nwaiting for input (type 'exit' or 'quit' to stop)\n🧑: ")
    if user_input.lower() == "exit" or user_input.lower() == "quit":
        break
    messages.append(ChatMessage.from_user(user_input))

    while True:
        print("⌛ iterating...")

        replies = chat_generator.run(messages=messages)["replies"]
        messages.extend(replies)

        # Check for tool calls and handle them
        if not replies[0].tool_calls:
            break
        tool_calls = replies[0].tool_calls

        # Print tool calls for debugging
        for tc in tool_calls:
            print("\n TOOL CALL:")
            print(f"\t{tc.id}")
            print(f"\t{tc.tool_name}")
            for k,v in tc.arguments.items():
                v_truncated = str(v)[:50]
                print(f"\t{k}: {v_truncated}{'' if len(v_truncated) == len(str(v)) else '...'}")

        tool_messages = tool_invoker.run(messages=replies)["tool_messages"]
        messages.extend(tool_messages)


    # Print the final AI response after all tool calls are resolved
    print(f"🤖: {messages[-1].text}")
waiting for input (type 'exit' or 'quit' to stop)
🧑: What is the top HN article now?
⌛ iterating...

 TOOL CALL:
	call_aOluHPdSMAGosryayVwNxOvL
	hacker_news_fetcher
	top_k: 1
⌛ iterating...

 TOOL CALL:
	call_G6Z10LdGwJgdqxspgIWwvsnl
	hacker_news_fetcher
	top_k: 1
⌛ iterating...
🤖: It appears that I'm facing an issue retrieving data from Hacker News, as the response does not contain the actual article information. Unfortunately, I'm unable to provide the top article at this moment. You might want to check the Hacker News website directly for the latest articles.


waiting for input (type 'exit' or 'quit' to stop)
🧑: What's the top 2 HN articles?
⌛ iterating...

 TOOL CALL:
	call_SWI39GuYw579wRDhQVs02WUu
	hacker_news_fetcher
	top_k: 2
⌛ iterating...
🤖: It seems I'm encountering difficulties retrieving the top articles from Hacker News properly. However, based on the partial information I received, one of the articles is:

1. **Adobe's new image rotation tool** - "Project Turnable" lets users fully rotate 2D vectors. This tool was showcased at Adobe's annual MAX conference as part of their "Sneaks" segment, where engineers present innovative ideas that may or may not be fully developed.

Unfortunately, the first article did not provide relevant content. For the most accurate and complete information on the top Hacker News articles, I recommend checking the Hacker News website directly.


waiting for input (type 'exit' or 'quit' to stop)
🧑: Create a newsletter targeted at engineers based on this article. No more than 100 words.
⌛ iterating...

 TOOL CALL:
	call_duVE2eBKkCe3wpJOuq6NEJte
	newsletter_generator
	articles: ["Adobe's new image rotation tool is one of the mo...
	target_people: engineers
	n_words: 100
⌛ iterating...
🤖: **Engineering Whirlwind**  
*Issue #42: AI Innovations Turned Up to Eleven*

Hello, Innovators!

Dive into Adobe's latest gem, *Project Turntable*! This fascinating tool offers engineers a chance to fully rotate 2D vectors like never before. Unveiled at their MAX conference, it sits at the intersection of imagination and engineering mastery. As Adobe's engineers sneak out innovative ideas, warm up those creative engines—who knows what else might come spinning your way?

Stay sharp and keep spinning those ideas!  
— The Engineering Brigade 🌟


waiting for input (type 'exit' or 'quit' to stop)
🧑: Email this to tuana.celik@deepset.ai You can decide on the subjectline
⌛ iterating...

 TOOL CALL:
	call_e7thnZ8Bq1kBjU4dyrGLP0jK
	email
	receiver: tuana.celik@deepset.ai
	body: **Engineering Whirlwind**  
*Issue #42: AI Innovat...
	subject: Latest Innovations: Adobe's Project Turntable
⌛ iterating...
🤖: The newsletter has been successfully emailed to tuana.celik@deepset.ai with the subject "Latest Innovations: Adobe's Project Turntable." If you need any further assistance, feel free to ask!


waiting for input (type 'exit' or 'quit' to stop)
🧑: exit

附加:转换工具

将函数转换为工具

from typing import Annotated
from pprint import pp

编写 JSON 模式并不有趣……🤔

def newsletter_pipeline_func(articles: List[str], target_people: str = "programmers", n_words: int = 100):
    result = newsletter_pipe.run({"prompt_builder": {"articles": articles, "target_people": target_people, "n_words": n_words}})

    return {"reply": result["llm"]["replies"][0].text}

newsletter_tool = Tool(name="newsletter_generator",
                          description="Generate a newsletter based on some articles",
                            function=newsletter_pipeline_func,
                            parameters={
                                "type": "object",
                                "properties": {
                                    "articles": {
                                        "type": "array",
                                        "items": {
                                            "type": "string",
                                            "description": "The articles to include in the newsletter",
                                        }
                                    },
                                    "target_people": {
                                        "type": "string",
                                        "description": "The target audience for the newsletter",
                                    },
                                    "n_words": {
                                        "type": "integer",
                                        "description": "The number of words to summarize the newsletter to",
                                    }
                                },
                                "required": ["articles"],
                            })

我们可以这样做 👇

from haystack.tools import create_tool_from_function
 
def newsletter_pipeline_func(
    articles: Annotated[List[str], "The articles to include in the newsletter"],
    target_people: Annotated[str, "The target audience for the newsletter"] = "programmers",
    n_words: Annotated[int, "The number of words to summarize the newsletter to"] = 100
    ):
    """Generate a newsletter based on some articles"""

    result = newsletter_pipe.run({"prompt_builder": {"articles": articles, "target_people": target_people, "n_words": n_words}})

    return {"reply": result["llm"]["replies"][0].text}

newsletter_tool = create_tool_from_function(newsletter_pipeline_func)

pp(newsletter_tool, width=200)
Tool(name='newsletter_pipeline_func',
     description='Generate a newsletter based on some articles',
     parameters={'properties': {'articles': {'items': {'type': 'string'}, 'type': 'array', 'description': 'The articles to include in the newsletter'},
                                'target_people': {'default': 'programmers', 'type': 'string', 'description': 'The target audience for the newsletter'},
                                'n_words': {'default': 100, 'type': 'integer', 'description': 'The number of words to summarize the newsletter to'}},
                 'required': ['articles'],
                 'type': 'object'},
     function=<function newsletter_pipeline_func at 0x7f6fd96511b0>)

将预先存在的工具转换为 Haystack 工具

Haystack 非常灵活。这意味着如果您已经在其他地方定义了工具,您可以将它们转换为 Haystack 工具。例如,LangChain 有一些有趣的工具,我们可以无缝地将它们转换为 Haystack 工具。

! pip install langchain-community
from pydantic import create_model
from haystack.tools.from_function import _remove_title_from_schema

def convert_langchain_tool_to_haystack_tool(langchain_tool):
    tool_name = langchain_tool.name
    tool_description = langchain_tool.description

    def invocation_adapter(**kwargs):
        return langchain_tool.invoke(input=kwargs)

    tool_function = invocation_adapter

    model_fields = langchain_tool.args_schema.model_fields

    fields = {name: (field.annotation, field.default) for name, field in model_fields.items()}
    descriptions = {name: field.description for name, field in model_fields.items()}

    model = create_model(tool_name, **fields)
    schema = model.model_json_schema()

    # we don't want to include title keywords in the schema, as they contain redundant information
    # there is no programmatic way to prevent Pydantic from adding them, so we remove them later
    # see https://github.com/pydantic/pydantic/discussions/8504
    _remove_title_from_schema(schema)

    # add parameters descriptions to the schema
    for name, description in descriptions.items():
        if name in schema["properties"]:
            schema["properties"][name]["description"] = description

    return Tool(name=tool_name, description=tool_description, parameters=schema, function=tool_function)
from langchain_community.agent_toolkits import FileManagementToolkit
toolkit = FileManagementToolkit(
    root_dir="/"
)  # If you don't provide a root_dir, operations will default to the current working directory
toolkit.get_tools()
[CopyFileTool(root_dir='/'),
 DeleteFileTool(root_dir='/'),
 FileSearchTool(root_dir='/'),
 MoveFileTool(root_dir='/'),
 ReadFileTool(root_dir='/'),
 WriteFileTool(root_dir='/'),
 ListDirectoryTool(root_dir='/')]
langchain_listdir_tool = toolkit.get_tools()[-1]
haystack_listdir_tool = convert_langchain_tool_to_haystack_tool(langchain_listdir_tool)
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.tools import ToolInvoker
from haystack.dataclasses import ChatMessage

chat_generator = OpenAIChatGenerator(model="gpt-4o-mini", tools=[haystack_listdir_tool])
tool_invoker = ToolInvoker(tools=[haystack_listdir_tool])

user_message = ChatMessage.from_user("List the files in /content/sample_data")

replies = chat_generator.run(messages=[user_message])["replies"]
# print(f"assistant messages: {replies}")

if replies[0].tool_calls:

    tool_messages = tool_invoker.run(messages=replies)["tool_messages"]
    # print(f"tool messages: {tool_messages}")

    # we pass all the messages to the Chat Generator
    messages = [user_message] + replies + tool_messages
    final_replies = chat_generator.run(messages=messages)["replies"]
    print(f"{final_replies[0].text}")
The files in the `/content/sample_data` directory are:

1. anscombe.json
2. README.md
3. mnist_train_small.csv
4. california_housing_train.csv
5. california_housing_test.csv
6. mnist_test.csv