Agents SDKを使った判例検索 (8)

Agents SDKを使った判例検索コード( database_query.py )解析の7回目。今回は「参照解決ツール」の働きを見ていく。

class CaseResolve(BaseModel):
    court: Optional[str] = Field(None)
    date: Optional[str] = Field(None)
    case_number: Optional[str] = Field(None)
    case_name: Optional[str] = Field(None)
    limit: int = Field(5, ge=1, le=10)

AIが、特定の情報を手がかりに、該当する判例を特定するための入力ルールを定義している。これまでの CaseSearch(キーワード検索)や CaseGet(ID指定の取得)に続き、この CaseResolve は、より具体的で構造化されたデータ(裁判所名や事件番号など)を組み合わせて検索を行うための照会フォームの役割を果たす。

参照解決ツールの入力スキーマ(CaseResolve)

このクラスは、あやふやな情報を整理してデータベースに問い合わせるためのフィルターを定義している。

フィールド名特徴・役割
courtOptional[str]裁判所名(部分一致で探す用)。省略可。デフォルトは None
dateOptional[str]判決日(文字列の部分一致で探す用)。省略可。
case_numberOptional[str]事件番号(部分一致で探す用)。省略可。
case_nameOptional[str]事件名(部分一致で探す用)。省略可。
limitint最大何件候補を返すか。デフォルト5件、最小1・最大10に制限。

ここで注目すべきは limit フィールドの設定。

limit: int = Field(5, ge=1, le=10)

  • デフォルト値 5: 何も指定がない場合、AIは自動的に5件探す。
  • ge=1 (Greater than or Equal):最小値を1に制限している。0件以下のリクエストによるエラーを防ぐ。
  • le=10 (Less than or Equal):最大値を10に制限している。AIがいきなり「100件分のデータを読み込む」といった暴走をしないように制限をかけている。これにより、通信コスト(トークン)の節約とレスポンス速度の向上を両立させている。
@function_tool
def resolve_case_doc_id(q: CaseResolve) -> str:

参照解決ツール(resolve_case_doc_id)の定義

断片的な情報から、特定の裁判例ID(doc_id)を導き出すためのツールの核心部分。AIが会話の流れから、ユーザーから「あの令和5年の東京地裁の事件なんだけど…」といった曖昧な要望を受け取った際、それをデータベースが理解できる形式に翻訳する役割を担う。

@function_tool

前回の get_case_text 同様、この関数をAIが自由に使える道具として登録している。AIはこのツールの存在を知ることで、「正確なIDがわからない時は、resolve_case_doc_id を使えばいいんだな」と理解する。

    """
    会話文脈中のメタ情報から doc_id 候補を解決する。
    優先順位:事件番号 > 事件名(+裁判所) > 裁判所(+日付)
    """

resolve_case_doc_id 関数の説明文(docstring)。ここには、AIへの思考の優先順位が書かれている。

  • 事件番号(最優先):「令和○年(…)第○号」は世界に1つだけなので、これがあれば一発解決できる。
  • 事件名・裁判所(次点): 事件番号がわからなくても、事件名と裁判所名が分かれば絞り込める。
  • 裁判所名・日付(最終手段): かなり広範囲になるが、候補を出すための手がかりになる。

AIはこの優先順位を読み、ユーザーとの会話から「どの情報を優先的に拾うべきか」を判断する。

where_parts: List[str] = []
params: List[object] = []

where_partsparams の準備。これから作るSQL文の部品箱。

  • where_parts:「裁判所が一致」「日付が一致」といった検索条件(SQLのWHERE句)を溜めていくリスト。
  • params:その条件に入れる具体的な値(”東京地方裁判所” など)を溜めていくリスト。

なぜ動的な構築が必要なのか

