クローズド環境でfaster-whisper + ローカルLLMを用いた自動議事録作成を試してみた

# 背景
プロジェクト配属時の議事録作成業務の説明を聞き、「やりたくねー」と思ってしまいました。
自分はタイピングも早くないし、聞き逃しが多いのでなんとかして逃れよう楽しようと思い、AIに丸投げしようとした次第です。

顧客からは外部に会議の情報を渡さず、支給されたPC内で作業するように厳命されているため、GPUの使用やデータ通信を行わない議事録作成ツールを作成することになりました。
最初は適当な記事を丸パクリしようとしましたが、ほとんどの記事ではGoogleColabやGPU、APIなどを使用しており、そのまま流用できる記事がなかったため、自身で試してみることにしました。

# 前提条件
<dl>
    <dt>
        外部と通信しない
    </dt>
        <dd>
            GoogleDrive等のオンラインストレージやAPIもアウト
        </dd>
    <dt>
        GPUは使用禁止
    </dt>
        <dd>
            一般的に業務で使用するノートPCはGPU非搭載のものがほとんどのため
        </dd>
    <dt>
        処理時間の制限はなし
    </dt>
        <dd>
            社内作業等の別PCでの業務中に実行する想定のため
        </dd>
</dl>

上二つがかなりキツイ縛りですね…
最後の条件については処理時間は短ければ短いほど良いですが、上二つの条件の代償を覚悟すると妥協せざるを得ない部分です。

# 処理の流れ
大まかな流れとしては、

会議を録音
↓
faster-whisperで文字起こし
↓
文字起こしの内容をローカルLLMで議事録形式に要約

になります。

## 環境
OS：windows10
CPU：intel Core i7 12650H
メモリ：32GB
Python：3.9.13

# faster-whisperで文字起こし
openAIの本家whisperを有志が軽量化したfaster-whisperを使用しました。
今回の条件ではCPUしか使えない分、~~処理時間をできるだけ抑えるために処理時間が短いモデルとして採用しました。~~
記事作成時に気づいたのですが、CPUのみ使用する場合は本家whisperの方が速いらしいです... m_ _m

https://zenn.dev/piment/articles/24269616aa9c04

導入手順はこちらの記事を参考にさせていただきました。

https://qiita.com/taiki_i/items/3d2d0d0b2dd79059f30e

## パッケージのダウンロード
以下のコマンドでGithubからインストールするだけ
今回はCPUを使用するため、CUDAやcuDNNの設定は不要
```:command
pip install yt-dlp
pip install git+https://github.com/guillaumekln/faster-whisper.git
```

## 文字起こし用コード
任意のディレクトリで以下のコードを実行すると文字起こしができます。
```python:transcription.py
from faster_whisper import WhisperModel

model_size = "large-v3"

# Run on GPU with FP16
# model = WhisperModel(model_size, device="cuda", compute_type="float16")
# or run on GPU with INT8
# model = WhisperModel(model_size, device="cuda", compute_type="int8_float16")
# or run on CPU with INT8
model = WhisperModel(model_size, device="cpu", compute_type="int8")

segments, info = model.transcribe(
    "Audio_file_path",
    beam_size = 5,
    language = "ja",
    vad_filter = True,
    hallucination_silence_threshold = 0.2,
)

print("Detected language '%s' with probability %f" % (info.language, info.language_probability))

text_pass = "output_txt_path"

start = [0, 0, 0]
end = [0, 0, 0]

with open(text_pass, 'w', encoding='utf-8') as f:
    for segment in segments:
        start[0] = int(segment.start // 3600)
        start[1] = int((segment.start % 3600) // 60)
        start[2] = int(segment.start % 60)
        end[0] = int(segment.end // 3600)
        end[1] = int((segment.end % 3600) // 60)
        end[2] = int(segment.end % 60)
        f.write("[%s:%s:%s -> %s:%s:%s] %s\n"

        % (str(start[0]).zfill(2), str(start[1]).zfill(2), str(start[2]).zfill(2), str(end[0]).zfill(2), str(end[1]).zfill(2), str(end[2]).zfill(2), segment.text))
        print("[%s:%s:%s -> %s:%s:%s] %s\n"
        % (str(start[0]).zfill(2), str(start[1]).zfill(2), str(start[2]).zfill(2), str(end[0]).zfill(2), str(end[1]).zfill(2), str(end[2]).zfill(2), segment.text)) 
```

# ローカルLLMで議事録形式に要約
文字起こしだけでも楽にはなりますが、どうせなら議事録形式にまとめてほしかったのでローカルLLMで要約させます。

## 環境構築
こちらの記事を参考にさせていただきました。

https://qiita.com/kenta1984/items/7233f8ec9d256f4fa4f7


コマンドプロンプトでllama-cpp-pythonをインストール
```:command
pip install llama-cpp-python
```

