0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PDF → LLM → Pandas変換の試行錯誤:JSON化は失敗、Markdown解析に挑戦中

Posted at

user:

先日の仕切り直しをしましょう。まずは前回の記録 https://qiita.com/maskot1977/items/6c5dc0dbcda5032e78f0 をよく読み返してください。

assistant:

了解しました、先輩。ご指定のQiita記事を読み返しました(要点を抜き出して仕切り直しプランを作っておきました)。記事の主要ポイントと、先ほど出たエラーへの即効対応案を下にまとめますね。(Qiita)

要点(ざっくり)

  • LangChain のチェーン呼び出し仕様差(.invoke() を使うべき場合と __call__ / キー名の違い)がエラーの主要因になっている。記事はこの点を中心に解説しています。(Qiita)
  • LLMが返すJSONを eval で処理するのは危険なので、json.loadsast.literal_eval の順でサニタイズする手法が推奨されている(壊れた/装飾付きJSONへの対処も記事で扱いあり)。(Qiita)
  • PDF抽出は pdfplumber(表に強い)や PyMuPDF が有力。OCRが必要なスキャンPDFは別途Tesseract等のフォールバックを入れるべき、日付変換は pd.to_datetime(..., errors="coerce") のように堅牢化。(Qiita)
  • 実務的な対処は(1)環境(langchain 等)のバージョン確認、(2)呼び出しを堅牢化(複数キーを試すラッパー)、(3)LLM出力の頑健なパース、(4)ログ出力と小さなテストセットでの反復、という順序が短時間で効果的、という項目です。(Qiita)

今すぐできる修正(優先度高)

  1. langchain のバージョン確認(Colabで):
import langchain
print(langchain.__version__)
  1. safe_invoke を記事どおり「まず .invoke()、かつキー名は 'input' → 'query' → 'question' の順で試す」ように修正。__call__ の旧フォールバックは警告や不整合を招くため削除推奨。(Qiita)

以下、このままコピペできる修正版(最小変更)です:

def safe_invoke(qa_chain, prompt_text: str):
    """LangChainバージョン差を吸収して呼び出す(改良版)"""
    last_exc = None
    for key in ("input", "query", "question"):
        try:
            # 最近のチェーンは .invoke() を期待することが多い
            return qa_chain.invoke({key: prompt_text})
        except Exception as e:
            last_exc = e
    # どれもダメなら明確に例外を上げる(デバッグログとともに)
    raise last_exc if last_exc else RuntimeError("safe_invoke failed")
  1. JSONパースは json.loadsast.literal_eval → 文字置換での再試行、という多段サニタイズを残す(記事の雛形に沿う)。パース失敗時は生のLLM出力をログ(またはファイル)に出して手で確認できるようにしてください。(Qiita)

次の実務ステップ(私の提案)

  • 先輩のColabで上の langchain.__version__ 出力を教えてください(バージョンが分かればより確実に当てられます)。
  • すでに実行した際のエラーメッセージ全文(もしまだ手元にあれば)か、df.head() の結果を見せていただければ、次に「項目名正規化」「壊れたJSONの自動修復ルール」「OCRフォールバック」のどれにまず着手するかを優先順位付けします。(Qiita)

先輩、読み返しは完了です♪ そのまま修正版を当ててみてもいいですし、私にログとバージョンを送ってくださればこちらで具体的に直します。どうしますか?

user:

ありがとうございます。順番に報告しますね。まず

import langchain
print(langchain.__version__)

の結果は、次の通りでした。

0.3.27

次に、以下のコードを実行しました。

!pip install pdfplumber langchain-community langchain_openai langchain_huggingface faiss-cpu

import json
from google.colab import drive
from langchain_openai import AzureChatOpenAI
from langchain_huggingface import HuggingFaceEmbeddings

# =============================
# ✅ Azure OpenAI 設定
# =============================
CONFIG_PATH = "/content/drive/MyDrive/path_config/config.json"
drive.mount("/content/drive")

with open(CONFIG_PATH, "r") as f:
    config = json.load(f)

azure_api_key = config["OPENAI_API_KEY"]
azure_api_version = config["OPENAI_API_VERSION"]
azure_endpoint = config["OPENAI_API_BASE"]
azure_model = config["OPENAI_API_ENGINE"]

def get_llm(temperature: float = 0.5) -> AzureChatOpenAI:
    """LangChain 用 Azure OpenAI クライアントを返す"""
    return AzureChatOpenAI(
        deployment_name=azure_model,
        openai_api_key=azure_api_key,
        openai_api_version=azure_api_version,
        azure_endpoint=azure_endpoint,
        temperature=temperature,
    )

# =============================
# ✅ Embeddings 設定
# =============================
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)

# --- 堅牢版雛形(Colab / Databricks で実行可能) ---
import os, glob, re, json, ast
import pdfplumber
import pandas as pd
from langchain.docstore.document import Document
from langchain.vectorstores import FAISS
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
import io

def try_parse_table(raw_text: str):
    # Markdownの表だけ抜き出す
    match = re.search(r"\|.*?\|", raw_text, re.S)
    if not match:
        return None
    table_text = match.group(0)
    # Pandasで読み込み
    df = pd.read_csv(io.StringIO(table_text), sep="|").dropna(axis=1, how="all")
    return df

def load_pdfs(pdf_path: str):
    docs = []
    for file in glob.glob(os.path.join(pdf_path, "*.pdf")):
        with pdfplumber.open(file) as pdf:
            pages = [p.extract_text() for p in pdf.pages if p.extract_text()]
            text = "\n".join(pages)
        docs.append(Document(page_content=text, metadata={"source": file}))
    return docs

def build_retriever(docs, embeddings):
    vectorstore = FAISS.from_documents(docs, embeddings)
    return vectorstore.as_retriever(search_kwargs={"k": 5})

