Type something to search...
构建一个能写入 Google 文档的研究助手(第 2 部分)

构建一个能写入 Google 文档的研究助手(第 2 部分)

可能对你的作业有所帮助的工具

本文是两部分系列的第二部分,我们使用 LangGraph 和 Tavily 构建一个简单的研究代理,该代理可以撰写和完善短文。为了跟踪它生成的计划、文章和评论,我们增加了以编程方式创建和编辑 Google Docs 的能力。在第一篇文章中,我们构建了代理。现在我们将构建文档连接。你可以在 这里 找到所有相关代码。

本系列的第一部分中,我们讨论了代理,并使用 LangGraph 和 Tavily 的工具构建了一个最小代理,该代理可以研究、撰写、审阅和修订短文。这对于演示来说很好,但如果我们实际上想在笔记本之外阅读这些文章呢?或者,更雄心勃勃的是,我们能否将这个代理变成一个对学习新主题的人有实际帮助的工具?这有可能成为一个全栈项目,但在这里我将专注于一个有趣的元素——赋予我们的系统将文章上传到 Google Docs 的能力。请记住,我们还保存了代理在得到最终答案时所采取的中间步骤——也许记录这些步骤也是值得的。

1. 最小可行产品

针对问题或主题提示,我们的代理生成了一长串输出。至少,我们希望将其导入一个带有标题和时间戳的 Google 文档中。我们还希望控制这个文档在 Google Drive 中的写入位置,并且最好能够创建和命名文件夹,以便我们的论文可以逻辑性地存储。我们在这里不会过于关注格式——尽管使用 Google Docs API 当然可以实现——我们更感兴趣的是将文本放入一个人们实际上会阅读的地方。格式化可以作为后续步骤,或者简单地留给读者的偏好。

一旦我们建立了文档连接,就可以对我们的论文做很多更高级的事情——比如使用 LLM 将其重新格式化以进行演示,并将其上传到 Google Slides 演示文稿中?或者抓取一些参考数据源并将其上传到 Google Sheets?我们可以将此功能作为工具添加到代理的控制流程中,让它决定该做什么。显然,这里有很多选择,但从小处开始是个好主意。

2. 连接到 Google Drive

让我们开始编写一些代码,以基本方式与 Google Docs 进行交互。首先需要一些设置:您需要一个 Google Cloud 账户和一个新项目。然后,您需要启用 Google Drive 和 Google Docs API。为了为该项目创建一些凭据,我们将使用一个 服务账户,可以按照 这里 的说明进行设置。此过程将创建一个 .json 文件中的私钥,您将其存储在本地计算机上。接下来,最好在您的 Google Drive 中为该项目创建一个“主文件夹”。完成后,您可以将服务账户添加到该文件夹并授予其写入权限。现在,您的服务账户拥有通过编程方式与该文件夹内容交互的授权。

from google.oauth2 import service_account
from abc import ABC, abstractmethod
from googleapiclient.discovery import build
## path to your .json credentials file
from research_assist.gsuite.base.config import CREDENTIALS
from typing import Any


class GSuiteService(ABC):
    """
    An abstract base class for G Suite services.

    This class defines the structure for any G Suite service implementation,
    requiring subclasses to specify the scopes and service creation logic.

    Attributes:
        credential_path (str): The path to the credentials file.
        SCOPES (list): The scopes required for the service.
    """

    def __init__(self) -> None:
        """
        Initializes the GSuiteService with the credential path and scopes.
        """
        # The name of the file containing your credentials
        self.credential_path = CREDENTIALS
        self.SCOPES = self.get_scopes()

    @abstractmethod
    def get_scopes(self) -> list[str]:
        """
        Retrieves the scopes required for the G Suite service.

        Returns:
            list[str]: A list of scopes required for the service.
        """
        raise NotImplementedError("Subclasses must implement this method.")

    @abstractmethod
    def get_service(self, credentials: Any) -> Any:
        """
        Creates and returns the service object for the G Suite service.

        Args:
            credentials (Any): The credentials to use for the service.

        Returns:
            Any: The service object for the G Suite service.
        """
        raise NotImplementedError("Subclasses must implement this method.")

    def build(self) -> Any:
        """
        Builds the G Suite service using the provided credentials.

        Returns:
            Any: The constructed service object.
        """
        # Get credentials into the desired format
        creds = service_account.Credentials.from_service_account_file(
            self.credential_path, scopes=self.SCOPES
        )

        service = self.get_service(creds)
        return service


