「またこのエラーか」をなくしたい !— ローカルCLI Debug Memory Vol.1 をつくった

logo_Python
目次

はじめに

開発をしていると「これ前にも見たな」というエラーに何度も遭遇します。特に Python の ModuleNotFoundError、LaTeX の Undefined control sequence は環境を変えるたびに同じ場所でつまずいている自覚がありました。そのたびにネット検索をしたりAIに聞いたりと、、、。

そこで、自分が踏んだエラーと解決メモを Markdown で残しておき、新しいエラーログを投げると過去事例から似たケースと解決候補を返してくれる、ローカルCLIツールを作ることにしました。それが今回の Debug Memory Vol.1 です。

Vol.1 のスコープは以下のように定めました。

  • やること: Markdown 蓄積、正規表現ベースの特徴抽出、ルールベースの類似検索、根拠付きの解決候補返却
  • やらないこと: LLM 連携、Web UI、ベクトルDB、Claude Skill 連携、Codex 連携

まずは「過去の自分の解決メモを、再現可能な仕組みで引き出せる」コアの部分を、ローカルで完結する形で作り上げることを目標にしました。

環境設定

動作環境は Python 3 系です。依存は最小限に抑え、frontmatter のパースに PyYAML を使う程度に留めています。

python -m venv .venv
source .venv/bin/activate
python -m pip install -e .

Windows PowerShell の場合は、仮想環境の有効化を次のコマンドで行います。

.\\.venv\\Scripts\\Activate.ps1
python -m pip install -e .

ディレクトリ構成は次のようにしました。

debug-memory/
  pyproject.toml
  README.md
  AGENTS.md
  debug_memory/
    __init__.py
    cli.py
    models.py
    parser.py
    signature.py
    search.py
    rag.py
    render.py
  memory/
    errors/
      2026-05-26_xxx.md
      2026-05-26_yyy.md
      2026-05-26_zzz.md
  templates/
    error_case.md
  tests/
    test_parser.py
    test_signature.py
    test_search.py
    test_rag.py

