AIエージェント Google patentで特許を検索しAIで要約してTeamsに毎朝投稿するBot

技術者や研究者が開発や研究をするためには文献調査(サーベイ)が不可欠です。しかし、他の業務に追われてなかなか手がつかないケースも多いですよね。

今回紹介するエージェントは、
1)特許の情報をgoogle patentから任意のキーワードで検索して、
2)内容をAIで要約し
3)毎朝Microsoft Teamsのチャネルに自動投稿してくれる
というものです。毎朝始業時に関連特許の要約が自動配信されたらとても嬉しいですよね。

高品質な要約を得るために2025年2月現在、最高性能の生成AI(LLM)のOpenAI 「o3-mini」を採用しました。

動作の概要

特許情報はGoogleの提供する特許の検索サービスGoogle patentから取得します。これはSerpAPIが提供するGoogle patent APIにより実現します。

任意の検索キーワードを設定し、検索した結果からランダムで一件選ぶようにしました。
使っているキーワードは「Lithography」「nanoindentation」。海外の特許を念頭に英語表記にしています。

取得した特許文書のPDFをダウンロードし、OCRしてテキストデータにします。

続いて、o3-miniで要約しますが、プロンプトはシンプルに下記のように与えています。

“以下の特許文書の内容を400字程度に要約してください。” + text

「text」には特許文書のテキストデータが入っています。

実行すると下記のような結果が得られます。

“title”: “Method of measuring an interaction force”,

“pdf_link”: “https://patentimages.storage.googleapis.com/8a/e1/b3/f74d8a48f26563/US9335240.pdf”,

“snippet”: “BACKGROUND Nanoindentation (see References 1 and 2) is a method to quantitatively measure a sample’s mechanical properties, such as elastic modulus and hardness, for example, using a small force and a high resolution displacement sensor. Typically, a force employed in nanoindentation is less than …”,

“summary”: “本発明は、サンプル表面との相互作用力を高精度に測定するためのシステムおよび方法に関するもので、特にナノインデンテーションなど微小力測定分野への応用が想定される。
具体的には、マイクロエレクトロメカニカルな変換素子を用い、該変換素子内に移動可能なプローブと、プローブの相対移動を検出するためのマイクロマシン製作のコンボドライブおよび差動容量型変位センサを組み込んでいる。
プローブをサンプル表面に向けて移動させ、その際に生じる信号出力を基に、プローブとサンプル表面との間に働く相互作用力を正確に算出する。
これにより、従来のナノインデンテーション技術よりも高感度かつ高精度な力の計測が可能となり、材料の弾性率、硬度などの機械的特性の定量評価に有用である。
また、本発明は装置の構造や製造方法、計測手法等の詳細も提供しており、先行技術との比較においてより優れた性能を示す点が強調されている。”

要約した結果はTeamsの指定のチャネルに投稿されます。このプログラムをGCP(Google Cloud Platform)上で定期実行させることで毎朝決まった時間に、特許の要約を配信することができるようになります。

フローチャート

動作を図にするとこのようになります。

ソースコード

OCRをpythonのpypdfでやっているため、英語圏の特許(米国、欧州)のみを対象にしていますが、中国語系の特許も対応できるようゆくゆくは改善したいです。

import os
import random
import json
import requests
import re
import io
from openai import OpenAI
import tempfile
import base64

#from serpapi import GoogleSearch
from PyPDF2 import PdfReader
from datetime import datetime

openai_key = os.environ.get("OPENAI_API_KEY")
client = OpenAI(api_key=openai_key)
##
# GCP Cloud Functions のエントリポイント
#
# main(event, context) が呼ばれる想定
##

def main(event,context):
    """
    GCP Cloud Functionsのエントリポイント。
    SerpAPIのGoogle Patents APIを直接呼び出して特許情報を取得し、
    ランダムに選んだ1件のPDFを要約し、Microsoft Teamsに送信します。
    """
    
    #print("[DEBUG] Raw data:", event.get('data', 'No data in event'))
    # --- 環境変数からAPIキーを取得 ---
    serpapi_key = os.environ.get("SERPAPI_KEY")
    teams_webhook_url = os.environ.get("TEAMS_WEBHOOK_URL")

    # いずれかのキーが取得できなければエラー
    if not serpapi_key:
        raise ValueError("SERPAPI_KEY environment variable is not set.")
    if not openai_key:
        raise ValueError("OPENAI_API_KEY environment variable is not set.")
    if not teams_webhook_url:
        raise ValueError("TEAMS_WEBHOOK_URL environment variable is not set.")

    # OpenAIのAPIキーを設定

    # 検索キーワードを定義(複数キーワードで繰り返し処理する例)
    query_list = [
        "electron beam lithography",
        "nanoindentation",
    ]

    # 各キーワードで検索を実行し、ランダムに1件を処理
    for query in query_list:
        results = search_google_patent(query, serpapi_key, max_results=50)
        if not results:
            print(f"No patents found for query: {query}")
            continue

        # 検索結果からランダムで1件選択
        selected_patent = random.choice(results)
        title = selected_patent.get("title", "No title")
        pdf_link = selected_patent.get("pdf")  # PDFリンクは"link"フィールドに格納される場合があります
        snippet = selected_patent.get("snippet", "")

        # PDFリンクがある場合のみ処理を続行
        if pdf_link:
            print(f"\n[INFO] Downloading PDF for: {title}")
            pdf_text = (snippet + download_and_read_pdf(pdf_link))
            if not pdf_text:
                print(f"[WARN] Could not read PDF for: {title}")
                continue
            # PDFの要約を生成
            print(pdf_text)
            summary = summarize_text(pdf_text)
        else:
            summary = "[WARN] PDF Link not found in the result."

        # レポートの生成
        published_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        result_text = (
            f"検索キーワード: {query}\n"
            f"タイトル: {title}\n"
            f"PDFリンク: {pdf_link or 'N/A'}\n"
            f"スニペット: {snippet}\n"
            f"要約取得日: {published_str}\n"
            f"--- 要約結果 ---\n"
            f"{summary}\n\n"
            "**本要約はo3-miniによって自動生成されました。**"
        )

        # Teamsに送信
        try:
            send_to_teams(teams_webhook_url, result_text)
        except Exception as e:
            print(f"[ERROR] Failed to send Teams message: {str(e)}")

    print("[INFO] Process completed for all queries.")