class GoogleDriveService(GSuiteService):
    """
    A service class for interacting with Google Drive API.

    Inherits from GSuiteService and implements the methods to retrieve
    the required scopes and create the Google Drive service.

    Methods:
        get_scopes: Returns the scopes required for Google Drive API.
        get_service: Creates and returns the Google Drive service object.
    """

    def get_scopes(self) -> list[str]:
        """
        Retrieves the scopes required for the Google Drive service.

        Returns:
            list[str]: A list containing the required scopes for Google Drive API.
        """
        SCOPES = ["https://www.googleapis.com/auth/drive"]
        return SCOPES

    def get_service(self, creds: Any) -> Any:
        """
        Creates and returns the Google Drive service object.

        Args:
            creds (Any): The credentials to use for the Google Drive service.

        Returns:
            Any: The Google Drive service object.
        """
        return build("drive", "v3", credentials=creds, cache_discovery=False)

代码是这样设置的,因为我们可能希望在将来使用许多 GSuite API(drive、docs、sheets、slides 等)。它们都将继承自 GSuiteService,并用该 API 的特定细节覆盖其 get_serviceget_scopes 方法。

一旦这一切设置好,您就可以与驱动器进行交互。这是一篇很好的 文章,展示了一些主要的交互方式。

在我们的实现中,我们将通过 GoogleDriveHelper 的方法与驱动器进行交互,该方法在初始化时创建 GoogleDriveService 的实例。我们首先给它我们主文件夹的名称。

from research_assist.gsuite.drive.GoogleDriveHelper import GoogleDriveHelper

master_folder_name = ai_assistant_research_projects
drive_helper = GoogleDriveHelper(f"{master_folder_name}")

现在假设我们想创建一个关于旅行者系列太空探测器的项目。我们可以通过在主文件夹内设置一个文件夹来进行组织:

project_folder_id = drive_helper.create_new_folder("voyager")

这将创建文件夹并返回其 ID,我们可以用它在其中创建文档。这个项目可能有多个版本,因此我们还可以创建相关的子文件夹。

version_folder_id = drive_helper.create_new_folder(
  "v1", 
  parent_folder_id=project_folder_id
)

现在我们准备创建一个空白文档,这也可以通过驱动器服务来完成。

final_report_id = drive_helper.create_basic_document(
    "final report", parent_folder_id=version_folder_id
)

在后台,驱动器助手正在运行以下代码,该代码传递一些元数据,表示我们希望向 googleapiclient.discovery.build 的创建方法创建一个文档(即,运行 GoogleDriveService().build() 的结果)。

document_metadata = {
            "name": document_name,
            "mimeType": "application/vnd.google-apps.document",
            "parents": [parent_folder_id],
}
## make the document
doc = (
  self.drive_service.files()
  .create(body=document_metadata, fields="id")
  execute()
)
doc_id = doc.get("id")

正如您所想,Google Drive API 有很多不同的功能和选项,我们在这里没有涵盖。到目前为止,我发现的最全面的 Python 包装器是 这个,如果您想进一步探索,这是一个不错的起点。

3. 写入 Google 文档

现在我们已经创建了一个空白文档,让我们来填充最终的论文!这就是 GoogleDocsServiceGoogleDocsHelper 的用武之地。GoogleDocsServiceGoogleDriveService 非常相似,并且也继承自我们在第 2 节中讨论的 GSuiteServiceGoogleDocsHelper 包含一些将文本和图像写入 Google 文档的工具。它们现在非常基础,但这正是我们这个项目所需的。

我们可以首先使用在第 1 部分中构建的代理来写一篇关于 Voyager 的论文。