エラー事例本体は memory/errors/*.md に置きます。データを Markdown のままにしておくことで、検索の仕組みとは独立して、手で読んだり Git で履歴管理したりできます。

設計の方針

実装に入る前に、Vol.1 で意識した方針を短くまとめます。

Markdownで蓄積する

エラー事例は、データベースのようなかっちりした形式ではなく、ふつうのMarkdownファイルとして保存することにしました。1つのファイルが1つのエラー事例にあたり、ファイルの先頭にタグや状態などのメモを書いておき、本文に「どんなエラーか」「どう直したか」を書く形です。

こうしておくと、エディタで気軽に開いて読み書きできること、Gitで履歴を追えること、検索の仕組みを後で差し替えても、蓄積したデータをそのまま使えることといった利点があります。

ルールベースで検索する

Vol.1 では LLM やベクトル検索を使わず、正規表現で抽出した特徴(例外名・クォート語・パッケージ名など)とMarkdownの先頭に書いたタグや言語情報を突き合わせる方式にしました。同じ入力に対して同じ結果を返す決定的な挙動と、「なぜ一致したか」を必ず説明できることを第一に確保したかったからです。

回答を断定しない

返ってくるのはあくまで過去の類似事例の解決メモであり、今のエラーへの確実な答えではありません。そのため出力では Top solution candidate という表現にとどめ、根拠となった事例とスコア、一致理由を必ず添える形にしました。

実装手順

Markdownエラー事例のフォーマット

1事例 = 1ファイルとし、ファイル先頭のメタ情報(タグ・状態・言語など)と、本文のセクション(エラー内容・解決策など)で構成しました。## Error## Solution を必須、## Context## Notes を任意としています。

---
id: err_20260526_001
title: Pythonでdotenvが読み込めない
status: resolved
tags:
  - python
  - dotenv
  - venv
language: Python
frameworks:
  - FastAPI
created_at: 2026-05-26
updated_at: 2026-05-26
---

## Error

ModuleNotFoundError: No module named ‘dotenv’

## Context

venvを作り直したあと、FastAPI起動時に発生。

## Solution

pip install python-dotenv

## Notes

仮想環境が違う場合もあるので、`which python` と `pip list` を確認する。

parser.py — frontmatterとセクションの抽出

parser.py では,Markdownファイルを読み込んで ErrorCase dataclass に変換します.先頭の --- ブロックを frontmatter として PyYAML でパースし,本文側は ## Error## Context## Solution## Notes を正規表現で抽出する設計にしました.

_SECTION_PATTERN = re.compile(
    r"^##[ \\t]+(Error|Context|Solution|Notes)[ \\t]*\\n(.*?)(?=^##[ \\t]+|\\Z)",
    re.MULTILINE | re.DOTALL,
)
_REQUIRED_FIELDS = ("id", "title", "status")
_REQUIRED_SECTIONS = ("Error", "Solution")

def parse_error_case(path: Path) -> ErrorCase:
    metadata, body = _split_frontmatter(path.read_text(encoding="utf-8"), path)
    for field in _REQUIRED_FIELDS:
        if field not in metadata or metadata[field] is None:
            raise ValueError(f"{path}: required frontmatter field '{field}' is missing")
    sections = _extract_sections(body, path)
    return ErrorCase(
        id=str(metadata["id"]),
        title=str(metadata["title"]),
        status=str(metadata["status"]),
        tags=list(metadata.get("tags") or []),
        language=metadata.get("language"),
        frameworks=list(metadata.get("frameworks") or []),
        error=sections["Error"],
        context=sections.get("Context", ""),
        solution=sections["Solution"],
        notes=sections.get("Notes", ""),
        path=path,
    )

必須フィールドや必須セクションが欠けている場合は、どのファイルのどこが足りないのかを明示する例外を出すようにしました。Markdownを手書きもしくはAIに記述させる運用が前提なので、壊れたファイルを早めに気付ける形を優先しています。

signature.py — エラーログから特徴を抽出する

signature.py では,エラー文や問い合わせ文から検索用の特徴を取り出します。抽出対象は例外名・クォートされた語・パッケージ名っぽい語・コマンド・一般キーワードの5種類です。

EXCEPTION_PATTERN = re.compile(
    r"\\b[A-Za-z_][A-Za-z0-9_]*(?:Error|Exception|Warning)\\b"
)
QUOTED_PATTERN = re.compile(r"""['\\"]([^'\\"\\n]+)['\\"]""")
COMMAND_PATTERN = re.compile(
    r"(?:^|\\s)((?:npm|pnpm|yarn|pip|poetry|python|node|docker|git|uv|pytest)"
    r"\\s+[^\\n]+)",
    re.IGNORECASE | re.MULTILINE,
)

例えば ModuleNotFoundError: No module named 'dotenv' を渡すと例外名として ModuleNotFoundError、クォート語として dotenv、パッケージ名として dotenv を拾います。

キーワード抽出ではストップワード(theerrormodule など)と短すぎる語を除外し、重複もケースインセンシブに正規化して落としています。大文字小文字の揺れと、特徴語と重複するノイズを抑えるための処理です。

search.py — ルールベースのスコアリング

search.py ではクエリ側のシグネチャと各事例側のシグネチャを突き合わせてスコアを足し上げます。何が一致したかを reasons として必ず残す形にしました。

if exception_matches:
    score += 0.35
    reasons.append(f"exception matched: {exception_matches[0]}")

if quoted_matches:
    score += 0.25
    reasons.append(f"quoted term matched: {quoted_matches[0]}")

if package_matches:
    score += 0.20
    reasons.append(f"package matched: {package_matches[0]}")

if tag_matches:
    score += 0.12
    reasons.append(f"tag matched: {tag_matches[0]}")

if case.status.casefold() == "resolved":
    score += 0.10
    reasons.append("status bonus: resolved")

配点は例外名 > クォート語 > パッケージ語 > タグ > 言語/フレームワーク > 一般キーワード の順で、より特徴的な一致を強く効かせています。加えて解決済み(status: resolved)の事例には +0.10 のボーナスを乗せ、過去に直し切ったケースが上位に来やすくしました.

rag.py — 解決候補を組み立てる

rag.py は検索結果から回答文を組み立てるところです。Vol.1 ではLLMを使わないためテンプレートで結果を整形しているだけです。

candidates = [
    result
    for result in results
    if result.case.status.casefold() == "resolved"
    and result.case.solution.strip()
]

if candidates:
    top_candidate = max(candidates, key=lambda result: result.score)
    lines.extend([
        "Top solution candidate:",
        top_candidate.case.title,
        "",
        "Solution:",
        top_candidate.case.solution,
    ])

ポイントは解決済みかつ ## Solution が空でない事例だけを候補にしていることです。未解決事例しかない場合は「解決済み事例は見つかりませんでした」という旨のメッセージを返し、新しい事例として記録するよう促す導線にしました。

cli.py — searchとask

CLI は依存を増やしたくなかったので argparse で組みました。サブコマンドは searchask の2つです。

def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="debug-memory")
    subcommands = parser.add_subparsers(dest="command", required=True)

    search_parser = subcommands.add_parser("search", help="List similar error cases.")
    _add_common_args(search_parser)

    ask_parser = subcommands.add_parser("ask", help="Render a solution candidate.")
    _add_common_args(ask_parser)

    args = parser.parse_args(argv)
    errors_dir = _resolve_errors_dir(args.errors_dir)
    cases = load_error_cases(errors_dir)
    results = search_similar_cases(args.text, cases, top_k=args.top_k)
    if args.command == "search":
        print(render_search_results(results))
    else:
        print(build_answer(args.text, results))
    return 0

共通オプションとして --text--top-k--errors-dir の3つを取ります。search はランキングだけを返し、ask は解決候補と根拠をまとめて返す、という役割分担にしました。

結果(実行例)

search の出力例

dotenv 周りのエラーを投げてみます。

python -m debug_memory search --text "ModuleNotFoundError: No module named 'dotenv'"

出力はランキング形式で、スコアと一致理由が並びます。

Search Results
==============

[1] Pythonでdotenvが読み込めない
    score: 1.000
    status: resolved
    tags: python, dotenv, venv
    reasons:
    - exception matched: ModuleNotFoundError
    - quoted term matched: dotenv
    - package matched: dotenv
    - status bonus: resolved

例外名・クォート語・パッケージ名の3つが揃って一致し、resolved ボーナスも加算されたうえで1位に来ています。

ask の出力例

今度は React の undefined map を投げてみます。

python -m debug_memory ask --text "TypeError: Cannot read properties of undefined (reading 'map')"

返ってくるのは解決候補と元になった事例(Evidence)です。

Debug Memory Answer
===================

Input:
TypeError: Cannot read properties of undefined (reading 'map')

Found 1 similar case.

Top solution candidate:
Reactでundefinedのmapを呼んで落ちる

Solution:
初期値を空配列にする / optional chaining を使う / loading状態を確認する。

Evidence:
[1] Reactでundefinedのmapを呼んで落ちる
    file: 2026-05-26_react-undefined-map.md
    status: resolved
    score: 0.82
    reasons:
    - exception matched: TypeError
    - quoted term matched: map
    - status bonus: resolved

解決済み事例がない場合

該当ケースが未解決のものしかない、あるいはそもそも見つからない場合は次のような出力になります.

No resolved solution candidate found.

Similar unresolved cases:
(none)

Suggestion: 過去の解決済み事例は見つかりませんでした。新しいエラー事例としてMarkdownに記録してください。

新規エラーをMarkdownに残す形にしています。

まとめ

得られたこと

過去のエラーと解決メモを「決まったフォーマットで貯める場所」を持てたことが一番大きい変化だと感じました。LLMを使わずとも,例外名とクォート語の一致だけで、自分のメモを引き出す用途には実用上ほぼ十分に機能しました。

Vol.1の限界

現状はエラーメッセージの表層的な一致に依存しているため、文言が少し変わっただけ(例えば言語が日本語化されたエラー、スタックトレースの一部だけが渡される場合など)でスコアが大きく下がります。同じ意味の別表現を吸収する仕組みは、ルールベースだけだとどうしても限界があるという実感を得ました。

今後の拡張

以下を順番に試していくつもりです。

  • LLM を補助に使った類似事例の要約や解決候補の整理
  • ベクトル検索を併用した意味的な類似マッチ
  • Claude Skill としての呼び出し対応
  • Codex の auto-fix ワークフローとの連携
  • GitHub Issue や開発ログとの接続

Vol.1 で「Markdown + ルールベース検索」の土台ができたので、このうえに段階的に重ねていく形を想定しています。

付録

  • リポジトリ
  • よく使うコマンド:
    • python -m debug_memory search --text "<error log>" — 類似事例のランキングだけを見る
    • python -m debug_memory ask --text "<error log>" — 解決候補と根拠をまとめて見る
    • python -m debug_memory ask --text "<error log>" --top-k 5 — 上位N件まで広げる
    • pytest tests/ -v — テストを実行する
CTA
  • URLをコピーしました!
  • URLをコピーしました!
この記事を書いた人
目次