##
# Google Patent検索
##

def search_google_patent(query, serpapi_key, max_results=50):
    """
    HTTPリクエストで直接アクセスし、検索結果を返します。

    Parameters:
        query (str): 検索クエリ
        serpapi_key (str): SerpAPIのAPIキー
        max_results (int): 最大取得数

    Returns:
        list: 検索結果のリスト
    """
    url = "https://serpapi.com/search"
    results = []
    page_number = 0
    results_per_page = 10  # APIで取得できる最大件数(1回のリクエスト)

    while len(results) < max_results:
        params = {
            "engine": "google_patents",  # SerpAPIのGoogle Patentsエンジン
            "country": "US,EP",
            "q": query,
            "api_key": serpapi_key,
            "start": page_number * results_per_page,  # ページ番号
            "num": results_per_page,  # 1リクエストでの結果数
        }

        try:
            response = requests.get(url, params=params)
            response.raise_for_status()  # HTTPエラーの場合例外を発生
            data = response.json()
            organic_results = data.get("organic_results", [])
            if not organic_results:  # 結果が空なら終了
                break
            results.extend(organic_results)
            page_number += 1
        except Exception as e:
            print(f"[ERROR] Failed to fetch patents: {str(e)}")
            break

    return results[:max_results]

##
# PDFダウンロード & テキスト抽出
##

def download_and_read_pdf(pdf_url):
    """
    PDFをダウンロードし、テキストを抽出して返す。
    失敗時はNoneを返す。
    """
    try:
        response = requests.get(pdf_url, timeout=30)
        response.raise_for_status()

        pdf_file = io.BytesIO(response.content)
        pdf_reader = PdfReader(pdf_file)

        text = ""
        for page in pdf_reader.pages:
            text += page.extract_text() or ""

        # テキストを適度に制限
        if len(text) > 100000:
            text = text[:100000]
        print(text)
        return text
    except Exception as e:
        print(f"[ERROR] Error downloading/reading PDF: {pdf_url}, {str(e)}")
        return None

##
# OpenAI GPTによる要約
##

def summarize_text(text):
    """
    特許文書のテキストを GPT で要約し、文字列を返す。
    """
    try:
        response = client.chat.completions.create(model="o3-mini",
        messages=[
            {"role": "system","content": "以下は特許文書です。内容を400字程度に要約してください。"},
            {"role": "user","content": text}
        ],
        )
        return response.choices[0].message.content
    except Exception as e:
        print(f"[ERROR] Error summarizing text: {str(e)}")
        return "要約に失敗しました。"

##
# Microsoft Teams送信
##

def send_to_teams(webhook_url, message):
    """
    結果メッセージをMicrosoft Teams (Webhook)に送信する。
    Adaptive Card形式で送信する例を示す。
    """
    headers = {"Content-Type": "application/json"}

    # ログに送信内容を記録
    print("[INFO] Sending message to Teams:")
    print(message)

    def create_text_block_with_link(line):
        url_pattern = re.compile(r'http[s]?://\S+')
        urls = url_pattern.findall(line)
        if urls:
            text_fragments = url_pattern.split(line)
            text_without_url = "".join(text_fragments)
            line_with_link = f"{text_without_url} [({urls[0]})]({urls[0]})"
            return {"type": "TextBlock", "text": line_with_link, "wrap": True}
        else:
            return {"type": "TextBlock", "text": line, "wrap": True}

    message_lines = message.split("\n")
    text_blocks = [create_text_block_with_link(line) for line in message_lines]

    payload = {
        "type": "message",
        "attachments": [
            {
                "contentType": "application/vnd.microsoft.card.adaptive",
                "content": {
                    "type": "AdaptiveCard",
                    "version": "1.2",
                    "body": text_blocks
                }
            }
        ]
    }

    response = requests.post(webhook_url, headers=headers, data=json.dumps(payload))
    if response.status_code != 200:
        raise ValueError(f"Request to Teams returned an error: {response.status_code}, {response.text}")