Scriptone

【Python】生成AIを利用して海外の技術記事を要約し和訳する

DevToの記事を日本語に要約してDiscordに送信する機能を組みました(リポジトリ:https://github.com/rmc8/DevToDigest)。

概要

【Python】Dev community (dev.to)からAPIで記事情報などを取得するでは、PythonでDevToのAPIを操作するためのモジュールを作りました。また、GPT 4o-miniの登場により個人でも優れた性能のモデルを安価にAPI経由で扱えるようになり、アイデア次第で活用の幅が広がってきています(前回の記事の間にGemini1.5 Flashの128K以下のモデルが4o-miniの半額で使えるようになっています!)。そして、プログラミングでは銀の弾丸がないと言われるようにいろいろな手法・柔軟な発想を持つことが1つの武器となり、日本とは異なるカルチャーを持つ海外の記事を読むことも日本で一味違う発想を持つプログラマーになる助けとなると思います。私にとっては日本語の記事を読むのですら億劫に感じてしまうので、英語の記事を日本語に要約して手軽に興味のある記事を読めるようにこのプログラムを書くこととしました。

コード

コードは主に三部構成となっています。(1)記事の取得、(2)Langchainを用いた生成AIでの記事の処理、(3)Discordへのデータ送信です。

記事の取得

記事はdevtopyを使って取得しています。


def fil_articles(
    articles: PublishedArticleList, threshold: int
) -> List[PublishedArticle]:
    return [a for a in articles.articles if a.positive_reactions_count > threshold]


res = dt.articles.get(page=1, per_page=1000, tag=tag_name.lower())
articles = fil_articles(res, reaction_threshold)

その日読まれている記事を1000件取得して、fil_article関数にデータと閾値を渡します。この閾値はポジティブなリアクションがどれだけついているかのフィルターに使うもので、デフォルト値として55が設定されています。55のポジティブなリアクションはおおよそで上位2%のリアクション数となります。そのため十分に人気のある記事だと判断できます。人気の記事に絞ることで、LLMの費用を減らしたり質の高い記事に集中して読むことができるので、英語が苦手でも読むためのインセンティブになります(英語学習*質の高い技術記事)。

その後、取得してフィルターした記事リストには記事のコンテンツがついていないので記事の取得を行います。記事の取得にもDevTOのAPIを使いますがリクエスト数を減らすために過去にLLMで処理をしたことがあるか、SQLiteで参照します。このSQLiteはローカルに作られるものであるので、個人が読んだものかどうかで判定される仕組みです。

for article in articles:
    url = str(article.url)
    if db.url_exists(url):
        continue
    article_id = article.id
    detail: Union[ErrorResponse, Article] = dt.articles.get_by_id(article_id)
    if type(detail) is ErrorResponse:
        continue
    title = article.title
    tags = article.tag_list
    contents = detail.body_markdown

処理ずみのURLが存在している場合には処理をスキップし、はじめて処理をする記事の場合にはその記事のIDを使って詳細な情報をAPIにリクエストします。記事の取得に成功した場合に、タイトルやタグ、記事情報を取得してLLMによる翻訳と要約の処理に移ります。

Langchainを用いた生成AIでの記事の処理

LangProcGptクラスで処理のための準備をします。このクラスでは生成AIへの処理のリクエストと構造かされたデータを返す役割を持ちます。

class LangProcGpt:
    MODEL_NAME = "gpt-4o-mini"

    def __init__(
        self,
        title: str,
        contents: str,
        url: str,
        img_url: str,
        tag_list: List[str],
        api_key: str,
    ):
        self.title = title
        self.contents = contents
        self.url = url
        self.tag_list = tag_list
        self.img_url = img_url
        self.llm = ChatOpenAI(
            model_name=self.MODEL_NAME, temperature=0, api_key=api_key
        )
        parser = PydanticOutputParser(pydantic_object=ProcResult)
        self.result_parser = OutputFixingParser.from_llm(
            parser=parser,
            llm=self.llm,
        )

実際にLLMで処理を行うのはtitleとcontentsであり、そのほかは単純に構造化されたデータを返したり、OpenAI APIの認証にキーを使ったりするのみです。処理についてはrunメソッドを実行することで行えるようになっています。

# main.py
params = {
            "title": title,
            "contents": contents,
            "url": url,
            "img_url": detail.social_image,
            "tag_list": tags,
            "api_key": os.getenv("OPENAI_API_KEY"),
        }
lp = LangProcGpt(**params)
data = lp.run(summary_sentences=summary_sentences)
def run(self, summary_sentences: int = 5) -> ProcessedArticleData:
    result = self.summarize_and_translate(summary_sentences)
    res_dict = {
        "en_title": self.title,
        "ja_title": result.ja_title,
        "url": self.url,
        "img_url": str(self.img_url),
        "tags": ", ".join([f"#{t}" for t in self.tag_list]),
        "ja_summary": result.ja_summary,
    }
    return ProcessedArticleData(**res_dict)

runの中を見ると処理がsummarize_and_translateに集約されており、最終的な結果をres_dictにまとめて構造化したデータを返しています。1つずつプロンプトや処理の内容を確認します。

要約する

def summarize_and_translate(self, summary_sentences: int):
    summary = self.summarize(summary_sentences) # ここ
    ja_summary = self.translate(summary)
    ja_title = self.translate(self.title)
    # (省略)

要約はsummarizeメソッドで行います。引数にはどのぐらいの文章数で要約するかを示す整数を渡しています。日英でN文字で要約するとなると、同じ意味の文章でも英語が長くなりがちです。意味のある区切りで文章を分けるとなるとN文字に要約させるよりもN文で要約させる方が適当だと判断しました。

def summarize(self, summary_sentences: int):
    prompt = PromptTemplate(
        template=SUMMARIZE_PROMPT_TEMPLATE,
        input_variables=["summary_sentences", "contents"],
    )
    formatted_prompt = prompt.format(
        summary_sentences=summary_sentences, contents=self.contents
    )
    output = self.llm.invoke(formatted_prompt)
    return output.content

summarizeメソッドではテンプレート機能を使ってプロンプトを作る処理をしています。

SUMMARIZE_PROMPT_TEMPLATE = """
## Instructions

Please summarize the following English text into a narrative of {summary_sentences} sentences in plain text format.

## Text

================================================================

{contents}

================================================================
""".strip()

プロンプトの内容としては英語の文章をナラティブ形式に文章をN個分に要約して、プレーンのテキストで出力するように指示しています。これは箇条書きなど簡単にまとめることを防ぎ、文脈を捉えた上で文章形式で要約させるためにこのようなプロンプトを書いています。要約したい文章はTextブロックに===で区切って渡しています。コードブロックで区切った場合に、Markdownのコードブロックと競合してしまうため、===で文章を渡していることを示しています。

要約を日本語に翻訳する

その後、要約した文章をもう一度翻訳するタスクに渡します。GPT 4o-miniは低価格で高性能ですがより正確にタスクをこなすためにはシンプルにタスクを分解すると良いです。そのため英語のまま一度要約するタスクをお願いして、要約したものを和訳させる手順で連鎖させることで意図する内容を得られるようにします。プロンプトの内容は以下のとおりです。

JA_TRANSLATE_PROMPT_TEMPLATE = """
## Instructions
Please translate the following text into Japanese.

## Text
{text}
""".strip()

シンプルに渡したテキストの言語を日本語にするように書いているだけのプロンプトです。前の要約の処理でプレーンのテキストを返すように指示しているため、テキストにブロックにはそのままテキストを入力させています。なお、記事のタイトルも同じプロンプトを使っており、シンプルに和訳できます。

データを構造化する

ここまでの処理で要約→要約の和訳、記事タイトルの和訳が完成しました。完成したデータを構造化する処理を行います。

class ProcResult(BaseModel):
    ja_title: str = Field(description="Japanese translation of the article title")
    ja_summary: str = Field(description="Japanese translation of the summary")

def summarize_and_translate(self, summary_sentences: int):
    summary = self.summarize(summary_sentences)
    ja_summary = self.translate(summary)
    ja_title = self.translate(self.title)
    formatted_instructions = self.result_parser.get_format_instructions()
    prompt = PromptTemplate(
        template=PARSE_PROMPT_TEMPLATE,
        input_variables=["ja_title", "ja_summary"],
        partial_variables={"formatted_instructions": formatted_instructions},
    )
    formatted_prompts = prompt.format(ja_title=ja_title, ja_summary=ja_summary)
    output = self.llm.invoke(formatted_prompts)
    return self.result_parser.parse(output.content)

formatted_instructionsは返したいデータの構造を定義しております。日本語のタイトルと日本語の要約を構造化したデータで返すことと、ja_title, ja_summaryの説明を記述しています。説明を含む型として定義することでlangchainを使ってLLMが理解しやすい形で説明を作ることを自動化しています。説明を作った後、要約や和訳と同様にプロンプトのテンプレートに値を埋め込みます。

PARSE_PROMPT_TEMPLATE = """
## Instructions
Please return an object containing a Japanese title and a Japanese summary.

## Input
### Japanese Title
{ja_title}

### Japanese Summary
{ja_summary}

## Output Format
{formatted_instructions}
""".strip()

プロンプトの内容としては構造化されたオブジェクトで日本語のタイトルと和訳した要約を返す指示をしています。構造化したデータを作るためのインプットに記事のタイトルと和訳した要約をわたし、出力の処理にはlangchainで作った構造化したデータの定義を渡しています。テンプレートからプロンプトを作った後GPT 4o-miniへプロンプトを渡し、その結果からJSON形式でデータを抜き出す処理をします。

prompt = PromptTemplate(
        template=PARSE_PROMPT_TEMPLATE,
        input_variables=["ja_title", "ja_summary"],
        partial_variables={"formatted_instructions": formatted_instructions},
    ) # プロンプトをのテンプレートを作る
formatted_prompts = prompt.format(ja_title=ja_title, ja_summary=ja_summary) # 値を埋め込んだプロンプトを取得する
output = self.llm.invoke(formatted_prompts) # モデルにプロンプトを渡して応答を得る
return self.result_parser.parse(output.content) # 応答から型の定義に沿って構造化された状態のデータを取り出す

ここまで処理をすることで、日本語のタイトルと日本語の要約を構造化された(プログラムで扱える)状態で渡せます。

Discordに処理結果を送信する

構造化されたデータをつかってDiscordにデータを送ります。Botなど作る必要がなくWebhook機能を使うことで簡単にメッセージを送れます。データの送り方はこの後記しますが、送信後エラーが出なければデータベースに処理ずみの記録をし、そうでない場合には次の記事の処理を行います。

lp = LangProcGpt(**params)
data = lp.run(summary_sentences=summary_sentences)
err_status = dw.report(data)
if err_status:
    continue
db.insert_data(
    url=data.url,
    en_title=data.en_title,
    ja_title=data.ja_title,
    translated_text=data.ja_summary,
    tags=data.tags,
)

Discordへのデータ送信は専用のライブラリを使っていないですが、requestsでも簡単にできます。

import requests

from .model import ProcessedArticleData


class DiscordWebhookClient:
    def __init__(self, webhook_url: str, is_silent: bool = True):
        self.webhook_url = webhook_url
        self.is_silent = is_silent

    def report(self, proc_data: ProcessedArticleData) -> int:
        embed = {
            "color": 0x00C0CE,
            "title": proc_data.ja_title,
            "url": proc_data.url,
            "fields": [
                {"name": "EnTitle", "value": proc_data.en_title},
                {"name": "Tags", "value": proc_data.tags},
                {"name": "Summary", "value": proc_data.ja_summary},
            ],
            "image": {"url": proc_data.img_url},
        }
        data = {
            "content": "",
            "embeds": [embed],
        }
        if self.is_silent:
            data["flags"] = 4096
        headers = {"Content-Type": "application/json"}
        response = requests.post(self.webhook_url, json=data, headers=headers)
        if response.status_code in (200, 204):
            return 0
        return response.status_code

インスタンス化の際にWebhookのURLとサイレント通知の設定の有無を渡し、実際にDiscordへデータの送信をする際に構造化されたデータを渡すのみで整形された状態でデータを送信できます。

report

リンク付きの日本語のタイトル、英語のオリジナルのタイトル、タグ、要約、記事のアイキャッチを送信できるので視覚的にも記事への興味を惹き、日本語でこの記事を読むか読まないかの判断がさっとできます。

使い方

以下のようにコマンドを打つことでDiscordへ記事の送信ができます。

python main.py {タグ名} {WebhookのURL} {リアクションの閾値}

私はRaspberry Pi3で以下のようなスクリプトを自動実行させています。

WEBHOOK_URL="https://discord.com/api/webhooks/xxxxxxxxxxx/yyyyyyyyyyyyyyzzzzzzzzzzzzz"
SCRIPT_PATH="$HOME/auto/DevToDigest/src/main.py"
THRESHOLD=100

cd $HOME/auto/DevToDigest/src
python3 $SCRIPT_PATH python $WEBHOOK_URL $THRESHOLD
python3 $SCRIPT_PATH typescript $WEBHOOK_URL $THRESHOLD
python3 $SCRIPT_PATH svelte $WEBHOOK_URL $THRESHOLD
python3 $SCRIPT_PATH flutter $WEBHOOK_URL $THRESHOLD
python3 $SCRIPT_PATH dart $WEBHOOK_URL $THRESHOLD
python3 $SCRIPT_PATH llm $WEBHOOK_URL $THRESHOLD

Pythonやweb、FlutterやLLMに興味があるのでその関連の情報をまとめて送るようにしています。

感想

8月7日から使いはじめていますが海外の技術記事を読み興味のある記事をブックマークして読む習慣がついてきています。日本でもまとめられているベストプラクティスもあれば、日本では見ない情報もあったりして人気記事の質は高いです。5ドルだけチャージして10日かん使っているのですがまだ0.07ドルしか使われていない状態でこれであれば非常に安価だと思います。シンプルなタスクにも良いですし、アイデアがあればより複雑なタスクにもGPT 4o-miniは役立つと思います。

まとめ

近頃の生成AIの小規模モデルは低価格で高性能であり、個人であっても今までは難しかったアイデアを実現する可能性を秘めています。ここでは挙げなかったですが処理内容によって実行する処理を振り分けるFunction callingも安価に扱うことができるようになったため、生成AIでできることを見極め、プロンプトや全体の処理を構成し、そのアイデアを実行することで驚くようなアプリも作れると思います。まず、シンプルな処理の足掛かりとしてこの記事が役立ちましたら幸いです。

関連書籍

※こちらはAmazonへのリンクです。