T.H/ 2024年 5月 14日/ 技術

はじめに

こんにちは。T.H.です。
今回は、議事録自動作成の実験の続きです。

必要な機能

議事録を自動作成するにあたって必要な機能のうち、

  • 文字起こしから要約を作成、議事録に整形する

を検討、実験してみます。
前回はChat-GPTに頑張って手入力するという何とも原始的な方法で実施しました。
今回はもう少し進化した手法を取りたいと思います。

LLMの選定

真っ先に出てくるのはChat-GPTですが、別の選択肢も検討してみました。
Google,Microsoft(Azure)などの他社API、こちらはChat-GPT同様に文字数(Token)制限があり、あまり状況は変わらなさそうです。
最近では比較的簡単にローカルで動かせるLLMというのも出てきているようでして、こちらはおそらく基本的には文字数制限なく行けそうに思います。
https://llama.meta.com/
日本語の対応も現在進行形で進んでいるようですね。
https://rinna.co.jp/news/2024/05/20240507.html

今回は実験ということもあり、まずはOpen AIのAPIで試すことにしました。
API実行時にはインプットの内容を学習せず、破棄することを明記している点もポイントです。

Open AI API key取得

Open AIのサイトからAPIにサインインします。
https://openai.com/index/openai-api/

APIは有料なので支払い方法を設定し、料金を支払う必要があります。
メニューのSettings -> BillingのPayment methodsからAdd Payment methodで追加しましょう。
Auto rechargeを止めておけば使いすぎることもないので安心です。

メニューのAPI Keys でキーを生成できます。
生成したキーは再度見ることはできません。別途保存する必要があります。

実装

APIのアクセス準備はこれで整いました。

Pythonからのアクセスはシンプルで単純なアクセスであれば難しくありません。
https://zenn.dev/umi_mori/books/chatbot-chatgpt/viewer/openai_chatgpt_api_python

ここで、問題となるのは、Token数の制限があることが挙げられます。
ということは、文章を適切に分割し、最終的に統合して議事録とする必要があります。

調べてみたところ分割を補助するLangChainというツールがあるとのことで、そちらを導入してみます。
下記URLの説明が非常にわかりやすいです。
https://zenn.dev/tsuzukia/articles/05bfdcfcf5bd68

pip install openai
pip install langchain

元の文章が大量であることを考慮し、map reduce法で実装することにいたしました。
とりあえずは雑に実装してみます。

from openai import OpenAI

client = OpenAI()
import os
import math
from langchain_text_splitters import CharacterTextSplitter
from langchain.prompts import PromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.chains.summarize import load_summarize_chain
from langchain.docstore.document import Document

INPUT_DIR = "."
INPUT_TEXT = "kakiokoshi.txt"

def load_txt():
    txt = ""
    filepath = f"{INPUT_DIR}/{INPUT_TEXT}"
    with open(filepath, "r", encoding="utf-8") as fr:
        txt = fr.read()
    return txt

def map_sammaries():
    text = load_txt()

    text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
        chunk_size=1500, chunk_overlap=0 , separator=".", 
    )
    texts = text_splitter.split_text(text)
    map_prompt_template = """あなたは会議の議事録を作成するプロフェッショナルアシスタントです。
                        これから会議の文字起こししたテキストを分割して渡します。
                        テキストは話者分離をしていません。
                        この文章から重要な内容を抽出してください。
                        特に誰が、何を、いつまでに行うかが重要です。
                        抽出は箇条書きではなく、文章で行なってください。
    ------
    {text}
    ------
    """

    map_combine_template="""あなたは会議の議事録を作成するプロフェッショナルアシスタントです。
                        これから会議を要約したテキストを分割して渡します。
                        テキストは話者分離をしていません。
                        この文章を要約し、議事録としてください。
                        特に誰が、何を、いつまでに行うかが重要です。
                        抽出は箇条書きではなく、文章で行なってください。
    ------
    {text}
    ------
    """

    map_first_prompt = PromptTemplate(template=map_prompt_template, input_variables=["text"])
    map_combine_prompt = PromptTemplate(template=map_combine_template, input_variables=["text"])

    map_chain = load_summarize_chain(
        llm=ChatOpenAI(temperature=0,model_name="gpt-3.5-turbo"),
        reduce_llm=ChatOpenAI(temperature=0,model_name="gpt-4"),
        collapse_llm=ChatOpenAI(temperature=0,model_name="gpt-4"),
        chain_type="map_reduce",
        map_prompt=map_first_prompt,
        combine_prompt=map_combine_prompt,
        collapse_prompt=map_combine_prompt,
        token_max=3000,
        verbose=True)

    docs = [Document(page_content=t) for t in texts]
    result=map_chain({"input_documents": docs}, return_only_outputs=True)

    print(result["output_text"])

実行してみたところ下記のエラーが発生しました。
token_maxを減らしてみる、LangChainの使用メソッドを変更するなどしてみましたが、どうにも解消しません。