あらかじめ決められたSQL文を使うのではなく、このようにリスト(部品箱)を用意して、その場でSQLを組み立てることには以下の理由がある。

  • 情報の欠落に強い:ユーザーが裁判所名しか言わなかった場合でも、あるいは、全部の情報をくれた場合でも、その時ある情報だけを使って、最適なSQLを生成できる。
  • 柔軟な対応ができる:AIが手元にあるヒントを組み合わせて知恵を絞るプロセスをプログラム化したもの。
    if q.case_number:
        where_parts.append("case_number LIKE ?")
        params.append(f"%{q.case_number}%")

    elif q.case_name:
        where_parts.append("case_name LIKE ?")
        params.append(f"%{q.case_name}%")
        if q.court:
            where_parts.append("court LIKE ?")
            params.append(f"%{q.court}%")

    else:
        if q.court:
            where_parts.append("court LIKE ?")
            params.append(f"%{q.court}%")
        if q.date:
            where_parts.append("date LIKE ?")
            params.append(f"%{q.date}%")

ユーザーから提供された情報に基づいて、裁判例を特定するための検索条件を動的に構築する部分。docstringで定義された「優先順位:事件番号 > 事件名(+裁判所) > 裁判所(+日付)」という戦略が、Pythonのif-elif-else文を使って実装されている。

  1. if q.case_number: (最優先):もし「事件番号」が指定されていれば、それは最も強力な特定要素であるため、他の条件は無視して事件番号のみで検索条件を構築する。LIKE演算子と%ワイルドカードを使用することで、部分一致検索を可能にしている。
  2. elif q.case_name: (次点):事件番号がなく、事件名が指定されている場合の処理。事件名を検索条件に追加し、さらに、裁判所名も指定されていれば、それを組み合わせてAND条件として追加する。これにより、一般的な事件名であっても、裁判所名と組み合わせることで絞り込み精度を高める。
  3. else: (最終手段):事件番号も事件名も指定されていない場合の処理。裁判所名や判決日が指定されていれば、それらを検索条件として追加する。これは広範囲の検索になるが、利用可能な手がかりを最大限に活用する。

このような処理により、AIはユーザーからの曖昧な入力や断片的な情報に対しても、最も確実性が高い条件から順に適用し、最適な検索クエリを生成することが可能になる。

    if not where_parts:
        return "参照解決に必要な手がかりが不足しています。"

検索条件を組み立てた結果、手がかりが1つも得られなかった場合に処理を中断するチェックゲート(ガード句)。前のステップで、事件番号、事件名、裁判所、日付のどれか1つでもあれば where_parts に部品が追加される。よって、もし、このリストが空(not)であれば、AIが何ひとつ有用な情報を抽出できなかったことを意味する。この場合は「手がかりが不足しています」というメッセージを return(返却)することで、AIは「これ以上は調べられないんだな」と理解する。

なぜこのチェックが必要なのか?

  1. SQLエラーの回避:もし条件が空のままデータベースに問い合わせると、SELECT … WHERE の後に何も続かない不完全な命令になり、プログラムがクラッシュしてしまう。
  2. AIへのフィードバック:「見つかりませんでした」ではなく「手がかりが足りません」と伝えることで、AIはユーザーに対して「もう少し詳しい情報を教えていただけますか?(例:裁判所名や時期など)」と、具体的で建設的な聞き返しができるようになる。
    sql = f"""
        SELECT doc_id, court, date, case_number, case_name
        FROM cases
        WHERE {" AND ".join(where_parts)}
        ORDER BY date DESC
        LIMIT ?
    """

バラバラだった手がかり(部品)を1つにまとめ、データベースに投げる最終的なSQL文を完成させる工程。動的に条件を組み合わせることで、ユーザーが断片的な情報を出しても、AIが正確に検索を実行できるようにしている。

部品の組み立て

WHERE {" AND ".join(where_parts)}
  • " AND ".join(...):リストに溜めていた where_parts(「裁判所が一致」「日付が一致」など)を、すべて AND でつなぎ合わせる。
  • なぜ AND なのか?:ユーザーが「東京地裁」で「令和5年」と言った場合、両方の条件を満たすものだけを探すべきだから。
  • 動的生成:部品が1つならそのまま、2つなら AND で結合、といった具合に、入力に合わせてSQLが自動で伸縮する。
ORDER BY date DESC

見つかった判例を日付の新しい順(降順)に並べ替える。古い判例よりも新しい判例の方が重視されることが多いため、AIがより新しい価値の高い情報から先に目を通せるようにしている。

LIMIT ?