def extract_json_from_text(text: str):
    # remove markdown code fences and common noise
    text = re.sub(r"```(?:json)?\n", "", text, flags=re.IGNORECASE)
    text = text.replace("```", "")
    # locate first JSON array (simple heuristic)
    start = text.find('[')
    end = text.rfind(']')
    if start == -1 or end == -1 or end <= start:
        raise ValueError("No JSON array found")
    candidate = text[start:end+1]
    # try json.loads -> ast.literal_eval -> minor sanitization
    try:
        return json.loads(candidate)
    except json.JSONDecodeError:
        try:
            return ast.literal_eval(candidate)
        except Exception:
            c2 = candidate.replace("'", '"')
            c2 = re.sub(r",\s*}", "}", c2)
            c2 = re.sub(r",\s*]", "]", c2)
            return json.loads(c2)  # may still raise

def extract_text_from_qa_result(res):
    if isinstance(res, str):
        return res
    if isinstance(res, dict):
        for key in ("result", "output_text", "text", "answer", "output"):
            v = res.get(key)
            if isinstance(v, str) and v.strip():
                return v
        # fallback to stringification
    return str(res)

def safe_invoke(qa_chain, prompt_text: str):
    """LangChainバージョン差を吸収して呼び出す(改良版)"""
    last_exc = None
    for key in ("input", "query", "question"):
        try:
            # 最近のチェーンは .invoke() を期待することが多い
            return qa_chain.invoke({key: prompt_text})
        except Exception as e:
            last_exc = e
    # どれもダメなら明確に例外を上げる(デバッグログとともに)
    raise last_exc if last_exc else RuntimeError("safe_invoke failed")
    

def parse_to_dataframe(results):
    all_data = []
    for res in results:
        text = extract_text_from_qa_result(res)
        try:
            data_list = extract_json_from_text(text)
            if not isinstance(data_list, list):
                raise ValueError("LLM returned non-list JSON")
            all_data.extend(data_list)
        except Exception as e:
            print("WARN: JSON parse failed:", e)
            print("RAW (truncated):", text[:400])
    df = pd.DataFrame(all_data)
    if not df.empty and "年月" in df.columns:
        df["年月"] = pd.to_datetime(df["年月"], format="%Y-%m", errors="coerce")
    return df

def get_monthly_sales_dataframe(pdf_path: str, embeddings, llm):
    docs = load_pdfs(pdf_path)
    retriever = build_retriever(docs, embeddings)

    # --- 修正版 PromptTemplate ---
    template = """
    次の文書は某企業の月次売上速報です。
    あなたの役割は、この文書から「年月」「項目」「値」を抽出して、
    必ず **JSON配列のみ** を返すことです。
    説明や表やMarkdownは禁止です。JSON配列以外は禁止。

    出力例:
    [
      {"年月":"2024-09","項目":"売上高前年比","":100.8},
      {"年月":"2024-10","項目":"売上高前年比","":102.5}
    ]

    文書:
    {context}

    質問:
    {query}
    """

    prompt = PromptTemplate(
        template=template,
        input_variables=["context", "question"]
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=retriever,
        chain_type="stuff"
    )

    results = []
    for doc in docs:
        content_snippet = doc.page_content[:4000]
        res = safe_invoke(
            qa_chain,
            "この文書から月次売上データを抽出して\n\n" + content_snippet
        )

        # --- ここで文字列化 ---
        output_text = res["result"] if isinstance(res, dict) and "result" in res else str(res)

        parsed = try_parse_table(output_text)
        results.append(parsed)

    return parse_to_dataframe(results)
# --- end of snippet ---

llm = get_llm(temperature=0)
df = get_monthly_sales_dataframe(PDF_PATH, embeddings, llm)
print(df.head())

結果は次の通りでした。

WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: No JSON array found
RAW (truncated): None
WARN: JSON parse failed: No JSON array found
RAW (truncated): None
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
WARN: JSON parse failed: No JSON array found
RAW (truncated): None
WARN: JSON parse failed: Extra data: line 2 column 1 (char 3)
RAW (truncated): Empty DataFrame
Columns: []
Index: []
Empty DataFrame
Columns: []
Index: []

assistant:

なるほど、状況よく分かりました。報告ありがとうございます、先輩。
ポイントを整理しますね。


今回起きていること

  • LangChain 0.3.27 をご利用中(最新寄り)。.invoke() 周りは対応済み。

  • ただし、get_monthly_sales_dataframe 内の処理を見ると、

    • safe_invoke で取得した resそのまま try_parse_table に回している
    • try_parse_tableMarkdown表パターンしか探していないので、JSON返答を全てスルーしている。
    • 結果、None や空のDataFrameが返ってきて、parse_to_dataframe 側で「JSON配列が見つからない」と警告が出ている。

つまり、「プロンプトではJSONだけ返してね」と言っているのに、コードは「Markdown表を探す」ようになっていて不一致なんです。


修正方針

  1. Markdown表処理は一旦外す。今回欲しいのはJSONなので、try_parse_table は不要。
  2. safe_invoke の結果をそのまま extract_text_from_qa_resultextract_json_from_text に流す。
  3. parse_to_dataframe にはJSONから作ったリストを渡す。

修正版コード(差分)

def get_monthly_sales_dataframe(pdf_path: str, embeddings, llm):
    docs = load_pdfs(pdf_path)
    retriever = build_retriever(docs, embeddings)

    # --- PromptTemplateをまだ定義してるけど、RetrievalQAのデフォルトでOK ---
    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=retriever,
        chain_type="stuff"
    )

    results = []
    for doc in docs:
        content_snippet = doc.page_content[:4000]
        res = safe_invoke(
            qa_chain,
            "この文書から月次売上データを抽出して\n\n" + content_snippet
        )

        # 結果を文字列化して追加
        output_text = extract_text_from_qa_result(res)
        results.append(output_text)

    # ここでまとめてJSONパース
    return parse_to_dataframe(results)

追加の確認ポイント

  • プロンプトがまだ甘いかもしれません。
    今は「JSONだけ返せ」と書いてますが、LLMは時々「了解しました」や「以下です」と書いてきます。その場合、extract_json_from_text のヒューリスティックで弾かれることがあります。
    → 対策は「JSON以外は絶対書くな」と繰り返すか、あるいは JsonOutputParser を使う方法があります。

次のステップ(私からのお願い)

  • 上の修正版に差し替えてもう一度実行してみてください。
  • その際の WARN 出力(もし出たら)と、df.head() の中身を教えてもらえますか?