openai.BadRequestError: Error code: 400 - {'error': {'message': "This model's maximum context length is 16385 tokens.

実装 改良版

仕方が無いため、文章の分割のみをlangchainに実行させ、
map_reduceを簡易的に自力で実装することにしました。
以下のような処理の流れになります。

  • 文字起こしした文章を読み込み、分割
  • 分割した文章をそれぞれ要約
  • 要約した文章を一度まとめ、ざっくりと文章がAPIのTOKENに収まるかを試算
  • あふれそうであれば再帰的に要約
  • 最終的に収まるであろうサイズになったら議事録化

ちょっと整理されていないコードですが、実験ということでご容赦ください。


# 元の文章を要約
def text_summeraize():
    MODEL = "gpt-3.5-turbo"
    CHUNK_SIZE = 3000
    chunk_overlap = 100

    text = load_txt()

    text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
        separator=" ", chunk_size=CHUNK_SIZE, chunk_overlap=chunk_overlap
    )
    text_chunks = text_splitter.split_text(text)
    response_messages = []

    for chunk in text_chunks:
        logger.info(chunk)

        messages = [
            {
                "role": "system",
                "content": """
                    あなたは会議の議事録を作成するプロフェッショナルアシスタントです。
                    これから会議の文字起こししたテキストを分割して渡します。
                    テキストは話者分離をしていません。
                    この文章から重要な内容を抽出してください。
                    特に、数値や人物に関しては明確に記載してください。
                    抽出は文章で行なってください。
                    """,
            },
            {"role": "user", "content": chunk},
        ]
        response = client.chat.completions.create(model=MODEL,
        messages=messages,
        temperature=0)
        response_messages.append(response.choices[0].message.content)
        logger.info(f"chat create: {response.choices[0].message.content}")
    print(response_messages)
    return response_messages

def reduce(msg, model, sys_content):
    logger.info(msg)
    messages = [
        {
            "role": "system",
            "content": sys_content,
        },
        {"role": "user", "content": msg},
    ]
    response = client.chat.completions.create(model=model,
    messages=messages,
    temperature=0)
    logger.info(f"chat create: {response.choices[0].message.content}")
    return response

def map_reduce(summaries:list):
    MODEL = "gpt-3.5-turbo"
    CHUNK_SIZE = 3000
    chunk_overlap = 100

    text = "".join(summaries)

    text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
        separator=" ", chunk_size=CHUNK_SIZE, chunk_overlap=chunk_overlap
    )
    text_chunks = text_splitter.split_text(text)
    sys_content = """
                あなたは会議の議事録を作成するプロフェッショナルアシスタントです。
                これから会議の要約文章を分割して入力します。
                この文章からより重要な内容を抽出してください。
                特に、数値や人物に関しては明確に記載してください。                
                抽出は文章で行なってください。
                """
    response_messages = []

    for chunk in text_chunks:
        response = reduce(chunk, MODEL, sys_content)
        logger.info(f"chat create: {response.choices[0].message.content}")
        response_messages.append(response.choices[0].message.content)

    print(response_messages)
    return response_messages

# 要約された文章をさらに再帰的に要約する
def recursive_reduce(summaries:list):
    TOKEN_MAX = 3000
    text = "".join(summaries)
    # TOKENを試算し、規定値以上であれば再帰的に要約
    # TOKENの判定方法はかなり簡易化しているため、修正が必要
    if len(text) < TOKEN_MAX * 1.5:
        return summaries
    else:
        res_summaries = recursive_reduce(map_reduce(summaries))
        return res_summaries

# 要約した文章を最終的に議事録に落とし込む
def merge_summaries(summaries:list):
    MODEL = "gpt-4"
    text = "".join(summaries)
    sys_content = """
                あなたは会議の議事録を作成するプロフェッショナルアシスタントです。
                これから会議の要約文章を入力します。
                この文章から会議の議事録を作成してください。
                議事録では、結論と、誰が、いつ、何をやるかを明確に記載してください。
                """

    response = reduce(text, MODEL, sys_content)

    logger.info(f"chat create: {response.choices[0].message.content}")
    merged_text = response.choices[0].message.content

    logger.info(merged_text)
    return merged_text

def main():
    # API Key  
    os.environ['OPENAI_API_KEY'] = "xxxx"

    summeraized_texts = text_summeraize()
    reduced_texts = recursive_reduce(summeraized_texts)
    output = merge_summaries(reduced_texts)

    print(output)
    logger.info(output)

if __name__ == "__main__":
    main()

実行結果

先述の実装でとりあえず議事録らしきものを出力することは出来ました。
(トラブル防止のため、文章を載せることは控えます)
…が、どうにも的を射ていないといいますか。
記述の要約としてはなんとなく成立しているので、会議で何が話されたか、の確認はある程度できるのですが、
肝心の結論がどうなったかががいまいち把握できません。
このままでは議事録としての運用は厳しそうです。
また、残念ながら多少、プロンプトをいじった程度では劇的な改善は見られませんでした。

改善案

突き詰めるとアウトプットの精度が問題ですので、考えられる施策を並べてみます。

  • LangChain実行時のエラー原因を突き止め、map reduceをツール任せにする
  • 実行時のプロンプト最適化
  • ローカルLLMを導入しmap reduceをせずにまとめてインプットする
  • インプット(文字起こし)の精度向上、話者分離

いずれも一筋縄ではいかなさそうな項目ですね。
本腰を入れて実験する必要がありそうです。

最後に

あり合わせで議事録を自動作成できないか、という思い付きでやってみましたが、ここまでの結論としましては、
「そんなに簡単には出来ない」というところになるかと思います。
自動化処理フロー自体は組めるものの、シンプルにあり合わせを使用するだけでは精度が出ず、
精度を出そうとするのであれば、ある程度時間をかけてチューニングする必要がありそうです。
それでもほぼチューニング無しでもある程度の手ごたえはありましたので、1年後ぐらいにはまた状況が変わっているかもしれませんね。

参考

About T.H

North Torch株式会社 プログラマ 技術的な経歴は.NETアプリケーションが一番長い。 その他はまだまだ勉強中。