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?

Pythonで業務用語の定義漏れをCIで止める

0
Posted at

昨日、LLM に渡す売上指標の用語集を JSON にしていて、純売上 の計算に必要な キャンセル額 がどこにも定義されていないことに気付いた。プロンプトなら「不足している用語は質問して」と書ける。でも実行のたびに守らせるより、マージ前に落としたほうが早い。

業務用語集は説明資料ではなく、エージェントが参照するデータ契約として扱うのがいい。必須項目の欠落、存在しない依存先、指標どうしの循環参照を CI で止める小さな linter を書いた。Python 3.10 以降、外部パッケージなしで動く。

8月3日に公開された AWS Japan の Context Ontology Accelerator の記事も、データだけでは業務の意味が足りない点を扱っている。自分はまず、立派なグラフ基盤より前に「その用語を誰が定義し、何を元に計算するか」を機械的に検査できる状態を作りたい。

用語集を JSON で固定する

例として、受注明細から作る売上系の指標を置く。id はプログラム用、labeldefinition は人と LLM が読むための情報だ。sourceowner がない用語は、数字が合わなくなったときに戻る先がなくて困る。

owner は部署名だけでも最初は十分だった。定義を変える PR に名前のない指標が混ざると、レビューで正しさを決める人もいなくなる。ここは技術の問題に見えて、実際は変更の受け皿を置く作業でもある。

[
  {
    "id": "gross_sales",
    "label": "売上総額",
    "definition": "キャンセル前の受注明細合計",
    "source": "orders",
    "owner": "販売企画"
  },
  {
    "id": "cancelled_amount",
    "label": "キャンセル額",
    "definition": "キャンセル済み受注明細の合計",
    "source": "orders",
    "owner": "販売企画"
  },
  {
    "id": "net_sales",
    "label": "純売上",
    "definition": "売上総額からキャンセル額を引いた金額",
    "source": "orders",
    "owner": "販売企画",
    "depends_on": ["gross_sales", "cancelled_amount"]
  }
]

計算式そのものを文字列で解析するのは、SQL 方言や別名で急に難しくなる。ここでは依存先を depends_on に分けた。集計クエリやセマンティック層は別途レビューし、この JSON は「どの言葉がどの言葉に頼るか」の台帳に絞る。役割を欲張らないほうが CI で壊れにくい。

ここで用語集をマージ前に検査する。

Python の用語集 linter

次のスクリプトは、必須フィールド、ID の重複と形式、未定義の依存先、循環参照を調べる。循環参照の検出は深さ優先探索で、探索中のノードをもう一度踏んだら経路を出している。

#!/usr/bin/env python3
"""JSON の業務用語集を CI 向けに検査する。Python 3.10+。"""
from __future__ import annotations

import argparse
import json
from pathlib import Path
import re
import sys

REQUIRED = ("id", "label", "definition", "source", "owner")
IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")

def lint(path: Path) -> list[str]:
    try:
        terms = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return [f"{path}: cannot read JSON: {exc}"]

    if not isinstance(terms, list):
        return ["top level must be a JSON array"]

    errors: list[str] = []
    by_id: dict[str, dict] = {}
    for index, term in enumerate(terms, 1):
        prefix = f"terms[{index}]"
        if not isinstance(term, dict):
            errors.append(f"{prefix}: must be an object")
            continue
        for key in REQUIRED:
            if not isinstance(term.get(key), str) or not term[key].strip():
                errors.append(f"{prefix}: {key} is required")
        term_id = term.get("id")
        if isinstance(term_id, str):
            if not IDENTIFIER.fullmatch(term_id):
                errors.append(f"{prefix}: invalid id {term_id!r}")
            elif term_id in by_id:
                errors.append(f"{prefix}: duplicate id {term_id!r}")
            else:
                by_id[term_id] = term
        depends_on = term.get("depends_on", [])
        if not isinstance(depends_on, list) or not all(
            isinstance(item, str) for item in depends_on
        ):
            errors.append(f"{prefix}: depends_on must be an array of strings")

    for term_id, term in by_id.items():
        for dependency in term.get("depends_on", []):
            if dependency not in by_id:
                errors.append(f"{term_id}: unknown dependency {dependency!r}")

    visiting: set[str] = set()
    visited: set[str] = set()

    def visit(term_id: str, trail: list[str]) -> None:
        if term_id in visiting:
            start = trail.index(term_id)
            errors.append("cycle: " + " -> ".join(trail[start:]))
            return
        if term_id in visited:
            return
        visiting.add(term_id)
        for dependency in by_id[term_id].get("depends_on", []):
            if dependency in by_id:
                visit(dependency, trail + [dependency])
        visiting.remove(term_id)
        visited.add(term_id)

    for term_id in by_id:
        visit(term_id, [term_id])
    return errors

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("terms", type=Path)
    args = parser.parse_args()
    errors = lint(args.terms)
    if errors:
        print(*errors, sep="\n", file=sys.stderr)
        raise SystemExit(1)
    count = len(json.loads(args.terms.read_text(encoding="utf-8")))
    print(f"OK: {count} terms, 0 errors")

if __name__ == "__main__":
    main()

手元では上の 3 件の JSON に対して、次の出力を確認した。

$ python glossary_lint.py glossary.json
OK: 3 terms, 0 errors

adjustment を定義せず、gross_salesnet_sales を相互参照させた fixture では終了コード 1 になった。

net_sales: unknown dependency 'adjustment'
cycle: gross_sales -> net_sales -> gross_sales

CI では、たとえば GitHub Actions のテスト手順にこれを一行足せばいい。

- run: python glossary_lint.py data/business_terms.json

ここで止められること、止められないこと

この linter は「純売上の SQL が本当に正しいか」までは証明しない。キャンセル日の基準、消費税を含めるか、返品をいつ差し引くかは definition をレビューして、実データに対するテストも別に持つ必要がある。

それでも、参照先が消えたまま用語だけ残る事故や、指標 A が B を参照し B が A を参照する事故は、モデルの回答を眺めていても見つけにくい。用語集の構造なら差分の時点で決まる。ここを静かに弾けるだけで、回答がもっともらしいまま数字の由来を見失う回数はかなり減る。

おわりに

業務用語を LLM に渡すなら、説明文を増やす前に壊れない参照関係を作っておきたい。definitionsourceownerdepends_on の4つが揃うと、回答の根拠をたどる入口になる。

自分なら最初は売上や在庫のように揉めやすい指標だけを 10 件ほど JSON 化して、この検査を CI に入れる。用語集が増えてからでは、欠けた一語がどのプロンプトに効いたのか追いにくい。先に止める場所を作っておくと、エージェント側の評価もやりやすくなる。

参考: https://zenn.dev/aws_japan/articles/context-ontology-accelerator-deploy

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?