from research_assist.researcher.Agent import ResearchAgent, load_secrets
from langchain_openai import ChatOpenAI
from tavily import TavilyClient

secrets = load_secrets()
model = ChatOpenAI(
    model="gpt-4o-mini", temperature=0, api_key=secrets["OPENAI_API_KEY"]
)
tavily = TavilyClient(api_key=secrets["TAVILY_API_KEY"])

agent = ResearchAgent(llm, tavily)
agent.run_task(
    task_description="The Voyager missions: What did we learn?", 
    max_revisions=3

)

请记住,代理的各种输出存储在其内存中,可以使用以下方式进行探索。在代码中,您可以看到我们使用“user_id = 1”作为占位符,但在具有多个用户的应用程序中,该 ID 将允许模型访问正确的内存存储。

memories = agent.in_memory_store.search(("1", "memories"))

最终报告文本可以在此处找到,关键名称对应于我们在第 1 部分中讨论的 AgentState。它位于索引 -3,因为它后面跟着对编辑节点的调用(该节点返回了“是”)和接受节点,后者现在只返回“True”。接受节点可以很容易扩展为将此报告自动写入文档。

final_essay = agent.in_memory_store.search(("1", "memories"))[-3].dict()["value"][
    "memory"
]["write"]["draft"]

让我们看看如何将这段文本放入 Google 文档中。请记住,在第 2 节中,我们使用 doc_id 创建了一个空白文档。GoogleDocsHelper 有两个基本方法可以做到这一点。第一个旨在提供标题和基本元数据,即文档撰写的日期和时间。第二个将一些文本粘贴到文档中。

代码展示了如何控制文本的位置和格式,这可能有些混乱。我们定义了一个包含 insertText 等指令的请求列表。当我们插入文本时,需要提供开始插入的索引,这对应于文档中的一个位置。