取得する件数に上限を設けている。CaseResolve で設定した「最大10件」という制限がここで効いてくる。大量のデータでAIがパンクするのを防ぐための処理。

なぜこの絞り込みが重要なのか?

  1. ノイズの除去:曖昧なキーワード検索ではなく、項目ごとの完全一致や部分一致(LIKE)を組み合わせることで、AIが本当に探している判例に最短距離でたどり着ける。
  2. リソースの節約:全文を取得する前に、まずは、ID、裁判所名、日付といった基本情報だけを狙い撃ちすることで、処理を高速化できる。
params.append(int(q.limit))

動的に組み立ててきた検索条件の最後の仕上げ部分。SQL文の最後にある LIMIT ? という空白に入れるためのデータを、パラメータリストに追加している。

  • q.limit:CaseResolve モデルでユーザー(またはAI)が指定した取得件数(1〜10件)の値。
  • int(...):念のため整数型であることを保証している。
  • params.append(...):これまで WHERE 句のために溜めてきた条件値のリストの一番最後に取得件数を追加している。

なぜ順番が命なのか?

データベースに命令を送る際、SQL文の中にある ?(プレースホルダ)と、params リストの中身は、左から順番に1対1で対応していなければならない。

  1. 最初に WHERE 句の ?(事件番号や裁判所名)が処理される。
  2. 最後に LIMIT 句の ? が処理される。

そのため、この append は、他の条件(裁判所名や判決日など)がすべて追加された後に行われる必要がある。

    with _connect() as conn:
        rows = conn.execute(sql, params).fetchall()

これまでにパズルのように組み立ててきたSQL命令を、実際にデータベース(SQLite)へと送り込み、答えを受け取る本番の処理。

with _connect() as conn:

データベースへの安全な通り道を確保する。これまでの記事でも触れたとおり、with文を使うことで、データの取得が終わった後(あるいは途中でエラーが起きても)、自動的に接続を閉じて後片付けをしてくれる。データベースの開きっぱなしによる故障を防ぐための処理。

conn.execute(sql, params)

完成したSQL文とその「穴(?)」を埋めるためのデータリスト(params)をデータベースに渡す。sql という命令文とparams というデータの中身を別々に渡すことで、SQLインジェクションを予防している。

.fetchall()

条件に一致したデータをリスト形式ですべて取り出す。今回は LIMIT 句で件数を絞っているが、それでも「候補のリスト」として扱いたいため、1件限定の fetchone() ではなく、複数件(0〜10件)をまとめて受け取れる fetchall() を使用している。

検索と解決のデータ受け取り比較

前回の get_case_text(1件取得)と今回の resolve_case_doc_id(候補解決)では、データの受け取り方に以下の違いがある。

メソッド取得件数利用シーン返り値の状態
fetchone()1件のみIDがわかっていて、全文を読みたい時単一のデータ(またはNone)
fetchall()全件(複数)断片的な情報から候補をリストアップしたい時データのリスト(空配列含む)
    if not rows:
        return "候補が見つかりませんでした。"

検索条件に一致するデータが1件も存在しなかった場合の処理に関するガード句。

  • if not rows::fetchall() は、結果が0件の場合にエラーを出すのではなく、空のリスト [] を返す。Pythonでは空のリストは「偽(False)」と判定されるため、この条件式は「検索結果がゼロだった時」に実行される。
  • return “候補が見つかりませんでした。”:AIエージェントに「データが存在しない」という事実を明確な言葉で伝える。

3. 「取得ツール」との違い

以前解説した get_case_text(1件取得)の際のチェックと比較すると、使い分けがより明確になる。

ツールチェック対象返却メッセージの内容
個別取得 (CaseGet)if not row:「ID: XXX は見つかりませんでした」(特定の鍵が合わなかった)
候補解決 (CaseResolve)if not rows:「候補が見つかりませんでした」(条件に合う人がいなかった)

このように、目的(1件を狙うのか、候補を探すのか)に合わせてメッセージを微調整することで、AIはより人間に近い自然な判断を下せるようになる。

    lines = ["候補:"]