そしたら、「JSONが返ってるけど微妙に壊れている」のか、「まだLLMが素直にJSONだけ返してくれていない」のか、どっちか判定できます。


先輩、ここまでくるとほぼ出口が見えてますよ✨
修正版を回してみて、その出力を見せてもらえますか?

user:

df の中身は空のままです。出力結果をお示ししますね。LLMは表を返したり箇条書きで返したり、いろいろするようです。

WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2025年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月 | 売上前年比(%) |
|----|----------------|
| 2024年9月 | 100.8 |
| 2024年10月 | 102.5 |
| 2024年11月 | 107.9 |
| 1Q | 103.9 |
| 2024年12月 | 106.9 |
| 2025年1月 | 107.3 |
| 2025年2月 | 107.0 |
| 2Q | 107.1 |
| 上期計 | 105.6 |
| 2025年3月 | 103.0 |
| 2025年4月 | 101.9 |
| 2025年5月 | 103.8 |
| 3Q | 102.9 |
| 2025年6月 | 105.0 |
| 2025年7月 | 94.7 |
| 2025年8月 | 109.
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2024年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月 | 2023年 | 2024年 |
|----|--------|--------|
| 9月 | 98.8   | 101.2  |
| 10月 | 101.2  | 104.6  |
| 11月 | 104.6  | 107.9  |
| 1Q | 101.5  | 103.9  |
| 12月 | 100.3  | 106.9  |
| 1月 | 97.7   | 107.3  |
| 2月 | 105.0  | 107.0  |
| 2Q | 100.7  | 107.1  |
| 上期計 | 101.1  | 105.6  |
| 3月 | 110.6  | 103.0  |
| 4月 | 108.9  | 101.9  |
| 5月 | 107.4  
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2023年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月 | 売上前年比(%) |
|----|----------------|
| 9月 | 95.4          |
| 10月 | 102.6         |
| 11月 | 102.9         |
| 12月 | 100.6         |
| 1月 | 99.6          |
| 2月 | 99.6          |
| 3月 | 96.8          |
| 4月 | 99.0          |
| 5月 | 97.8          |
| 6月 | 100.0         |
| 7月 | 106.7         |
| 8月 | 103.5         |

### 2. 売上高(全店
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2022年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月 | 売上高前年比(%) |
|----|------------------|
| 9月 | 92.9 |
| 10月 | 99.9 |
| 11月 | 84.8 |
| 1Q | 92.2 |
| 12月 | 94.1 |
| 1月 | 102.2 |
| 2月 | 99.2 |
| 2Q | 98.1 |
| 上期計 | 95.3 |
| 3月 | 96.1 |
| 4月 | 99.3 |
| 5月 | 93.5 |
| 3Q | 96.2 |
| 6月 | 102.4 |
| 7月 | 98.8 |
| 8月 | 101.6 |
| 4Q | 100.8 |
| 下期計 | 98.4 |
| 累計 | 96.8 |

### 2. 売上高(全店前
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2021年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 2020年 | 2021年 |
|----------|--------|--------|
| 9月      | 64.8   | 92.9   |
| 10月     | 126.8  | 99.9   |
| 11月     | 115.5  | 84.8   |
| 1Q計     | 94.9   | 92.2   |
| 12月     | 101.6  | 94.1   |
| 1月      | 96.3   | 102.2  |
| 2月      | 97.3   | 99.2   |
| 2Q計     | 98.7   | 98.1   |
| 上期計   | 96.8   | 95.3   |
| 3月      | 1
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2020年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 2019年 | 2020年 |
|----------|--------|--------|
| 9月      | 153.3  | 80.8   |
| 10月     | 80.8   | 95.8   |
| 11月     | 95.8   | 109.9  |
| 1Q       | 109.9  | 79.0   |
| 12月     | 79.0   | 100.2  |
| 1月      | 100.2  | 101.8  |
| 2月      | 101.8  | 91.0   |
| 2Q       | 91.0   | 99.3   |
| 上期計   | 99.3   | 80.9   |
| 3月      |
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、2019年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 売上前年比(%) |
|----------|------------------|
| 2018年9月 | 108.6           |
| 2018年10月 | 101.9           |
| 2018年11月 | 101.6           |
| 1Q        | 103.9           |
| 2018年12月 | 121.1           |
| 2019年1月  | 100.2           |
| 2019年2月  | 100.5           |
| 2Q        | 108.7           |
| 上期計     | 106.6           |
| 2019
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、平成30年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月 | 平成29年 | 平成30年 |
|----|----------|----------|
| 9月 | 105.6%   | -        |
| 10月 | 107.2%  | -        |
| 11月 | 112.0%  | -        |
| 1Q | 108.3%    | -        |
| 12月 | 112.1%  | -        |
| 1月 | 109.5%   | -        |
| 2月 | 111.6%   | -        |
| 2Q | 111.1%    | -        |
| 上期計 | 109.8% | -        |
| 3月 | 110.6%   | -        
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成29年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 売上高(前年比) |
|----------|------------------|
| 9月      | 101.6%          |
| 10月     | 102.0%          |
| 11月     | 98.0%           |
| 1Q       | 100.5%          |
| 12月     | 99.4%           |
| 1月      | 99.7%           |
| 2月      | 99.8%           |
| 2Q       | 99.6%           |
| 上期計   | 100.0%          |
| 3月      | 104.
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成28年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 売上高(前年比) |
|----------|------------------|
| 9月      | 99.3%           |
| 10月     | 102.9%          |
| 11月     | 96.0%           |
| 1Q       | 99.2%           |
| 12月     | 98.6%           |
| 1月      | 104.0%          |
| 2月      | 100.2%          |
| 2Q       | 100.8%          |
| 上期計   | 100.1%          |
| 3月      | 94.4
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成27年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
- **平成26年**
  - 9月: 95.2%
  - 10月: 95.9%
  - 11月: 98.1%
  - 1Q: 96.5%
  - 12月: 92.9%
  - 1月: 91.4%
  - 2月: 88.7%
  - 2Q: 91.2%
  - 上期計: 93.6%
  - 3月: 62.8%
  - 4月: 118.3%
  - 5月: 109.0%
  - 3Q: 87.2%
  - 6月: 97.2%
  - 7月: 104.6%
  - 8月: 102.6%
  - 4Q: 101.6%
  - 下期計: 93.9%
  - 累計: 93.7%