## モデルダウンロード
モデルはGPT-3.5 Turboに匹敵する日本語性能があるらしい「Llama-3-ELYZA-JP」シリーズの8B版を使用しました。
（ちなみに「Llama-3-ELYZA-JP」シリーズの70B版はGPT-4を上回るらしい）

Hugging-Faceで「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」をダウンロードして、作業ディレクトリ下に作成した「model」ディレクトリに格納します。

https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B-GGUF/tree/main

## 議事録作成用コード

```python:summarization.py
from llama_cpp import Llama
from tqdm import tqdm
# LLMのインスタンスを作成
llm = Llama(
   model_path="/model/Llama-3-ELYZA-JP-8B-q4_k_m.gguf",
   chat_format="llama-3",
   n_ctx=1024,
)

def split_text(text, max_length):
   """テキストを指定した最大長さで分割する"""
   words = text.split()  # 空白で分割
   chunks = []
   current_chunk = []
   current_length = 0
   for word in words:
       # 現在のトークン数が最大長さを超えないように単語を追加
       if current_length + len(word) + 1 > max_length:
           chunks.append(' '.join(current_chunk))
           current_chunk = [word]
           current_length = len(word)
       else:
           current_chunk.append(word)
           current_length += len(word) + 1  # +1はスペースの分
   if current_chunk:
       chunks.append(' '.join(current_chunk))
   return chunks
   
def summarize_text(text):
   """テキストを要約する"""
   response = llm.create_chat_completion(
       messages=[
           {
               "role": "system",
               "content": "あなたは優秀な日本人のアシスタントです。会議の発言などのテキストから、他愛のない雑談や文字起こしミスを除いて要約してください。。特に指示が無い場合は、常に日本語で回答してください。",
           },
           {
               "role": "user",
               "content": f"以下の文章を要約してください。ただし、情報が欠落させず議事録のように箇条書きで要約してください。\
               # 文章\
               {text}",
           },
       ],
       max_tokens=1024,
   )
   return response["choices"][0]["message"]["content"]
   
def recursive_summarize(text, max_chunk_size=900):
   """テキストを再帰的に要約する"""
   # テキストを分割
   chunks = split_text(text, max_chunk_size)
   # 各チャンクを要約
   chunk_summaries = []
   for chunk in tqdm(chunks, desc="Processing Chunks", unit="chunk"):
       chunk_summary = summarize_text(chunk)
       chunk_summaries.append(chunk_summary)
   # チャンク要約を統合
   combined_summary = ' '.join(chunk_summaries)
   # 統合された要約が長すぎる場合、再度分割して再帰的に要約
   if len(combined_summary) > max_chunk_size:
       return recursive_summarize(combined_summary, max_chunk_size)
   else:
       return summarize_text(combined_summary)
       
def summarize_text_from_file(file_path):
   # ファイルからテキストを読み込む
   with open(file_path, 'r', encoding='utf-8') as file:
       text = file.read()
   # 再帰的に要約
   final_summary = recursive_summarize(text)
   return final_summary
   
# 要約したいテキストファイルのパス
file_path = '/txt/sample.txt'
# 要約を実行
summary = summarize_text_from_file(file_path)
print("最終的な議事録:")
print(summary)
```

文字起こししたテキストをそのまま要約するとほぼ確実にトークン不足になるため、「Map Reduce」という手法を用いて要約していきます。
Map Reduceは長い文章を複数の文章に分割して要約し、最後に要約した文章をまとめて再度要約する手法です。
これで、かなり長いテキストでも要約できるはずです。

詳細は以下の記事で紹介されています。

https://note.com/izai/n/n23698de159c5

# 議事録作成をしてみた結果
本当は作成した議事録をそのまま添付したかったのですが、機密情報が含まれているため許可が下りませんでしたm_ _m（後日、別のサンプル音声での議事録を添付いたします。）

結論から言うと、以下のような課題点が見つかりました。
- 処理時間が長い（約30分の会議の音声で、文字起こしに70分程度、要約に40分程度かかりました）
- ちょいちょい誤字がある
- 同じフレーズを繰り返し文字起こしをする現象（ハルシネーション）がある
- 議事録の形になっていない（だれがいつ発言したか記録されていない）

処理時間は想定通りというか、CPUのみの使用なのでしょうがないですね。
むしろ2,3時間くらいかかると思っていたので思ったより速い印象を受けました。

誤字に関しては人間でも聞き取りにくいような部分があったので、音質の改善や業務用後の辞書を作ることで改善されると思われます。

ハルシネーションの方は、いろいろ調べてみましたが、根本的な解決が見つかりませんでした。
一応、特定のモードの有効化や文章を短くすることでハルシネーションを抑制することができるそうですが、完全には防止できないそうです…

最後の議事録形式についてですが、LLMでの要約時のプロンプトでもっと明確にフォーマットを説明するべきでした…
また、発言者にの特定は現状のコードでは行っていないため、しょうがないです。
「pyannote」という話者分類モデルを使用すれば可能と思われるので、いつか試してみます。