LangChainとNeo4jでシステム連携図を自動生成する方法（３）

![aozora.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/28600395-305e-c9dd-257e-c338c12cdbd2.jpeg)

# はじめに
前回と前々回では、文章と画像をインプットとしてNeo4jでグラフを描画しました。それぞれ一定の成果は得られたものの、商用利用に耐えうる精度には達しませんでした。

https://qiita.com/ogi_kimura/items/1e03b29bb245b553be26

https://qiita.com/ogi_kimura/items/5e51dfbf31ef4f117a9a

今回、新たに考えた方法としては、Excelのシェイプ（円・四角・線）を読み込み、それをNeo4jで活用することです。シェイプを用いれば、線で繋がっている始点と終点のシェイプを特定でき、その情報を基に、より精度の高い「システム連携図」を作成できると思っています。

もちろん、「単にExcelで作成した図をNeo4jに移しているだけでは？」という懸念もありますが、まずは精度を確認してから、その先の改善を検討することにしました。

# プログラム概要
当初は、次のような流れを想定していました。

1. PythonでExcelのシェープ群を読み込み、リレーションの結果をテキスト形式で出力
2. そのテキストをLangChainで読み込み、クエリを生成してNeo4jでグラフを描画

しかし、Pythonのライブラリでは、Excelの線の始点・終点を取得できないことが判明しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/2c6975b2-3213-95bc-bb92-66f1e98a347c.png)

そこでChatGPTに相談したところ、PythonがExcelのマクロを埋め込み、実行できることがわかりました。これまで、JavaやC++では外部で作成したVBAをExcelで実行できないと思っていたので、Pythonの柔軟さには驚きました。

今回の最終的な流れは以下の通りです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/aa537e19-8048-d0e6-60b0-6d2596e0d109.png)

1. PythonでVBAプログラムを作成し、Excelに埋め込む
2. VBAを実行してシェープのリレーション情報をテキスト形式で出力
3. そのテキストをLangChainで読み込み、クエリを生成してNeo4jでグラフを描画


# プログラム詳細

まずは起点となるプログラム(app.py)を紹介します。

```python:app.py
from langchain_community.graphs import Neo4jGraph 
from langchain.chat_models import ChatOpenAI
from langchain_experimental.graph_transformers import LLMGraphTransformer
from new4j_loader import load_excel_fig_text, split_text

 # log.txt → neo4j用テキスト
llm = ChatOpenAI(model= "gpt-3.5-turbo") 

import os
os.environ["NEO4J_URI"] = "neo4j+s://*********.databases.neo4j.io:7687"
os.environ["NEO4J_USERNAME"] = "neo4j"
os.environ["NEO4J_PASSWORD"] = "***********************************"

graph = Neo4jGraph() 
# 最初に全てクリアしておく
graph.query("MATCH (n)  DETACH DELETE n;") 

# ===== Excelファイルの場合 =====
docs = load_excel_fig_text() 

tgt_chunks = split_text(docs) 
llm_transformer = LLMGraphTransformer(llm=llm) 
graph_documents = llm_transformer.convert_to_graph_documents(tgt_chunks) 
graph.add_graph_documents(graph_documents) 
```

前回・前々回と大きな違いはありませんが、`load_excel_fig_text()`という関数を読んでいます。この関数の内容は後ほど説明します。

次に、**ExcelVBA**を埋め込んで、実行するプログラムです。ここが今回は難所でした。後ほど苦労した箇所を紹介します。