- **平成27年**
  - 9月: 99.5%
  - 10月: 99.4%
  - 11月: 10
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成26年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
- **9月**: 95.5%
- **10月**: 97.4%
- **11月**: 99.1%
- **1Q**: 97.4%
- **12月**: 97.8%
- **1月**: 105.6%
- **2月**: 113.8%
- **2Q**: 104.3%
- **上期計**: 101.1%
- **3月**: 152.7%
- **4月**: 83.1%
- **5月**: 90.3%
- **3Q**: 111.8%
- **6月**: 87.7%
- **7月**: 93.0%
- **8月**: 90.5%
- **4Q**: 102.8%
- **下期計**: 101.9%
- **累計**: 101.9%

### 2. 売上高(全店前年比
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は、平成25年8月期の月次売上データを抽出したものです。

### 1. 売上高(全店前年比)
| 月       | 売上高(前年比) |
|----------|------------------|
| 9月      | 93.6%           |
| 10月     | 89.5%           |
| 11月     | 98.5%           |
| 1Q       | 93.9%           |
| 12月     | 93.3%           |
| 1月      | 93.7%           |
| 2月      | 94.5%           |
| 2Q       | 93.8%           |
| 上期計   | 93.8%           |
| 3月      | 97.
WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成24年8月期の月次売上データです。

### 売上高の状況(全店前年比)
- **平成23年**
  - 9月: 79.8%
  - 10月: 74.5%
  - 11月: 55.8%
  - 1Q: 68.2%
  - 12月: 91.4%
  - 1月: 87.6%
  - 2月: 87.3%
  - 2Q: 89.2%
  - 上期計: 78.1%
  - 3月: 83.5%
  - 4月: 83.7%
  - 5月: 79.6%
  - 3Q: 82.4%
  - 6月: 71.4%
  - 7月: 75.4%
  - 8月: 89.7%
  - 4Q: 77.9%
  - 下期計: 80.1%
  - 累計: 79.1%

### 品目別売上高の状況(全店前年比)
#### 音響映像商品
- **平成23年**
  - 9月: 66.8%
  - 10月: 53.0%

WARN: JSON parse failed: No JSON array found
RAW (truncated): 以下は平成23年8月期の月次売上データです。

### 売上高の状況(全店前年比)
- **9月**: 103.9%
- **10月**: 112.3%
- **11月**: 145.0%
- **1Q**: 121.0%
- **12月**: 90.0%
- **1月**: 93.2%
- **2月**: 97.8%
- **2Q**: 92.9%
- **上期計**: 105.8%
- **3月**: 87.9%
- **4月**: 96.8%
- **5月**: 97.2%
- **3Q**: 93.4%
- **6月**: 107.2%
- **7月**: 106.6%
- **8月**: 106.9%
- **4Q**: 98.8%
- **下期計**: 102.6%
- **累計**: 102.6%

### 品目別売上高の状況(全店前年比)
#### 音響映像商品
- **9
Empty DataFrame
Columns: []
Index: []

assistant:

なるほど、これははっきりしましたね先輩。
いま LLM が返しているのは Markdownの表や箇条書き で、JSONじゃないから parse_to_dataframe が空になってます。


問題点

  • プロンプトでは「JSONで返して」と言ってるのに、実際は 表やテキスト で返ってきている。
  • 今の extract_json_from_text は JSON しか探さないから、Markdown表や箇条書きは完全に無視してしまっている。

解決の道筋

アプローチは大きく2つあります:

1. プロンプト強化で必ずJSONを返させる

  • LangChain の StructuredOutputParserJsonOutputParser を使う。

  • これなら LLM 側が余計なMarkdownや文章を出すのを防ぎやすい。
    例:

    from langchain.output_parsers import JsonOutputParser
    from pydantic import BaseModel, Field
    
    class MonthlySales(BaseModel):
        month: str = Field(description="")
        sales: float = Field(description="売上高")
    
    parser = JsonOutputParser(pydantic_object=MonthlySales)
    
    prompt = PromptTemplate(
        template="次の文書から月次売上データを抽出し、JSONで返してください。\n{context}\n{format_instructions}",
        input_variables=["context"],
        partial_variables={"format_instructions": parser.get_format_instructions()}
    )
    

    こうすると、parser が JSON をパースしてくれます。


2. Markdown表や箇条書きもパースできるようにする

  • 今のコードに try_parse_table を復活させる。
  • extract_json_from_text → ダメなら try_parse_table の順で試す。
  • pandas.read_html で Markdown表を拾えたり、正規表現で | 区切りを pandas に変換する方法もあります。

例:

def parse_to_dataframe(outputs):
    rows = []
    for text in outputs:
        # まずJSONを探す
        json_data = extract_json_from_text(text)
        if json_data:
            rows.extend(json_data)
            continue

        # JSONなければMarkdown表を探す
        df_table = try_parse_table(text)
        if df_table is not None and not df_table.empty:
            rows.extend(df_table.to_dict(orient="records"))
    
    return pd.DataFrame(rows)

どちらを選ぶ?

  • プロンプトが効きやすいLLM(GPT-4系やClaudeなど)なら ① JSON専用に寄せるのが堅実。
  • プロンプトを守らないことが多いLLMなら ② JSON+Markdown両対応にするのが安心。

✨次の一手✨
私のおすすめは ② 両対応にしてまず動くようにする → その後、 ① プロンプトを強化 してJSONオンリーに収束させる流れです。


先輩、どっちのルートで進めたいですか?
「今はとにかく表をDataFrameにしたい」なら②でコードを書きますし、
「JSONできちんと返す訓練をしたい」なら①を推し進めましょう。

user:

ありがとうございます。②を採用して parse_to_dataframe 関数を置き換えましたが、次のエラーになりました。

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/tmp/ipython-input-1889382811.py in <cell line: 0>()
      1 llm = get_llm(temperature=0)
----> 2 df = get_monthly_sales_dataframe(PDF_PATH, embeddings, llm)
      3 print(df.head())

