MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第10回】ローカル完結モードと外部AIモードを切り替え、APIキーをWindowsの資格情報マネージャーに保存する

    前回(第9回)では、フォルダ内の図面PDFをまとめて処理する一括処理と、重い処理を別スレッドで動かす仕組みを作りました。

    第10回は、図面を社外に出さない「ローカル完結モード」と、精度を優先する「外部AIモード」を設定画面で切り替えられるようにし、外部AIのAPIキーを安全に保存する仕組みをPythonで作ります。ポイントは次の3つです。

    • 設定(モード・モデルID)は設定ファイルに、APIキーは設定ファイルに書かずOSの資格情報ストアに、と保存先を分ける
    • 既定はローカル完結。ローカル完結のあいだは画面で「外部AI」を選べず、送信する関数の側でも止める(画面だけに頼らない)
    • APIキーは画面・ログ・エラーメッセージに出さない。出ないことをpytestで確かめる
    目次

    今回作る機能と完成イメージ

    メイン画面の読み取り方式の横に「設定…」ボタンを追加し、次の項目を持つ設定画面を開けるようにします。

    項目内容保存先
    読み取りモードローカル完結(既定)/外部AIを許可設定ファイル
    モデルID外部AIのモデル。空欄なら既定設定ファイル
    APIキー「保存」「削除」のみ。保存済みのキーは表示しないOSの資格情報ストア
    起動時の転記設定ファイル第8回のYAMLを起動時に読み込む設定ファイル

    第10回からは、設定で「外部AIを許可」にしない限り外部AIは使えません。一括処理(第9回)はこれまでどおりローカルだけで読み取ります。

    仕組みの説明:設定とAPIキーは保存先を分ける

    設定ファイルはユーザーごとの設定フォルダへ

    設定は settings.json に保存します。保存先はアプリの置き場所ではなく利用者ごとの設定フォルダなので、exe(第11回)をどこに置いても保存でき、利用者ごとに設定が分かれます。

    OS保存先
    Windows%APPDATA%\MadoriOCR\settings.json(例:C:\Users\<ユーザー名>\AppData\Roaming\MadoriOCR)
    macOS~/Library/Application Support/MadoriOCR/settings.json
    Linux~/.config/madori-ocr/settings.json(XDG_CONFIG_HOME があればその下)

    APIキーは「資格情報ストア」へ

    APIキー(外部AIを呼ぶための合言葉。これがあれば誰でも発行元の料金で利用できる)を設定ファイルに平文で書くと、ファイルのコピーやバックアップと一緒に漏れます。そこで keyring ライブラリを使い、OSの資格情報ストア(パスワードなどを保管するOSの仕組み。Windowsでは「資格情報マネージャー」)に保存します。

    keyringの公式ページでは、推奨のバックエンド(実際の保存先)として、macOSのキーチェーン、LinuxのSecret Service・KWallet、Windowsの「Windows Credential Locker」が挙げられています。バックエンドは環境に合わせて自動で選ばれ、set_keyring() で差し替えることもできます。

    📰 出典:keyring(PyPI)

    採用したのは keyring 25.7.0(2025年11月公開・MITライセンス)です。APIキーは「資格情報ストア → 環境変数 ANTHROPIC_API_KEY(第5回からの方法。開発用)」の順に探し、どちらにも無ければ送信確認より前に止めます。

    「外部AIを許可」していなければ、2か所で止める

    画面の選択肢を無効にするだけでは、画面の作り間違いや後から追加する別の入口から送信されるおそれがあります。そこで、送信する関数(app/core/ocr_ai.py)が設定を必ず受け取り、ローカル完結なら通信の前に例外にするようにしました。

    場所ローカル完結のときの動き
    メイン画面「外部AI」の選択肢を無効化し、選択をローカルに戻す
    読み取り開始の処理念のため再確認し、エラーを表示して止める
    送信する関数(コア処理)設定を確認し、通信・画像化の前に ExternalAiDisabledError

    実装

    ファイル構成(第10回時点の差分)

    app/
    ├ core/
    │  ├ settings.py        … 新規:設定ファイルの読み書き・検証、APIキーの保存・読み込み(keyring)
    │  └ ocr_ai.py          … 変更:送信する関数が設定を必ず受け取り、ローカル完結なら送信しない
    └ ui/
       ├ settings_dialog.py … 新規:設定画面(モード・モデルID・APIキー・起動時の転記設定ファイル)
       └ main_window.py     … 変更:「設定...」ボタン、ローカル完結中は「外部AI」を無効化
    tests/
    ├ conftest.py           … 新規:全テストでkeyringをメモリ上の実装に差し替える
    ├ test_settings.py      … 新規48件
    └ test_ocr_ai.py        … 変更:設定を渡す形に修正、ローカル完結で止まることなど7件を追加
    requirements.txt        … keyring==25.7.0 を追加

    設定の型:APIキーの欄をそもそも持たない(app/core/settings.py)

    設定は変更できないデータクラス(frozen=True)で持ちます。APIキーの項目を作らないことで、設定ファイルにキーが書かれる経路をなくしています。

    # app/core/settings.py(抜粋)
    MODE_LOCAL = "local"
    MODE_EXTERNAL_AI = "external_ai"
    
    
    @dataclass(frozen=True)
    class AppSettings:
        """アプリの設定。APIキーは意図的に持たない(keyring に別に保存する)。"""
    
        #: 読み取りモード(`MODE_LOCAL` / `MODE_EXTERNAL_AI`)
        read_mode: str = MODE_LOCAL
        #: 外部AIのモデルID。空なら `app/core/ocr_ai.py` の既定(DEFAULT_AI_MODEL)
        ai_model: str = ""
        #: 起動時に読み込む転記の設定ファイル(第8回のYAML)。空なら読み込まない
        default_mapping_path: str = ""
    
        @property
        def external_ai_allowed(self) -> bool:
            """外部AIへの送信を許可しているか。"""
            return self.read_mode == MODE_EXTERNAL_AI

    設定ファイルが無ければ既定(ローカル完結)、壊れていればエラーを表示してローカル完結で起動するので、外部AIが勝手に有効になることはありません。モデルIDの欄にAPIキーらしい値(sk- で始まる)を貼り付けた場合も保存を拒否します。

    保存先のフォルダをOSごとに決める

    # app/core/settings.py(抜粋)
    def default_settings_dir(
        platform: str | None = None,
        environ: Mapping[str, str] | None = None,
        home: Path | None = None,
    ) -> Path:
        plat = sys.platform if platform is None else platform
        env = os.environ if environ is None else environ
        base_home = Path.home() if home is None else home
        if plat.startswith("win"):
            appdata = env.get("APPDATA", "").strip()
            base = Path(appdata) if appdata else base_home / "AppData" / "Roaming"
            return base / "MadoriOCR"
        if plat == "darwin":
            return base_home / "Library" / "Application Support" / "MadoriOCR"
        xdg = env.get("XDG_CONFIG_HOME", "").strip()
        base = Path(xdg) if xdg else base_home / ".config"
        return base / "madori-ocr"

    引数で差し替えられるので、Linux上のpytestでもWindowsの保存先を確かめられます。

    APIキーを保存する:エラーにキーを混ぜない

    # app/core/settings.py(抜粋)
    KEYRING_SERVICE = "madori-ocr"
    KEYRING_USERNAME = "anthropic-api-key"
    
    
    def save_api_key(text: str) -> None:
        value = validate_api_key(text)
        if not is_keyring_available():
            raise ApiKeyStoreError(
                "この環境ではOSの資格情報ストアが使えないため、APIキーを保存できません。"
                f"開発時は環境変数 {ENV_API_KEY} を使ってください。"
            )
        try:
            keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, value)
        except Exception as exc:
            raise ApiKeyStoreError(f"APIキーを資格情報ストアに保存できませんでした({type(exc).__name__})。") from None
        logger.info("APIキーを資格情報ストアに保存しました(サービス名=%s)", KEYRING_SERVICE)

    失敗時は元の例外を連結せず(from None)、種類名だけを伝えます。バックエンドによってはエラーメッセージに保存しようとした値を含める可能性があるためです。使えるバックエンドが無い環境(Linuxのサーバーなど)では、is_keyring_available() で見分けて保存ボタンを無効にし、環境変数だけを使います。

    APIキーを読み込む:資格情報ストア → 環境変数

    # app/core/settings.py(抜粋)
    @dataclass(frozen=True)
    class ResolvedApiKey:
        """読み込んだAPIキーと、その読み込み元。`repr()`・`str()` にキーの値を出さない。"""
    
        value: str = field(repr=False)
        source: str
    
        def __str__(self) -> str:
            return f"{MASKED_KEY}({self.source})"
    
    
    def load_api_key(environ: Mapping[str, str] | None = None) -> ResolvedApiKey | None:
        stored = _read_stored_api_key()
        if stored is not None:
            logger.debug("APIキーを資格情報ストアから読み込みました")
            return ResolvedApiKey(stored, SOURCE_KEYRING)
        env = os.environ if environ is None else environ
        from_env = env.get(ENV_API_KEY, "").strip()
        if from_env:
            logger.debug("APIキーを環境変数 %s から読み込みました(開発用)", ENV_API_KEY)
            return ResolvedApiKey(from_env, SOURCE_ENV)
        return None

    field(repr=False) にしておくと、デバッグ用に print(resolved) やログに %r で出しても、キーの値は表示されません。

    送信する関数の側でも止める(app/core/ocr_ai.py)

    ai_ocr_image() などに引数 settings を必須で追加しました。ローカル完結なら何も送らずに例外になります。

    # app/core/ocr_ai.py(抜粋)
    def ensure_external_ai_allowed(settings: AppSettings) -> None:
        if not isinstance(settings, AppSettings):
            raise TypeError("settings には AppSettings を渡してください。")
        if not settings.external_ai_allowed:
            raise ExternalAiDisabledError(
                "ローカル完結モードのため、外部AIへは送信できません。外部AIを使う場合は、"
                "「設定...」画面で読み取りモードを「外部AIを許可」に切り替えてください。"
            )
    
    
    def ai_ocr_image(
        image: Image.Image,
        *,
        settings: AppSettings,
        base_scale: float,
        # (以下の引数は第5回と同じため省略)
    ) -> AiOcrResult:
        ensure_external_ai_allowed(settings)
        if client is None:
            client = create_client(get_api_key())
        used_model = model or resolve_model(settings=settings)
        # (以下は第5回と同じ:画像の縮小 → 送信 → 応答JSONのパース)

    モデルIDは「設定画面 → 環境変数 MADORI_OCR_AI_MODEL → 既定」の順です。APIのエラーメッセージは、APIキーらしい文字列を伏せ字にしてから表示します。

    画面:ローカル完結のあいだは「外部AI」を選べない(app/ui/main_window.py)

    # app/ui/main_window.py(抜粋)
        def _apply_settings(self) -> None:
            """設定を画面に反映する。ローカル完結モードなら「外部AI」の選択肢を無効にし、ローカルに戻す。"""
            if self.settings.external_ai_allowed:
                self.ai_radio.configure(state="normal", text=AI_RADIO_TEXT_ALLOWED)
            else:
                self.ai_radio.configure(state="disabled", text=AI_RADIO_TEXT_DISABLED)
                self.read_mode_var.set(READ_MODE_LOCAL)

    送信時は、送信確認ダイアログ(第5回。許可モードでも毎回出ます)を出した時点の設定を、別スレッドの送信関数へ渡します。

    設定画面:入力欄は伏せ字、保存済みのキーは見せない(app/ui/settings_dialog.py)

    # app/ui/settings_dialog.py(抜粋)
            # show= で入力した文字を伏せ字にする。保存後は入力欄を空に戻し、値を画面に残さない
            self.api_key_entry = ttk.Entry(key_row, textvariable=self.api_key_var, show=_MASK_CHAR, width=30)

    保存済みのキーは状態だけを表示し、末尾の数文字も出しません。キーを入力したまま「OK」を押すと、保存し忘れとして警告します。

    動作確認の方法

    pytest は352件すべて成功しました(第9回までの297件に、設定・APIキー48件と外部AI側の7件を追加)。ruff check . もエラーはありません。

    pip install -r requirements.txt   # keyring==25.7.0 が追加されています
    pytest tests/test_settings.py tests/test_ocr_ai.py

    このLinux開発環境には使える資格情報ストアが無いため、tests/conftest.py でkeyringのバックエンドをメモリ上のテスト用実装に差し替えています(keyring.set_keyring())。開発者のPCでも本物の資格情報には触れません。主に次を確かめています。

    • ローカル完結なら送信関数が通信も画像化もしない
    • APIキーが資格情報ストア→環境変数の順で読まれ、設定ファイルに書かれない
    • エラーに値を含めてしまう架空のバックエンドでも、エラー・ログ(DEBUGまで)に架空のキーが出ない

    次は、設定画面で「外部AIを許可」を選び、APIキーの入力欄に架空の値を入力した状態です(開発環境(Linux)での確認画面です。Windows実機では見た目が異なります)。

    設定画面。読み取りモードで「外部AIを許可」が選ばれ、APIキーの状態は「保存済み(伏せ字)。キーは表示しません」、新しいAPIキーの入力欄は伏せ字で表示されている

    「OK」を押すと、設定ファイルにはAPIキー以外の3項目だけが保存され、メイン画面の「外部AI」が選べるようになることも確認しました。

    確認できていないこと

    • Windowsの資格情報マネージャーでの実際の保存・読み込み・削除。keyring 25.7.0のソースを読むと、サービス名(今回は madori-ocr)を名前とする汎用資格情報として保存する実装ですが、Windows実機では試していません
    • Windows実機での設定画面の見た目、%APPDATA% への保存
    • 実際のClaude APIでの動作(今回もAPIキーは使っていません)

    つまずきやすい点・セキュリティ上の注意

    • 設定の切り替えは「うっかり送信」を防ぐもので、利用者本人の操作は止められない:設定ファイルは利用者が書き換えられます。組織としてオフライン完結を徹底したい場合は、外部AIの機能を含まない版を配布するなど、別の対策が必要です
    • 資格情報ストアに入れれば絶対安全、ではない:keyringの公式ページのセキュリティの注意では、Windows Credential Lockerについて「分析は行われていない」と書かれています(macOSについては、同じPythonから動く別のスクリプトがパスワードの確認なしに読めることが注意として挙がっています)。PC自体の管理(ログインの保護・不審なソフトを入れない)が前提です
    • 環境変数は開発用に限る:環境変数にキーを置いたままにすると、資格情報ストアに保存が無いとき自動で使われます。配布先のPCでは設定しない運用にします
    • テストで本物の資格情報ストアに触れない:keyring.set_keyring() で差し替えないと、開発者のPCの資格情報を書き換えてしまうことがあります
    • ログ・エラー画面にキーを出さない:例外の連結やデバッグ出力から漏れやすいので、キーを持つ値は repr を伏せ、エラーは種類名だけにします

    発注者向けメモ

    「オフラインで完結させたい」か「精度を優先したい」かを、契約時に決めておく

    同じツールでも、ローカル完結(図面を社外に出さない代わりに、スキャン図面などでは精度が落ちやすい)と外部AI(精度の底上げが期待できる代わりに、図面画像を社外のAIサービスへ送る・利用料金がかかる)では、前提になる運用が大きく違います。どちらを前提にするかで、確認画面の作り込み、テストに使う図面、見積もりの範囲が変わります。

    「最初はローカル完結、スキャン図面が多ければ外部AIも検討」のように段階を決めておくと、後から「外部AIも使えるはず」「送らない約束だったはず」という食い違いを防げます。顧客から預かる図面を社外へ送ってよいかは、顧客との秘密保持の取り決め次第です。個別の判断は契約の専門家に確認してください。

    APIキーは「発注側の名義」で取得するのがおすすめ

    一般に、外部AIのAPIキーは発行したアカウント(組織)に利用料金が請求され、サービスの契約上の利用者もそのアカウントの持ち主になります(契約条件は各サービスの規約で確認してください)。開発会社の名義のキーを組み込んでもらう形だと、次の点で困りやすくなります。

    • 利用料金を開発会社経由で精算することになり、使用量が見えにくい
    • 図面の送信先サービスとの契約当事者が開発会社になり、データの扱いを自社で確認・管理しにくい
    • 保守契約の終了や担当者の交代のとき、キーを止める・入れ替える操作を自社でできない

    発注側の名義でアカウントを作り、キーの発行・無効化・入れ替えを自社で行える体制にしておくと安心です。今回の作りなら、キーは各PCの設定画面で保存するため、開発会社にキーの値を渡す必要もありません。

    社内ルールで決めておくこと

    • どの図面なら外部AIに送ってよいか(顧客の了承の有無、図面の種類)
    • 誰が「外部AIを許可」に切り替えてよいか、許可したPCをどう把握するか
    • APIキーの管理者、入れ替えの頻度、退職・異動・PC入れ替え時の手順
    • 利用料金の上限や通知をどう設定するか(サービスの管理画面でできることを確認)
    • ☐ ローカル完結と外部AIのどちらを前提にするか、契約・仕様書に書いたか
    • ☐ 図面を社外へ送ってよいか、顧客との取り決めを確認したか
    • ☐ APIキーを発注側の名義で取得し、管理者を決めたか
    • ☐ 外部AIを許可する人・PC・図面の範囲を社内ルールにしたか

    開発会社への質問例

    • 「ローカル完結モードのとき、どの処理で送信を止めていますか?画面の設定だけですか?」
    • 「APIキーはどこに保存されますか?設定ファイルやログに残ることはありませんか?」
    • 「APIキーを当社名義で用意した場合、入れ替えや削除は当社だけでできますか?」
    • 「オフライン専用の版(外部AIの機能を含まない版)を作ることはできますか?」

    まとめと次回予告

    第10回では、app/core/settings.py と設定画面を作り、「ローカル完結(既定)」と「外部AIを許可」を切り替えられるようにしました。APIキーはkeyringでOSの資格情報ストアに保存し、ローカル完結のあいだは画面と送信関数の2か所で外部AIを止めています。Windowsの資格情報マネージャーでの実動作は未確認です。

    次回(第11回・最終回)は「Windows向けにexe化して配布し、運用時の注意点を整理する」です。PyInstallerで単一のexeにまとめ、配布・初回起動時の注意点と、精度の限界を踏まえた最終チェック体制を整理します。

    この連載の記事一覧

    この記事は連載「CAD図面PDFをExcelへ自動転記するツール開発」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次