```python:excel_graph_creator.py
import win32com.client as win32

def create_text_from_excel_shapes(file_name, log_name):
    # Excelを起動
    excel = win32.Dispatch("Excel.Application")
    excel.Visible = False  # Excelを非表示で実行

    # ----- Excelファイルをオープン -----
    wb = excel.Workbooks.Open(file_name)
    ws = wb.Worksheets(1)

    # ----- VBAコードを埋め込む -----
    vba_code = f"""
    Sub GetArrowConnections()
        Dim stream As Object
        filePath = "{log_name}"
        Set stream = CreateObject("ADODB.Stream")
        With stream
            .Type = 2 ' テキストストリーム
            .Charset = "UTF-8" ' 文字セットをUTF-8に設定
            .Open ' ストリームを開く
            For Each shp In ActiveSheet.Shapes
                If shp.Connector Then
                    Dim startShape As String
                    Dim endShape As String
                    
                    ' 開始点の接続先を取得
                    If Not shp.ConnectorFormat.BeginConnectedShape Is Nothing Then
                        startShape = shp.ConnectorFormat.BeginConnectedShape.Name
                    Else
                        startShape = "None"
                    End If
                    
                    ' 終了点の接続先を取得
                    If Not shp.ConnectorFormat.EndConnectedShape Is Nothing Then
                        endShape = shp.ConnectorFormat.EndConnectedShape.Name
                    Else
                        endShape = "None"
                    End If
                    
                    ' 結果を表示
                    .WriteText "Arrow: " & shp.Name & " " & "Start: " & GetShapeOvalText(startShape) & " " & "End: " & GetShapeOvalText(endShape) & vbCrLf
                End If
            Next shp
            ' ストリームをファイルに保存
            .SaveToFile filePath, 2 ' 2は上書きオプション
            .Close ' ストリームを閉じる
        End With
        Set stream = Nothing
    End Sub

    Function GetShapeOvalText(nm As String) As String
        For Each shp In ActiveSheet.Shapes
            If shp.AutoShapeType = msoShapeOval Then
            If nm = shp.Name Then
                nm = shp.TextEffect.Text
                Exit For
            End If
            End If
        Next shp
        GetShapeOvalText = nm
    End Function

    """

    # ----- VBAコードをモジュールに追加 -----
    vb_module = wb.VBProject.VBComponents.Add(1)  # 標準モジュールを追加
    vb_module.CodeModule.AddFromString(vba_code)

    # ----- VBAマクロを実行 -----
    # 実行させるには、「トラストセンター：アクセスを信頼するをON」と「マクロ設定：すべてのマクロを有効にする」を事前設定
    excel.Application.Run("GetArrowConnections")

    # ----- ファイルを保存 -----
    # 保存するとエラーとなるので、保存は諦める。
    # 特にxlsmファイルを保存する必要もない。
    #wb.SaveAs(r"Book1.xlsm", FileFormat=52)  # xlsm形式で保存 (52はマクロ有効ファイル)

    # -----　Excelを終了(デーモンが残るので、SaveChangesを設定し、インスタンスをdelした)　-----
    wb.Close(SaveChanges=False)
    excel.Application.Quit()
    del wb
    del excel
```

最後に作成したテキストファイル（ここではlog.txt）をPyMuPDFLoaderで読み込み、チャンク化してリスト形式で返します。この部分の処理は、以前の記事で使用したものと同様です。
```python:new4j_loader.py
import glob
import os
from langchain_community.document_loaders import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter 
from langchain.docstore.document import Document 
from langchain.chat_models import ChatOpenAI

import base64
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts.chat import HumanMessagePromptTemplate

from excel_graph_creator import create_text_from_excel_shapes

FIG_LOG_NAME = "demodata/log.txt"

system = ("あなたは有能なアシスタントです。ユーザーの問いに回答してください") 
question = """
    図の中のシステム間の関連を全て列挙してください
"""

# ========== demodata/ *.xlsx の読み込み ==========
def load_excel_fig_text(path: str= "demodata/*.xlsm") -> list: 
    xlsm_resources = [] 
    for file in glob.glob(path): 
        # xlsmファイルを基にオブジェクトを読込み、テキストファイルに
        # ノードとリレーション関係を記述
        create_text_from_excel_shapes(file, FIG_LOG_NAME)   
        print(file) 
        loader = PyMuPDFLoader(FIG_LOG_NAME) 
        pages = loader.load_and_split() 
        file_text = ''.join([x.page_content for x in pages]) 
        doc = Document(page_content=file_text, metadata={'source': file}) 
        xlsm_resources.append(doc) 
    return xlsm_resources 

# ========== テキストのチャンク分割 ==========
def split_text(docs: list) -> list:     
    text_splitter = RecursiveCharacterTextSplitter( 
        chunk_size=700, 
        chunk_overlap=100, 
    ) 
    chunked_resources = text_splitter.split_documents(docs) 
    return chunked_resources 
```

# 苦労したポイント
以下では、`excel_graph_creator.py`の実装で苦労したところを記載します。

### 線シェープの始点・終点に表示されたテキストが取得できない
線シェープ自体は、始点と終点のシェープ（今回は円）のシステムで付与している名称を取得できます。しかし、シェープに表示されている「ノード1」などのテキストは取得できませんでした。そこで、表示されている文字を取得するために、ヘルパー関数`GetShapeOvalText`を作成しました。


### UTF-8の問題
最初は単純にVBAでテキスト形式のファイルを保存していましたが、日本語（例：「ノード1」）を出力する際に文字化けが発生しました。これは、VBAではUTF-8に対応していないためです。そこで、ストリーム方式を適用して、なんとかUTF-8でテキストファイルを保存できるようにしました。

```vb:
Set stream = CreateObject("ADODB.Stream")
With stream
    .Type = 2 ' テキストストリーム
    .Charset = "UTF-8" ' 文字セットをUTF-8に設定
    .Open ' ストリームを開く
    ・・・・・
    ・・・・・
 ```

### トラストセンター
最初にPythonでExcelマクロを埋め込んで実行しようとした際に、権限がないというエラーが発生しました。これを回避するために、Excelのトラストセンターでマクロ実行の設定を緩和する必要がありました。ただし、セキュリティ的に好ましくないため、実行後は元の状態に戻すことを推奨します。