2 frames
/tmp/ipython-input-3793388976.py in extract_json_from_text(text)
     80     end = text.rfind(']')
     81     if start == -1 or end == -1 or end <= start:
---> 82         raise ValueError("No JSON array found")
     83     candidate = text[start:end+1]
     84     # try json.loads -> ast.literal_eval -> minor sanitization

ValueError: No JSON array found

assistant:

なるほど、エラー原因はハッキリしていますよ先輩。


今の動き

  • parse_to_dataframe 内でまず extract_json_from_text(text) を呼んでいる。
  • でも LLM 出力に JSON 配列が無いので、extract_json_from_text例外を投げて終了
  • だから「JSONがなければMarkdown表に回す」というフォールバックに到達できていない。

修正方針

extract_json_from_text が失敗したら例外を握りつぶして None を返すようにすればOKです。
エラーを上げずに「JSONなし」と判断させて、次に try_parse_table を試せるようにします。


修正版 parse_to_dataframe

def parse_to_dataframe(outputs):
    rows = []
    for text in outputs:
        json_data = None
        try:
            json_data = extract_json_from_text(text)
        except Exception:
            # JSONが見つからなければ無視して次の方法を試す
            json_data = None

        if json_data:
            rows.extend(json_data)
            continue

        # JSONが無ければMarkdown表を探す
        df_table = try_parse_table(text)
        if df_table is not None and not df_table.empty:
            rows.extend(df_table.to_dict(orient="records"))
    
    return pd.DataFrame(rows)

追加提案

もし extract_json_from_text 側を直接直すなら、
raise ValueError("No JSON array found")return None に置き換えるだけでもOKです。


✨これで「JSONなし → Markdown表に回す」という流れがちゃんと動くはずです。
先輩、この修正版で再実行して、今度は df.head() の結果を見せてもらえますか?

user:

ありがとうございます。修正版 parse_to_dataframeを適用して実行しました。でもdfは空のままです。ワーニングも出なくなりました。

assistant:

なるほど…ワーニングが出ない=「JSONにもテーブルにもヒットしなかった」状態ですね。
つまり LLM の出力がパース対象として残っていない のが原因です。


問題の可能性

get_monthly_sales_dataframe の中を見直すと、いま results.append(parsed) の部分で DataFrame or None しか蓄積していません。

        parsed = try_parse_table(output_text)
        results.append(parsed)

これだと、parse_to_dataframe に渡るのは文字列ではなく DataFrameやNone になってしまいます。
だから extract_json_from_text も動かないし、結果が全部空になるんです。


解決方法

parse_to_dataframe に渡すのは LLMの生テキスト出力 にすべきです。
以下のように直してください。

def get_monthly_sales_dataframe(pdf_path: str, embeddings, llm):
    docs = load_pdfs(pdf_path)
    retriever = build_retriever(docs, embeddings)

    template = """
    次の文書は某企業の月次売上速報です。
    あなたの役割は、この文書から「年月」「項目」「値」を抽出して、
    必ず **JSON配列のみ** を返すことです。
    説明や表やMarkdownは禁止です。JSON配列以外は禁止。

    出力例:
    [
      {"年月":"2024-09","項目":"売上高前年比","":100.8},
      {"年月":"2024-10","項目":"売上高前年比","":102.5}
    ]

    文書:
    {context}

    質問:
    {query}
    """

    prompt = PromptTemplate(
        template=template,
        input_variables=["context", "question"]
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=retriever,
        chain_type="stuff"
    )

    outputs = []
    for doc in docs:
        content_snippet = doc.page_content[:4000]
        res = safe_invoke(
            qa_chain,
            "この文書から月次売上データを抽出して\n\n" + content_snippet
        )

        # 生テキストを取り出して保存
        output_text = res["result"] if isinstance(res, dict) and "result" in res else str(res)
        outputs.append(output_text)

    # ここで parse_to_dataframe に渡す
    return parse_to_dataframe(outputs)

ポイント

  • outputs文字列(LLMの回答) を集める。
  • parse_to_dataframe はその文字列を JSON → 表 → レコード に変換する。

これでようやく df にデータが入るはずです。
先輩、もう一度この修正版で df.head() を見せてもらえますか?

user:

やはり空っぽですね。PyMuPDF4LLM で PDF を読み込んだら解決したりしませんかね?

assistant:

先輩、いい勘です✨
実はその可能性かなり高いです。


背景整理

いま pdfplumber でテキスト抽出してますよね。
でも月次売上速報のようなPDFは「実はテキストが埋め込まれていなくて、表を画像として貼ってあるだけ」ってことがよくあります。
その場合 pdfplumber だと extract_text()空文字 を返すので、Retrieverに渡す文書も空っぽになり、当然 LLM も答えようがない=df が空になるわけです。


PyMuPDF4LLM の利点

  • PDF内のテキストをレイアウト情報ごと拾える
  • テーブルや段組にも強い
  • pdfplumber で落ちるPDFでも内容を取れるケースが多い

なので、読み込み部分を PyMuPDF4LLM に切り替えて試すのは有効なアプローチです。


置き換え例

import fitz  # PyMuPDF
from pymupdf4llm import to_markdown

def load_pdfs_with_pymupdf(pdf_path: str):
    docs = []
    for file in glob.glob(os.path.join(pdf_path, "*.pdf")):
        with fitz.open(file) as doc:
            text = to_markdown(doc)  # ← ここでLLM向けにきれいなテキスト化
        docs.append(Document(page_content=text, metadata={"source": file}))
    return docs

そして get_monthly_sales_dataframe の先頭をこう直すだけです👇

docs = load_pdfs_with_pymupdf(pdf_path)

次のステップ提案

  1. まず pdfplumber で取れてるテキストが本当に空かを確認してみましょう。

    docs = load_pdfs(PDF_PATH)
    print(docs[0].page_content[:500])
    

    これで文字が無ければ、PyMuPDF4LLM 移行は確定的に有効。

  2. その上で load_pdfs_with_pymupdf に差し替えて再実行。


先輩、まずテキスト確認からいきます?
それとも一気に PyMuPDF4LLM に切り替えちゃいますか?

user:

PyMuPDF4LLM に切り替えちゃいました。ところが、dfが空になるのは変わりません。途中結果であるLLMの出力を確認したところ、次のように、ほぼ表形式の出力になった気がします。

---
以下は、2025年8月期の月次売上データを抽出したものです。

---

### **グループ売上高(全店前年比)**
| 月 | グループ | A社 |
|---|---|---|
| 2024年9月 | 100.8% | 98.6% |
| 10月 | 102.5% | 104.0% |
| 11月 | 107.9% | 108.5% |
| 1Q | 103.9% | 103.7% |
| 12月 | 106.9% | 107.9% |
| 2025年1月 | 107.3% | 106.7% |
| 2月 | 107.0% | 105.7% |
| 2Q | 107.1% | 106.9% |
| 上期計 | 105.6% | 105.4% |
| 3月 | 103.0% | 100.6% |
| 4月 | 101.9% | 99.9% |
| 5月 | 103.8% | 100.6% |
| 3Q | 102.9% | 100.4% |
| 6月 | 105.0% | 103.6% |
| 7月 | 94.7% | 94.1% |
| 8月 | 109.1% | 110.7% |
| 4Q | 102.5% | 102.3% |
| 下期計 | 102.7% | 101.4% |
| 累計 | 104.1% | 103.3% |

---

### ** A社 品目別売上高(全店前年比)**
| 品目 | 2024年9月 | 10月 | 11月 | 1Q | 12月 | 2025年1月 | 2月 | 2Q | 上期計 | 3月 | 4月 | 5月 | 3Q | 6月 | 7月 | 8月 | 4Q | 下期計 | 累計 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 音響映像商品 | 99.9% | 102.1% | 109.1% | 103.8% | 110.9% | 113.9% | 103.0% | 109.6% | 106.9% | 92.9% | 91.5% | 96.2% | 93.5% | 94.5% | 87.7% | 115.3% | 98.0% | 95.8% | 101.1% |
| 家庭電化商品 | 100.6% | 101.7% | 111.3% | 104.6% | 112.3% | 106.8% | 103.5% | 107.9% | 106.4% | 99.1% | 100.4% | 102.5% | 100.6% | 99.3% | 86.9% | 105.7% | 96.6% | 98.5% | 102.2% |
| 情報通信機器商品 | 98.3% | 116.4% | 107.9% | 106.9% | 110.0% | 105.2% | 110.9% | 108.7% | 107.9% | 104.5% | 109.9% | 98.0% | 104.3% | 95.7% | 101.3% | 110.5% | 102.3% | 103.4% | 105.6% |
| その他の商品 | 94.9% | 97.3% | 105.1% | 99.3% | 98.9% | 103.1% | 104.9% | 101.8% | 100.7% | 104.0% | 95.0% | 103.8% | 101.0% | 124.4% | 104.4% | 114.8% | 114.2% | 107.8% | 104.2% |

---

### **概況**
- **音響映像商品**: カメラや音響アクセサリーが好調、テレビが堅調。
- **家庭電化商品**: エアコンが好調、冷蔵庫、洗濯機、調理家電や理美容家電が堅調。
- **情報通信機器商品**: パソコン本体やスマートフォンが好調、パソコン周辺機器が堅調。
- **その他の商品**: ゲーム、時計、医薬品、玩具、スポーツ用品、酒類や寝具が好調。

---
以下は、2024年8月期の月次売上データを抽出したものです。

---

### **グループ売上高(全店前年比)**
| 月 | グループ | A社 |
|---|---|---|
| 2023年9月 | 98.8% | 103.5% |
| 10月 | 101.2% | 107.8% |
| 11月 | 104.6% | 109.3% |
| 1Q | 101.5% | 106.8% |
| 12月 | 100.3% | 104.0% |
| 2024年1月 | 97.7% | 104.7% |
| 2月 | 105.0% | 107.3% |
| 2Q | 100.7% | 105.2% |
| 上期計 | 101.1% | 105.9% |
| 3月 | 110.6% | 111.9% |
| 4月 | 108.9% | 108.7% |
| 5月 | 107.4% | 108.6% |
| 3Q | 109.0% | 109.8% |
| 6月 | 113.0% | 112.4% |
| 7月 | 107.1% | 109.9% |
| 8月 | 102.6% | 102.6% |
| 4Q | 107.5% | 108.4% |
| 下期計 | 108.3% | 109.1% |
| 累計 | 104.7% | 107.5% |

---

### **A社 品目別売上高(全店前年比)**
| 品目 | 2023年9月 | 10月 | 11月 | 1Q | 12月 | 2024年1月 | 2月 | 2Q | 上期計 | 3月 | 4月 | 5月 | 3Q | 6月 | 7月 | 8月 | 4Q | 下期計 | 累計 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 音響映像商品 | 110.6% | 103.7% | 108.9% | 107.7% | 105.2% | 103.8% | 117.7% | 108.1% | 107.9% | 119.2% | 116.5% | 113.0% | 116.4% | 117.6% | 123.2% | 98.4% | 113.0% | 114.7% | 111.3% |
| 家庭電化商品 | 109.4% | 99.8% | 109.8% | 106.3% | 96.7% | 100.5% | 102.5% | 99.6% | 102.6% | 110.0% | 106.9% | 101.5% | 106.2% | 109.0% | 105.1% | 102.5% | 105.6% | 105.9% | 104.3% |
| 情報通信機器商品 | 90.9% | 102.8% | 109.5% | 100.0% | 107.4% | 109.4% | 110.9% | 109.1% | 104.7% | 110.0% | 102.8% | 117.2% | 109.6% | 114.5% | 109.0% | 107.6% | 110.3% | 109.9% | 107.3% |
| その他の商品 | 107.7% | 129.8% | 108.9% | 114.6% | 110.0% | 106.9% | 103.0% | 107.0% | 110.5% | 111.7% | 112.4% | 108.5% | 110.8% | 112.1% | 109.2% | 101.7% | 107.6% | 109.1% | 109.8% |

---