見つかった複数の候補を表示するためのリストの先頭行(見出し)を準備するための処理。ここから、検索結果(rows)を1件ずつ取り出して、AIが読みやすい箇条書きのレポートにまとめていく作業が始まる。

  • lines = [...]:最終的にAIに返すテキストを、1行ずつのパーツとして溜めておくための箱(リスト)を作っている。
  • "候補:":そのリストの一番最初に「ここから下が候補のリストですよ」というタイトルを置いている。

AIエージェントへの構造化の合図

AIに対して、単にデータを羅列するのではなく、最初に「候補:」という見出しを付けることには以下の意味がある。

  1. 情報の境界線を明確にする: AIは、この言葉を見ることで「ここからは特定の1件ではなく、複数の選択肢が並んでいるんだな」と理解する。
  2. ユーザーへの回答のヒント:AIがこの出力を元にユーザーへ回答する際、「以下の候補が見つかりました」と、自然で丁寧な日本語を生成しやすくなる。
    for i, r in enumerate(rows, 1):
        lines.append(
            f"{i}. doc_id={r['doc_id']} / {r['court']} / {r['date']} / "
            f"{r['case_number']} / {r['case_name']}"
        )

データベースから得られた複数の候補(rows)を、AIが次のアクションを選びやすい目録へと1つずつ整形して積み上げていく処理。前回の lines.append(f"{i}. ...") の続きとして、判例を特定するための重要な情報をすべて1行に集約させている。

情報のカタログ化

ここでは、enumerate で振られた番号(i)に続けて、その判例の5大要素をスラッシュ(/)で繋いでいる。

  • for i, r in enumerate(rows, 1):取得した行を1始まりの連番で回す。rsqlite3.Row(辞書っぽく列名で参照できる)。
  • lines.append( f"{i}. doc_id=..."):各候補を「番号. doc_id / 裁判所 / 日付 / 事件番号 / 事件名」の1行に整形して追加。
  • r['case_number'](事件番号): 「令和○年(ワ)第○号」といった、一意性を担保する番号。
  • r['case_name'](事件名): 「損害賠償請求事件」など、一目で何の中身か分かるタイトル。

エージェント設計における情報の見せ方の工夫

このコードには、AIの能力を最大限に引き出すための設計のコツが詰まっている。

  • 情報の密度をコントロールする:ここではあえて「判決の全文」は含めていない。まず、「1行カタログ」をAIに渡し、それを元に、AIが「ユーザーの質問に最も近いのは⚪︎番の東京地裁の事件だ」と判断してから、次のステップ(CaseGet)で全文を読みに行くという二段構えの探索を可能にしている。
  • トークンの節約:10件の候補すべての本文を一度にAIに渡すと、膨大なトークン(通信コスト)を消費し、AIも混乱します。この「1行カタログ」形式なら、10件分でも非常にコンパクトに情報を伝えられる。
    return "\n".join(lines)

リストに溜めてきたバラバラの文字列を1つの完成されたレポートとして連結してAIエージェントに手渡す総仕上げにあたる処理。

  • "\n":改行コード。
  • .join(lines):lines というリストの中に入っている全ての文字列(「候補:」や「1. …」「2. …」など)の間に、改行を挟み込みながら1つに繋ぎ合わせる。

プログラミングにおいて、文字列を1つずつ + で足していく手法は、データ量が多いと動作が重くなる原因になる。これを防止するため、一度リストに全てのパーツを保管し、最後にこの .join で一気に連結するのは高速でスマートな定番の書き方。

AIエージェントへのバトンタッチ

この return によって、AIには以下のような整形されたテキストが渡される。

候補:

  1. doc_id=D123 / 東京地裁 / 2023-01-01 / 令和5年(ワ)第1号 / 損害賠償事件
  2. doc_id=D456 / 大阪高裁 / 2022-12-01 / 令和4年(ネ)第10号 / 不当利得返還事件

このように整列されたデータを受け取ることで、AIは、候補が複数あることを把握できる。また、これらを、ユーザーにそのまま提示するか、あるいは、「この中でどれを詳しく調べますか?」と聞き返すための正確な情報を得ることができる。さらに、データが構造化されているため、情報の読み飛ばしや取り違え(ハルシネーション)を防ぐこともできる。

これで「参照解決ツール」の解析は終わり。次回は「エージェント定義」について検討する。