手順は以下の通りです。

まず、Excelの上部にある「ファイル」を押下してください。「その他」があるのでクリックすると、「アカウント」が出てきますので、それを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/f31c8fbe-2b27-dcba-9ca8-863bb76a1eeb.png)

左下の「トラストセンター」をクリックしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/f9f58ab7-f85d-b394-30d2-6ad8b99cc62b.png)

右側に「トラストセンターの設定(I)」ボタンが出てきますので、クリックしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/3fd0c83e-ff26-8598-aca3-75b05a0299bf.png)

左側に「マクロの設定」が出てくるので、クリックしてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/140e80c0-ca8d-c344-b556-450871bd60f2.png)

「すべてのマクロを有効」にチェックを入れ、「開発者向けのマクロ設定」のチェックボックスをONにします。「OK」ボタンをクリックすれば設定内容が反映されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/0f7b8c8b-429b-0285-6ca0-5ea8f766b758.png)


### ファイル保存
ファイル保存時に「中間ファイル」への保存でエラーが発生しました。

```dosbatch:
pywintypes.com_error: (-2147352567, '例外が発生しました。', (0, 'Microsoft Excel', "ファイル '/C4EEB300' にアクセスできません。次のいずれかの理由が考えられます。\n\n• ファイル名またはパスが存在しません。\n• ファイルが他のプログラムによって使用されています。\n• 保存しようとしているブックと同じ名前のブックが現在開かれています。", 'xlmain11.chm', 0, -2146827284), None)
```
ただ、ファイルを保存する必要がなかったため、この処理をコメントアウトしました。
```python
    # ----- ファイルを保存 -----
    # 保存するとエラーとなるので、保存は諦める。
    # 特にxlsmファイルを保存する必要もない。
    #wb.SaveAs(r"Book1.xlsm", FileFormat=52)  # xlsm形式で保存 (52はマクロ有効ファイル)

```

### タスクが残る
Excelが正常に終了しても、バックグラウンドでExcelのタスクが残る問題が発生しました。そのため、2回目に実行するとエラーが発生します。これを防ぐため、`wb`（ワークブック）と`excel`のインスタンスを明示的に`del`するようにしました。

```python:
    # -----　Excelを終了(デーモンが残るので、SaveChangesを設定し、インスタンスをdelした)　-----
    wb.Close(SaveChanges=False)
    excel.Application.Quit()
    del wb
    del excel
```

# サンプル
今回は、以下のようなシェープを利用することにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/8dc26100-0bdb-a46f-2fb6-5a4a0362323e.png)

４つの「ノード」とそれらに対して線でつないでいます。
「システム連携図」を作成する場合は、「ノード」をそのまま「○○システム」などと置き換えても良いでしょう。

# 実行
実行後のテキストファイル(log.txt)を確認してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/854dd0dc-a9e3-43e0-eb38-3d46cbf512cf.png)
意図した結果になっているようです。「ノード」が4つと少ないこともあるのかもしれませんが、パーフェクトです。

次に**Neof4**を確認してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/99564d5b-d6c4-4e8c-7d6d-2da7ca8a6135.png)

一瞬「ん？」と思いましたが、紫が「線(arrow)」、赤が「円」となっているようです。
Excelの中のシェープを確認しましたが、問題なさそうです。

# おわりに
この方法で実施すれば、非常に精度の高い「システム連携図」を作成できそうです。
また、**Neo4j**のクエリを使えば、必要なものだけを抜き出したりすることが出来そうです。
ただ、冒頭でも説明した通り、**Excel**のものを単純に**Neofj**に移しているだけの様にも感じました。
しかし、**Neo4j**のクエリは強力で、「○○システムに関連するシステムを全て洗い出して」という指示ができそうです。データをファイルで管理する方法からデータベースで管理する方法に変わったくらいのパラダイムシフトが起こるかもしれません。

最後までご覧いただき、ありがとうございました。

# 追記
今回使用したサンプルが雑でしたので、もう少し「システム関連図」に近いものを改めて用いることにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/2b50ae26-a7f1-5f07-d44b-3d04e350cdcb.png)

１つ問題があり、「サーバ」や「クライアント」のアイコンには文字を入力できないことが分かり、四角のシェープを追加してそこに連携の線を関連付けることにしました。

### VBAからの出力結果

VBAからの出力結果は以下の様になりました。
想定通りの出力結果です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/15f68baa-519b-de99-8e78-7a84b8024bd6.png)

次に、**Neo4j**を確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/6a20d213-15a6-ccd1-6830-038bd1a41399.png)

惜しいところまで来ています。
矢印の向きが逆転しているところがあるのと、「データベース3」への線が「アプリサーバ」という架空のサーバから接続されていました。**LangChain**の中で問題があったのだと推測します。**gpt-4o**を利用すると精度が上がるのかもしれません。