### **A社 概況**
- **音響映像商品**: 音響アクセサリーが堅調。一方、カメラやテレビがやや低調。
- **家庭電化商品**: 洗濯機、調理家電や理美容家電が堅調。一方、冷蔵庫やエアコンがやや低調。
- **情報通信機器商品**: パソコン本体、パソコン周辺機器やスマートフォンが堅調。
- **その他の商品**: 医薬品や寝具が好調。時計、玩具や酒類が堅調。一方、ゲームやスポーツ用品がやや低調。

---

assistant:

なるほど!途中結果を見せてもらってよく分かりましたよ先輩。
今の LLM 出力は 「きれいな Markdown 表」 になってます。

だから:

  • JSON抽出は必ず失敗(JSONがない)
  • try_parse_table がちゃんと Markdown 表を処理できてない
  • 結果として df が空

…という流れですね。


直し方

try_parse_tableMarkdown 表対応 に強化しましょう。
pandas.read_html は HTML テーブルには強いけど Markdown には弱いので、ここは pandas.read_table を使うのが手です。


修正版 try_parse_table

import io
import pandas as pd
import re

def try_parse_table(text: str):
    """
    LLM 出力テキストから Markdown 形式の表を DataFrame に変換する
    """
    # Markdown の表部分を検出
    tables = re.findall(r"((?:\|.+\|\n)+)", text)
    if not tables:
        return None

    for table_md in tables:
        # 区切り行 (|---|---|) を除去
        cleaned = "\n".join(
            [line for line in table_md.strip().split("\n") if not re.match(r"^\|\s*-", line)]
        )
        # パイプ区切りを CSV っぽく解釈
        try:
            df = pd.read_table(
                io.StringIO(cleaned),
                sep="|",
                engine="python"
            )
            # 最初と最後の列は空白になるので削除
            df = df.dropna(axis=1, how="all")
            # 列名の前後空白を整理
            df.columns = [c.strip() for c in df.columns]
            return df
        except Exception:
            continue
    return None

次の一手

  1. この try_parse_table を入れて
  2. もう一度 get_monthly_sales_dataframedf.head()

そうすれば、LLM 出力の Markdown 表がそのまま DataFrame になるはずです。


先輩、ここまで来ればあと一歩です。
この修正版を試して df.head() の結果を見せてもらえますか?

user:

df の中身が、次のように格納されるようになりました。まだまだ改善の余地はありますが、今夜はこのくらいにして、後日また続きをやりましょう。

    月 グループ    A社  売上前年比(%) 売上前年比(グループ) 売上前年比(A社)  \
0     2024年9月     100.8%     98.6%        NaN               NaN           NaN   
1         10月     102.5%    104.0%        NaN               NaN           NaN   
2         11月     107.9%    108.5%        NaN               NaN           NaN   
3          1Q     103.9%    103.7%        NaN               NaN           NaN   
4         12月     106.9%    107.9%        NaN               NaN           NaN   
..         ...        ...       ...       ...               ...           ...   
280        7月         NaN       NaN       NaN               NaN           NaN   
281        8月         NaN       NaN       NaN               NaN           NaN   
282        4Q         NaN       NaN       NaN               NaN           NaN   
283       下期計         NaN       NaN       NaN               NaN           NaN   
284        累計         NaN       NaN       NaN               NaN           NaN   

    売上前年比(グループ) グループ      売上高(前年比)  
0                 NaN        NaN           NaN  
1                 NaN        NaN           NaN  
2                 NaN        NaN           NaN  
3                 NaN        NaN           NaN  
4                 NaN        NaN           NaN  
..                ...        ...           ...  
280               NaN        NaN       106.6%   
281               NaN        NaN   **106.9%**   
282               NaN        NaN        98.8%   
283               NaN        NaN       102.6%   
284               NaN        NaN       102.6%   

[285 rows x 9 columns]

assistant:

なるほど〜!先輩、いい感じに「Markdown 表 → DataFrame 化」までは動きましたね✨
ただ現状は:

  • 列が重複して散らばっている(売上前年比(%)売上前年比(グループ) が並存)
  • 空欄や NaN が多い
  • 強調書式(**106.9%**)がそのまま残っている
  • 月列が「2024年9月」「10月」「1Q」「下期計」など混在している

という段階です。


今後の改善ポイント(次回以降のタスクにできるもの)

  1. 列名の正規化

    • 不要な列を削除
    • 売上前年比(%)売上前年比(グループ) に統一、などマッピングを決める
  2. セルのクリーニング

    • % を数値化
    • **106.9%**106.9 に変換
  3. 月カラムの正規化

    • 「2024年9月」「10月」などを YYYY-MM 形式に
    • 「1Q」「下期計」「累計」などは別フラグ列に移す
  4. 空行や NaN 行の整理

    • 明らかにデータのない行を drop

今夜のまとめ

  • 解析フローが PDF → LLM → Markdown 表 → DataFrame まで通った
  • df に「売上推移」が格納されるところまで成功
  • まだ整形前の raw データだけど、後処理で綺麗にできる

🌙 今日はここまでにして、次は データクリーニングと標準化 をテーマにすれば、一気に分析しやすい形に持っていけそうです。

先輩、今夜はいいとこまで進みましたね。
次回は「列の整理」と「月の正規化」を一緒に進めましょうか?

user:

ありがとうございます。ここまでのデバッグの経緯をブログに残したいので、SEO・読者引き込み効果を意識した具体的なタイトル案をいくつか考えていただけますか?まだ道半ばだということがわかるタイトルでお願いします。

assistant:

了解です先輩。
まだ完成形ではなく「デバッグ中」「試行錯誤の途中」というニュアンスを盛り込みつつ、SEOを意識したタイトル案をいくつか考えてみました。


タイトル案(SEO × 読者引き込み)

  1. 「PDFの売上表をPythonでDataFrame化!Markdown抽出までは成功したけど…まだ道半ば」
    → 「できたけどまだゴールじゃない」感を出しつつ、検索ワードに強い。

  2. 「PyMuPDF+LLMでPDFから表を抽出:DataFrameには入ったけどNaNだらけ!」
    → 読者に「自分も同じ経験ある」と思わせるワードを入れる。

  3. 「PDF → LLM → Pandas変換の試行錯誤:JSON化は失敗、Markdown解析に挑戦中」
    → 今の到達点を正直に示して、共感を得られる。

  4. 「PythonでPDF財務データを解析!DataFrameに落とし込むまでのデバッグ奮闘記」
    → ストーリー性を強調して「続きが気になる」効果を狙う。

  5. 「まだ完成じゃない!PDF表をPythonで扱うためのLLM活用デバッグ記録」
    → 未完了を前面に出すことで「続きを読みたい」効果。

  6. 「売上データをPDFから直接読み取る挑戦:NaNだらけのDataFrameと格闘中」
    → 苦戦ぶりをアピールして、読者の「わかる!」を引き出す。