def create_doc_template_header(self, document_title: str, doc_id: str) -> int:
     """
     为文档创建一个标题模板,包括标题和当前日期。

     参数:
         document_title (str): 文档的标题。
         doc_id (str): 要更新的文档的 ID。

     返回:
         int: 插入标题后的索引。
     """
     # 添加模板标题
     title = f"""
     {document_title}
     """
     template = f"""
     撰写于 {datetime.date.today()},时间为 {datetime.datetime.now().strftime("%H:%M:%S")}
     """
     requests: List[Dict[str, Any]] = [
            {
                "insertText": {
                    "location": {
                        "index": 1,
                    },
                    "text": template,
                }
            },
            {
                "insertText": {
                    "location": {
                        "index": 1,
                    },
                    "text": title,
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": 1,
                        "endIndex": len(title),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "TITLE",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": len(title) + 1,
                        "endIndex": len(title) + len(template),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "SUBTITLE",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
        ]
     result = (
            self.docs_service.documents()
            .batchUpdate(documentId=doc_id, body={"requests": requests})
            .execute()
     )
     end_index = len(title) + len(template) + 1
     return end_index

def write_text_to_doc(self, start_index: int, text: str, doc_id: str) -> int:
     """
     在指定索引处将文本写入文档。

     参数:
         start_index (int): 插入文本的索引。
         text (str): 要插入的文本。
         doc_id (str): 要更新的文档的 ID。

     返回:
         int: 插入文本后的索引。
     """
     end_index = start_index + len(text) + 1

     requests: List[Dict[str, Any]] = [
            {
                "insertText": {
                    "location": {
                        "index": start_index,
                    },
                    "text": text,
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": start_index,
                        "endIndex": start_index + len(text),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "NORMAL_TEXT",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
        ]

     result = (
            self.docs_service.documents()
            .batchUpdate(documentId=doc_id, body={"requests": requests})
            .execute()
        )

     return end_index

您可以在 这里 了解有关索引的更多信息。当多个 insertText 调用时,似乎先写最后一段文本更容易——例如在下面的代码中,template(即应出现在标题下的元数据)首先出现在索引 1 的列表中。然后我们在索引 1 处写入 title。这导致 title 在文档中首先出现,而 template 出现在下面。请注意,我们还需要指定 paragraphStyle 块的 startIndexendIndex 以更改文本的格式。

上述代码中的两个方法返回当前文本块的结束索引,以便可以将其用作后续要附加块的起始索引。如果您打算更具创意地处理文档的样式和格式,这份 指南 可能会有所帮助。

现在我们已经看到了底层代码,我们可以调用它将我们的最终报告写入文档。

from research_assist.gsuite.docs.GoogleDocsHelper import GoogleDocsHelper

docs_helper = GoogleDocsHelper()

## 添加文档标题 
title_end_index = docs_helper.create_doc_template_header(
    "voyager final report", doc_id
)

## 添加文本
doc_end_index = docs_helper.write_text_to_doc(
    start_index=title_end_index, text=final_essay, doc_id=doc_id
)

太好了!现在我们拥有所有的文档工具,可以编辑、格式化和分享我们的代理生成的报告。有趣的是,代理将文本格式化为 Google 文档支持的 markdown,但我无法找到一种方法让文档自动识别并将 markdown 转换为漂亮的标题和副标题。毫无疑问,有办法做到这一点,这将使报告看起来更好。

运行上述代码后,文档应该看起来像这样。

4. 其他代理输出的情况如何?

我们应该能够将存储在代理内存中的所有信息写入文档,这将使我们能够轻松浏览每个阶段的结果。一种稍微黑客式的方法如下:

memories = agent.in_memory_store.search(("1", "memories"))

## this is needed because we may call some nodes several times 
## and we want to keep track of this so that we can make new documents
## for each call
seen_keys = set()
iterations = defaultdict(int)

## folder id where we want to write the documents
folder_id = f"{folder_id}"

for m in memories:
    data = m.dict()["value"]["memory"]
    available_keys = data.keys()
    node_key = list(available_keys)[0]
    unique_node_key = node_key + "_00"
    if unique_node_key in seen_keys:
        iterations[node_key] += 1
        unique_node_key = unique_node_key.replace("_00", "") + "_{:02d}".format(
            iterations[node_key]
        )

    print("-" * 20)
    print("Creating doc {}".format(unique_node_key))

    # get the text
    text = data[node_key][list(data[node_key].keys())[0]]
    
    # the tavily research output is a list, so convert it to a string
    if isinstance(text, List):
        text = "\n\n".join(text)
    
    # if anything else is not a string (e.g. the output of the accept node)
    # convert it to a string
    if not isinstance(text, str):
        text = str(text)

    # create document
    report_id = drive_service.create_basic_document(
        unique_node_key, parent_folder_id=folder_id
    )

    # create header
    end_index = docs_helper.create_doc_template_header(unique_node_key, report_id)

    # fill document
    end_index = docs_helper.write_text_to_doc(
        start_index=end_index, text=text, doc_id=report_id
    )

    seen_keys.add(unique_node_key)

这将生成 7 个文档,下面我们来看一些示例截图。

初步计划概述了报告的结构。有趣的是,模型似乎更倾向于许多短小的部分,我认为这在应对要求使其简明易懂的提示时是合适的。

在研究阶段,调用 Tavily 搜索并返回与所用查询相关的小块格式良好的文本。其中一些块被截断,这份文档的可读性不高,但它很好地展示了从研究节点到写入节点传递的信息类型。

在审查阶段,我们得到了对论文第一版的精彩批评。通常,这些评论的结构与初步计划相似,并提出许多非常一般性的建议,例如“考虑使用更具描述性的标题”或“这一部分可以扩展以包含更多示例”。如果我们比较审查前后的实际报告,通常只会看到结构上的小变化,以及每个部分的一些额外细节。这种变化在多大程度上实际上提高了文本的质量是有争议的,但通过在几个示例上进行尝试,我相信这确实有帮助。

最后,我们得到了编辑对审查后草稿的判断。我目前使用的提示使得编辑相当宽容,因此通常会说一些类似于这里所示的内容。通过一些提示调整,我们可以鼓励它在需要时将更多报告发送回审查。

这就是本文和这个迷你系列的全部内容。感谢您的阅读,希望您能在自己的项目中找到一些有用的信息。在使研究代理更加稳健、对其输出进行适当评估以及与文档(或其他 GSuite API)进行更好集成方面,还有很多潜在的扩展。请告诉我您是否有其他有趣的想法!

作者与本文讨论的任何工具没有关联。

Related Posts

使用 ChatGPT 搜索网络功能的 10 种创意方法

使用 ChatGPT 搜索网络功能的 10 种创意方法

例如,提示和输出 你知道可以使用 ChatGPT 的“搜索网络”功能来完成许多任务,而不仅仅是基本的网络搜索吗? 对于那些不知道的人,ChatGPT 新的“搜索网络”功能提供实时信息。 截至撰写此帖时,该功能仅对使用 ChatGPT 4o 和 4o-mini 的付费会员开放。 ![](https://images.weserv.nl/?url=https://cdn-im

阅读更多
在人工智能和技术领域保持领先地位的 10 项必学技能 📚

在人工智能和技术领域保持领先地位的 10 项必学技能 📚

在人工智能和科技这样一个动态的行业中,保持领先意味着不断提升你的技能。无论你是希望深入了解人工智能模型性能、掌握数据分析,还是希望通过人工智能转变传统领域如法律,这些课程都是你成功的捷径。以下是一个精心策划的高价值课程列表,可以助力你的职业发展,并让你始终处于创新的前沿。 1. 生成性人工智能简介课程: [生成性人工智能简介](https://genai.works

阅读更多
10 个强大的 Perplexity AI 提示,让您的营销任务自动化

10 个强大的 Perplexity AI 提示,让您的营销任务自动化

在当今快速变化的数字世界中,营销人员总是在寻找更智能的方法来简化他们的工作。想象一下,有一个个人助理可以为您创建受众档案,建议营销策略,甚至为您撰写广告文案。这听起来像是一个梦想? 多亏了像 Perplexity 这样的 AI 工具,这个梦想现在成为现实。通过正确的提示,您可以将 AI 转变为您的 个人营销助理。在本文中,我将分享 10 个强大的提示,帮助您自动

阅读更多
10+ 面向 UI/UX 设计师的顶级 ChatGPT 提示

10+ 面向 UI/UX 设计师的顶级 ChatGPT 提示

人工智能技术,如机器学习、自然语言处理和数据分析,正在重新定义传统设计方法。从自动化重复任务到实现个性化用户体验,人工智能使设计师能够更加专注于战略思维和创造力。随着这一趋势的不断增长,UI/UX 设计师越来越多地采用 AI 驱动的工具来促进他们的工作。利用人工智能不仅能提供基于数据的洞察,还为满足多样化用户需求的创新设计解决方案开辟了机会。 1. 用户角色开发 目的

阅读更多
在几分钟内完成数月工作的 100 种人工智能工具

在几分钟内完成数月工作的 100 种人工智能工具

人工智能(AI)的快速发展改变了企业的运作方式,使人们能够在短短几分钟内完成曾经需要几周或几个月的任务。从内容创作到网站设计,AI工具帮助专业人士节省时间,提高生产力,专注于创造力。以下是按功能分类的100个AI工具的全面列表,以及它们在现实世界中的使用实例。 1. 研究工具 研究可能耗时,但人工智能工具使查找、分析和组织数据变得更加容易。**ChatGPT, Cop

阅读更多
你从未知道的 17 个令人惊叹的 GitHub 仓库

你从未知道的 17 个令人惊叹的 GitHub 仓库

Github 隐藏的宝石!! 立即收藏的代码库 学习编程相对简单,但掌握编写更好代码的艺术要困难得多。GitHub 是开发者的宝藏,那里“金子”是其他人分享的精心编写的代码。通过探索 GitHub,您可以发现如何编写更清晰的代码,理解高质量代码的样子,并学习成为更熟练开发者的基本步骤。 1. notwaldorf/emoji-translate *谁需

阅读更多