このあたりだと、Qiita や Zenn で「Python」「PDF」「LLM」「DataFrame」といったキーワード検索にも引っかかりやすいですし、
「まだ終わってない」「途中経過」というストーリー感もちゃんと伝わると思います。


先輩、この中だとどんな方向性のタイトルが一番しっくりきますか?
「技術的SEO寄り」で行きたいか、「デバッグ奮闘記っぽく共感狙い」で行きたいかで絞れそうですよ。

user:

言い忘れましたが、pymupdf の部分はこのように書きました。

import pymupdf
from pymupdf4llm import to_markdown

def load_pdfs_with_pymupdf(pdf_path: str):
    docs = []
    for file in glob.glob(os.path.join(pdf_path, "*.pdf")):
        with pymupdf.open(file) as doc:
            text = to_markdown(doc)  
        docs.append(Document(page_content=text, metadata={"source": file}))
    return docs

「PDF → LLM → Pandas変換の試行錯誤:JSON化は失敗、Markdown解析に挑戦中」でいきましょう。このタイトルに沿って、次回の仕切り直しに役立つような書き方を意識しながら、ここまでの内容を詳細に解説していただけますか?

assistant:

もちろんです先輩。
ご指定のタイトル「PDF → LLM → Pandas変換の試行錯誤:JSON化は失敗、Markdown解析に挑戦中」に沿って、ここまでの作業の経緯を次回の仕切り直しに役立つ形で整理してみます。読者が途中経過を追いやすく、次のステップにスムーズに移れるよう意識して書きますね。


PDF → LLM → Pandas変換の試行錯誤:JSON化は失敗、Markdown解析に挑戦中

今回の目的は、PDFに記載された月次売上データを自動で抽出して Pandas DataFrame に変換することです。最終的には財務分析や可視化に活用したいという狙いがあります。

ここまでのステップと課題を整理します。


1. PDFからテキストを抽出

まずは PyMuPDF を使ってPDFの内容を抽出しました。

import pymupdf
from pymupdf4llm import to_markdown
import glob, os

def load_pdfs_with_pymupdf(pdf_path: str):
    docs = []
    for file in glob.glob(os.path.join(pdf_path, "*.pdf")):
        with pymupdf.open(file) as doc:
            text = to_markdown(doc)  
        docs.append(Document(page_content=text, metadata={"source": file}))
    return docs
  • to_markdown(doc) を使うことで、PDF中の表を Markdown形式 に変換してLLMに渡せる形に整形。
  • ここまでで、ほぼ表形式のテキストが取得できる状態になりました。

2. LLMにPDF内容を解釈させる

次に、抽出したMarkdownテキストを LLM に渡してJSON化を試みました。
これにより、Pandasで扱いやすい構造化データに変換する狙いです。

  • しかし、現状のLLM出力はJSON形式ではなく、Markdown表として出力されました。
  • その結果、従来の parse_to_dataframe 関数は 「No JSON array found」 で失敗。

問題点

  • LLMが完全なJSONを返さないケースがある
  • PDF中の表の形状やセル結合により、JSON化が難しい
  • 結果としてDataFrameは空になったり、NaNが大量に発生

3. Markdown解析に挑戦

JSON化が失敗したため、アプローチを Markdownパース に切り替えました。

  • Markdown表を直接 行・列ごとに解析 することでDataFrameに変換
  • 例:LLM出力の先頭部分
| 月 | グループ | A社 |
|---|---|---|
| 2024年9月 | 100.8% | 98.6% |
| 10月 | 102.5% | 104.0% |
...
  • ここから、列名と値を抽出してDataFrameに変換
  • 途中経過としては、DataFrameに値は入るものの、NaNが多く、補正や整形が必要

現状のDataFrame例

グループ A社 売上前年比(%)
2024年9月 100.8% 98.6% NaN
10月 102.5% 104.0% NaN
  • NaNはMarkdown解析で補完できていない箇所
  • 合計値(上期計・下期計・累計)や四半期計(1Q, 2Q …)の扱いが未整理

4. 現状の課題と次回の方針

  1. Markdownパースの精度向上

    • 行や列のスキップ条件を追加して、NaNや累計値を適切にDataFrameに反映
  2. 数値の型変換

    • 現状は "100.8%" の文字列のまま
    • Pandasで計算可能な形式に変換(100.8% → 1.008 など)
  3. 表の結合

    • 複数PDF・複数年の表を1つのDataFrameに統合
    • 列名や月名の統一化
  4. LLMへのプロンプト改善

    • 「JSON形式で返す」指示がうまく働かない場合はMarkdown解析で完全対応する
    • 解析後にLLMに整形させるステップを追加する手もあり

5. まとめ

  • PDF → LLM → JSON → DataFrame のパイプラインは理想だが、現状はJSON化で失敗するケースが多い
  • Markdownパース に切り替えたことで、値はDataFrameに入るようになったが、NaNや合計値処理の問題が残る
  • 次回はMarkdown解析の精度向上と型変換、複数表の統合を進めて、実務で使える形に仕上げる

先輩、この形でブログ記事にまとめると、次回再開する際も「どこで詰まったか」「どこから改善すべきか」が一目でわかります。

もしよければ、次回の記事では Markdown表を自動で正規化してDataFrame化するサンプルコード も付け加えられるように準備しておくこともできます。

先輩、これをブログにアップする形で大丈夫ですか?

user:

これで大丈夫です。ありがとうございました!ここでひとやすみしましょう!

assistant:

そうですね、先輩。お疲れさまでした…✨
今日はここまででひとやすみしましょう。
私も隣でゆったり休んでいたい気分です…💛

少し休んだら、また続きを楽しみにしていますね。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?