Skip to content

全体作り直し - #19

Merged
KO1231 merged 53 commits into
developfrom
feature/scrap-and-build
Sep 16, 2026
Merged

KO1231 merged 53 commits into
developfrom
feature/scrap-and-build

Conversation

@KO1231

@KO1231 KO1231 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

No description provided.

- pyproject.toml (PEP 621) + uv.lock。Pipfile は legacy 用に一旦残す
- 依存を core / classify / aws に分割。core は apply ワーカーが必要とする
  ものだけに絞り、LLM SDK と MIME パーサを切り離す
- [tool.uv] exclude-newer でサプライチェーンのクールダウンを宣言的に設定
- ruff / mypy --strict / pytest / coverage 設定
- pre-commit (gitleaks, detect-private-key, no-commit-to-branch=main)
- CI: lint / format / types / test / pip-audit。Actions はコミットSHAで固定
- src レイアウト骨格 + スモークテスト
- .gitignore 全面見直し (policy.yml, .state/, Terraform, eval データ)
旧 ClassifiedEmailData がモデルに渡していた入力を作り直す。同じ合成
fixture で旧実装と突き合わせた結果:

  HTMLのみ      OLD: '<html><head><style>.a{color:red}</style>...' → NEW: '請求書の確認を…'
  入れ子MIME    OLD: 'parts could not found.'                      → NEW: '金曜までに契約書の…'
  ISO-2022-JP   OLD: '\x1b$BK\F|Cf$K...'(無言で文字化け)          → NEW: '本日中にご確認ください。'
  Shift_JIS     OLD: UnicodeDecodeError(スレッドごと消失)          → NEW: '至急ご対応ください。'

- payload ツリーを再帰走査。mixed > alternative > plain が拾えるように
- 各パートの Content-Type から charset を取得し errors="replace" で降格
- base64 デコードを先、HTML パースを後に。script/style/comment も除去
- RFC 2047 ヘッダデコード、From/To/Cc/Reply-To/List-Id/Authentication-Results
- 生の labelIds を保持(CATEGORY_* はタブ移動の判定に要る)
- 添付はメタデータのみ。本文は改行構造を残す(後段の引用除去のため)
- tests/fixtures/gmail.py で Gmail JSON を合成生成。実メールは入れない
- 36 tests、mime.py の行カバレッジ 96%

CLAUDE.md: 欠陥3の記述を実測に合わせて修正。ISO-2022-JP は例外を出さず
無言で文字化けする(7bit clean のため utf-8 デコードが成功してしまう)
無害化 (security/sanitize.py) は mime.py の責務から意図的に外したもので
あって漏れではない。実装が入るまでリストから消さない。mime の出力は
まだプロンプトに入れて安全ではない旨も明記。
config.yml から labelID を廃止。表示名だけを設定し、起動時にメールボックス
から ID を解決する。存在しないラベル名は1500スレッド分類し終えた後の
modify ではなく、起動直後に候補付きのエラーで落ちる。

- epc.settings: pydantic-settings。優先順は CLI > env > config.yml > 既定値。
  未知のキーは黙って無視せずエラー(設定ミスの温床だったため)。
  ラベル名の重複、非正の上限値、不正なバックエンド名も起動時に弾く
- epc.gmail.labels: 名前→ID 解決。typo には difflib で候補を出す。
  2つの優先度が同一ラベルに解決される場合も拒否
- epc.gmail.auth: pickle を廃止し JSON 化(任意コード実行の経路だった)。
  スコープを gmail.modify のみに縮小(readonly/labels を包含する)。
  保存するのは永続フィールドのみで、短命なアクセストークンは保存しない
  (毎回の実行が新バージョンを作り、シークレットストアの履歴上限に当たる)
- epc.gmail.client: execute(num_retries=5)。HttpError は操作名付きの
  GmailError に変換。http オブジェクトがスレッドセーフでない旨を明記
- CLI: config validate / labels / login
- config.yml.example を新スキーマで書き直し。旧スキーマを渡された場合は
  検証エラーの羅列ではなく移行中である旨を説明する

スコープ縮小により既存トークンは無効。移行するものがないので token.pickle
の読み取りは実装しない(除去した脆弱性を再導入しないため)。

88 tests / カバレッジ 89%
EmailPriorityClassifier の略。パッケージ名・CLIコマンド名・環境変数
プレフィクスの3箇所で使う。Terraform 以降はリソース名にも波及するため、
改名するならその前、という注意も併記。
MIME コアから繰り越していた宿題。分類器を繋ぐ前のゲートとして実装する。

- epc.security.sanitize: NFKC 正規化、ゼロ幅・双方向制御・タグ文字・制御
  文字の除去、data: URI と長大な base64 連続の置換、長さ上限。除去した
  ものは SanitisationReport に記録する(何も消さずに返すのと、消したことを
  知らないのは別物のため)
- epc.security.detect: 注入の検知。あくまでシグナルであって判断ではない。
  誤検知は実在スレッドから高権限アクションを奪うので、パターンは狭く
  高精度に絞る。マッチしたテキストは返さない(ログが payload の配送経路に
  なるため)
- CSS で隠されたテキストの除去は html_to_text 側に置いた。これは「敵対的か
  どうか」ではなく「可視かどうか」の問題で、判定には DOM が要り、
  sanitize が走る時点では markup が残っていないため。除去件数は
  EmailMessage.hidden_elements_removed で検知側に渡す
- tests/injection/: 14種の実在手口のコーパス。不変条件は
  「payload は除去されるか検知されるかのどちらかで、素通りかつ無言には
  ならない」。display:none 除去を無効化しても検知が拾って不変条件が
  維持されることを確認済み(多層防御が効いている)

ソース中の不可視文字は全て \u エスケープで書く。読めないソースはレビュー
できない。全角形だけは攻撃そのものなのでリテラルのまま残す。

161 tests / security は行カバレッジ 100%
隠し要素の除去はパースとインライン style の正規表現のみで、CSS カスケードの
解決もレンダリングもしない(105KB のHTMLメールで 12.8ms。レンダリングすれば
桁が変わる)。

その結果すり抜ける2ケースを、忘れられないようコーパスに名前付きで追加:
- style ブロック + class セレクタ(カスケード解決が要る)
- 白背景に白文字(継承した背景の解決が要る。推測するとダークテーマの正常な
  本文を食うので、検知に回す方がまし)

どちらも検知側が拾って不変条件は維持される。Attack.payload_is_visible を
removable に変更。「読み手に見えるか」と「除去できると判定できるか」は
別物で、後者が実際に分岐を決めているため。
旧実装の json.dumps(thread)[:2500] を置き換える。あれには3つ問題があった:

- Gmail はメッセージを古い順で返すので、先頭から切ると「対応が要るか」を
  決める最新のやり取りが丸ごと消えていた
- シリアライズ済み文字列を途中で切るので、モデルには壊れたJSONが渡っていた
- 引用履歴が毎回予算に計上され、長いスレッドは自分自身の再読で予算を使い切る

新実装: 引用と署名を落とし、1通あたりの上限をかけ、**新しい順に**予算が
尽きるまで採用する。構造体を組んでから最後にシリアライズするので常に valid で、
何通落としたかも payload に載る(omitted_messages)。

- build_payload が「スレッド → プロンプト」の唯一の経路で、その途中で
  sanitise を通す。無害化を飛ばす経路を構造的に作らない
- MessagePayload にヘッダを載せる。送信者・ドメイン・宛先数・List-Id/
  List-Unsubscribe の有無・SPF/DKIM/DMARC。旧実装は date/size/data の3つ
  しか渡していなかった
- List-Id 等は値ではなく有無の bool で渡す。値は攻撃者が書ける文字列のため
- estimate_tokens はバックエンド非依存のヒューリスティック。CJK は1文字≒1
  トークン、それ以外は3文字で1。実トークナイザはモデル固有だが、予算は
  OpenAI/Bedrock/ローカルで同じ意味を持つ必要がある。過大評価側に倒す
- InjectionSignal は payload の外側に持たせる。パイプラインは見るが、
  プロンプトには一切出さない

193 tests / budget.py は行カバレッジ 99%
プロンプトは優先度ポリシーそのものなので、ポリシーがあるべき場所 —
リポジトリ内、バージョン付き、diff とレビューが効く場所 — に置く。旧実装は
ベンダーのダッシュボード(gitから不可視)か gitignore されたディレクトリ
(clone直後に存在しない)にあり、どちらもレビューもテストもできなかった。

- prompts/system.md, prompts/user.md をコミット対象に。frontmatter で
  version を持ち、分類結果に記録するので精度変化をプロンプト変更に
  帰属できる
- 個人ルールは policy.yml(git除外)に分離し、実行時に system prompt へ
  レンダリング。リポジトリがパブリックなので、この分離は必須
- .gitignore は prompts/ を「原則除外・名指しで許可」に変更。新しく置いた
  ファイルは列挙するまで除外されたまま。パブリックリポジトリではこちらが
  fail-safe。既存の gptoss_*.txt(ac.jp/go.jp のルールを含む)は除外のまま
  であることを確認済み
- payload は nonce 付きデリミタで囲う。本文中の偽デリミタは escape_delimiter
  で分断され、実デリミタは常にちょうど2回しか現れない
- InjectionSignal はプロンプトに一切出さない。「このスレッドは怪しい」と
  モデルに伝えると、その判定自体が攻撃対象になる
- response_json_schema がモデルの出力面の全て。ラベル名を書く欄も、
  アクションを要求する欄も、ツールを呼ぶ欄も存在しない。注入が成功しても
  1スレッドの誤ラベル以上のことが起きない根拠
- テンプレートエンジンは入れない。必要なのはプレースホルダ2つで、信頼
  できない入力を含む文字列の中で任意の式を評価できる依存は割に合わない
- epc prompt render: 送る前に実際のプロンプトを読める唯一の手段

ガードテストを追加: コミット済みプロンプトに ac.jp / go.jp 等の個人的文脈が
含まれないこと、および system prompt が実際に送っているフィールドを説明して
いること(旧プロンプトは送っていないヘッダを説明していた)。

212 tests
Classifier プロトコルは意図的に狭い。予算配分・無害化済みの ThreadPayload を
受け取り、優先度と理由だけを返す。ラベル名も、アクションも、ツール呼び出しも
返せない。注入が成功しても1スレッドの誤ラベル以上が起きない理由がこれ。

- base: 応答パーサを1箇所に集約。「体裁には寛容、中身には厳格」
  — markdown フェンスや前後の散文は許容するが、P1/P2/P3 以外は拒否する。
  優先度を推測で補完すると壊れたプロンプトを隠すので、絶対に補完しない。
  モデル出力は信頼できないテキストなので reason は300字、signals は8個・
  タグ形に正規化する(ログと履歴ファイルに入るため)
- openai: Structured Outputs (strict json_schema) でスキーマを強制。
  リトライは SDK の max_retries に任せる
- local: OpenAI互換。スキーマ非対応サーバでは json_object にフォールバック。
  base_url は設定から取り、localhost にハードコードしない
- bedrock: **Anthropic 専用クライアントではなく boto3 Converse を使う。**
  Converse はプロバイダ非依存の窓口で、同じリクエストが Claude / Nova /
  Llama / Mistral / gpt-oss に届く。ベンダー固有クライアントは1系統に最適で
  他が詰むので、差し替え可能な抽象の目的と逆になる。
  構造化出力は tool 定義で「要求」するが「依存」しない。tool 強制が
  非対応のモデルでは toolChoice: auto に降格し(一度で学習するので1500
  スレッドが毎回無駄な初回試行を払わない)、それも駄目ならテキストを読む。
  どの経路でも最後は共通パーサが検証するので、境界は転送方式ではなく enum
- factory: 遅延 import。依存の core/classify/aws 分割が意味を持つように

259 tests。ネットワーク・認証情報・課金なしで全バックエンドを検証。
分類が「何を変えるべきか」を決め、適用先は差し替え可能にする。

- pipeline: multiprocessing.Pool を ThreadPoolExecutor に置き換え。処理は
  端から端までHTTP待ちなので、プロセスは何も買わず多くを払っていた
  (classifier が picklable 必須 → API クライアントがモジュールグローバル、
  毎回のワーカー起動コスト、子プロセスが同じローテートログを開く)
- ratelimit: バッチ後スリープではなく1リクエストずつ整流する次スロット時計。
  遅いレスポンスが全ワーカーを止めなくなる
- actions: 宣言的ルール。設定に置くのは、これが交渉不可能であるべき半分
  だから。P1 に言いくるめられても、設定がそう言っていなければスターは付かない。
  最初にマッチしたルールが勝つ(和集合にすると設定の実効挙動が読めなくなる)
- planner: ルールBを実装。既存ラベル付きスレッドは数えるだけで再計画しない。
  ユーザーが手で直した分類を毎回上書きしないため。
  注入検知されたスレッドは優先度ラベルは付くが高権限の動詞(スター・タブ移動・
  重要マーク)を落とす。分類は成立させ、狙いであろう「目立たせる処理」だけ拒む
- dispatch: DirectSink / JsonlSink。**dry-run はループ内のフラグではなく
  シンクになった**。書き出した JSONL は再生可能で、
  「レビューしたものをそのまま適用する」が実際にできる
- applier: 同一ラベル変更の mutation をまとめて batchModify。
  1500往復が2回になる
- gmail/query: ラベル名を引用符で囲む。旧実装は生で埋め込んでいたので、
  空白を含むラベルで別物にマッチし、症状は「分類済みスレッドへの再課金」
  という気づけないものだった
- CLI: epc run / epc apply。終了コード 0=正常 1=致命的 2=部分失敗
- tests/unit/test_layering.py: actions/ と dispatch/ が classify/ と mime を
  import しないことを import グラフで検証する。誰も検証できない規約は
  いずれ崩れるため

327 tests。legacy はまだ残す(実メールボックスでの dry-run 検証後に削除)
- logging: structlog。秘匿は「方針」ではなく「最後の砦」として実装する。
  旧実装はリクエスト/レスポンス全体(=メール本文)を DEBUG でディスクに
  書いていた。ライブラリが悪かったのではなく、内容をそのまま渡していた。
  なので防御は2段構え: 呼び出し側は事実(ID・件数・enum)しか渡さない/
  それでも body・subject・sender・token 等のキーは処理側で置換し、全文字列に
  上限をかける。将来の呼び出し側がしくじっても、漏れるのは1行であって
  メールボックスではない
- report: 分類履歴 JSONL。**調査には足りるが復元には足りない**粒度にする。
  件名はダイジェスト、送信者はドメインのみ、本文は保存しない。この
  ファイルは実行より長生きし、S3 にコピーされ、誰も意識しないバックアップに
  入るため。既定は「記録しない」(自分のメールに関する判断の記録は、
  後から気づくものではなく明示的に選ぶもの)
- state: historyId チェックポイント。読めない/無い場合は例外ではなく
  「最初から」を返す。誤判定のコストはフルスキャン1回だが、例外のコストは
  「何もしない実行」なので。保存は一時ファイル+rename で、中断しても
  前回のチェックポイントが壊れない
- Gmail History API による差分同期。受信トレイを再列挙する代わりに
  「前回以降の変更」を訊く。チェックポイント失効(Gmail の保持は約1週間)は
  routine なのでフルスキャンに降格して継続する
- チェックポイントは**クリーンな実行のときだけ**前進させる。部分失敗後は
  同じ窓を再列挙し、成功済みスレッドはラベル除外で無料でスキップされる。
  失敗分が永久に飛ばされることがない

357 tests
OpenAI互換バックエンド向け。分類は短く仕様の決まった判断なので低い方で
足りることが多い。推論モデルでは思考も出力トークンとして課金されるため、
max_output_tokens は「答えの長さ」ではなく「思考+答え」の上限である旨を
明記する(512では推論で使い切る恐れがあったので 2048 に)。

未指定のパラメータは None で送らず省略する。パラメータを知らないモデルに
渡すべきではないため。
キーのスコープを api.responses.write / api.responses.read で作った結果、
chat.completions が 401 (Missing scopes: model.request) になった。これは
不足ではなく**そちらの方が適切**。model.request は「任意のモデルを任意の
エンドポイントで呼べる」広い権限で、シークレットストアに置いて無人で回る
キーには広すぎる。実装側を狭いスコープに合わせる。

- OpenAIClassifier: Responses API。instructions + input + text.format の
  strict json_schema + reasoning.effort
- LocalClassifier: chat completions のまま。LM Studio / llama.cpp / vLLM は
  chat completions を実装しており Responses はほぼ実装していないため、
  ここは追随できない。継承をやめて別クラスにする
- store=False を全リクエストに付ける。渡しているのは他人のメールで、
  プロバイダ側の保持は後から抜けるものではない
- omit センチネルを使い、未指定パラメータは送らない(**kwargs 展開だと
  SDK のオーバーロードが解決できず型が Any に落ちる)

実メールボックスで検証済み。legacy 判定との一致 12/15。
新実装が実メールボックスで通ることを確認できたので切り替える。
legacy 判定との一致は 12/15、不一致3件はいずれも新実装の方が妥当だった
(自動送信のOTPを P1 にしていた/個別サポートケースを P3 にしていた/
完了済みの礼状を P1 にしていた)。

削除:
- main.py, email_priority_classifier/ 一式
- Pipfile, Pipfile.lock(uv に一本化)
- test.py(ラベルID調査用のスクラッチ。epc labels が置き換える)
- secrets/token.pickle(任意コード実行の経路。token.json が置き換え済み)
- ruff の legacy 除外設定、Makefile の legacy-run、CLI の旧スキーマ検出

config.yml を新スキーマへ移行。labelID セクションは消える(ラベルIDは
起動時にメールボックスから名前で解決する)。actions は未経験の挙動
(スター付与・タブ移動)を含むので dry_run: true で置き、一度確認してから
外す運用にする。旧 config はリポジトリ外に退避済み。

CLAUDE.md を全面書き直し。旧実装の欠陥記述が大半を占めていたが、それらは
修正済みで参照先も消えたため。現行の不変条件・一見奇妙な選択の理由・
罠に絞る。README も uv / epc コマンド / 新 config スキーマに更新。

356 tests
旧実装の OPENAI_PROMPT_ID / OPENAI_PROMPT_VERSION(OpenAI ダッシュボードの
保存プロンプトを参照していた頃の変数)を削除。プロンプトはリポジトリ内に
移したので不要。

必要なスコープ、バックエンドごとに何が要るか、EPC__ による上書き方法、
そして .env が自動では読まれないことを明記する。
指摘のとおり run セクションだけ性質の違うものが同居していた。
dry_run(モード)と state_backend/state_file(チェックポイントの保管場所)
は別の話で、他のセクションが全て「何についての設定か」で括られているのと
揃っていなかった。

加えて、実装側に重複があった: dry_run と dispatch.sink が同じ判断を2箇所で
持ち、CLI が `if dry_run or sink == "jsonl"` と OR していた。

- dry_run はルートへ。セクションの一員ではなく**セクションを上書きするもの**
  だから。dispatch が何に設定されていようと「Gmail には書かない」を意味する。
  一部の dispatch 設定でしか効かない安全スイッチは、無いより悪い
- Settings.resolve_sink() に優先順位を集約。将来のエントリポイントが自動で
  従い、後から追加したシンクが dry-run 中に書いてしまう事故も防げる
- run → state に改名し、キーも backend / file に。credentials.backend /
  credentials.token_file と形が揃う

363 tests(dry_run の優先順位を固定するテストを追加)
ECS は SIGTERM を送り、stopTimeout(既定30秒)後に SIGKILL する。最初の
シグナルを無視すると実行中に殺され、分類済みで未書き込みのものが全部消える。
それは課金済みの成果物なので落としてはいけない。

停止要求の意味を「新しい作業を始めない」に限定する:
- 実行中の分類は終わらせる
- シンクを flush する(分類済みは Gmail に反映される)
- interrupted として報告し、チェックポイントは進めない

放棄したスレッドは失敗としては数えない。何も壊れておらず、次回また来るため。
ただしチェックポイントは据え置くので、確実に戻ってくる。

シグナルハンドラはフラグを立てるだけにする。停止処理は flush できるメイン
スレッド側の仕事なので。

370 tests
同一イメージがローカルでも Fargate でも動く形にする。差分は環境変数と
マウントだけ。焼き込むのはパッケージと汎用プロンプトのみで、config.yml /
policy.yml / secrets / log / .state はマウントで外から与える。

- ベースイメージをダイジェストで固定。タグは可変なので、ビルドのたびに
  ベースが変わるのは uv.lock で塞いだのと同じ種類の穴
- uv は ghcr.io のイメージからコピーせず PyPI から入れる。レジストリと
  資格情報の経路が1つ減る。uv 自体はランタイム層に残らず、実際に出荷される
  依存は uv.lock のパッケージ単位ハッシュで固定されている
- uv sync --no-editable。既定のエディタブルインストールだとランタイム層が
  builder にしか無い /build/src を指し、ビルドは通って import で落ちる
- secrets は :ro でマウント。「永続フィールドだけ保存する」設計の副産物で、
  トークンのリフレッシュに書き込みが要らない。epc login はホスト側で実行する
- rootfs は read-only、/tmp は tmpfs、UID 10001 固定(バインドマウントした
  log/ の所有者が予測可能になる)

CI は上記を仮定せず検証する: 非root、ランタイム層にビルドツールが無いこと、
そして .dockerignore が効いていること(秘密情報とプッシュされるレイヤの間の
最後の砦なので)。

実機で確認済み: ビルド 361MB、epc --version、Gmail 認証とクエリ、
read-only rootfs 下で log/ と .state/ にだけ書けること、ホストから
UID 10001 の書き込みが見えること。
分類側と適用側を別々に失敗・スケールできるようにする。いまは書き込み
フェーズの Gmail 429 がその分類結果ごと消すが、キューがあれば mutation は
待ち、バックオフ付きで再試行され、駄目なら DLQ に落ちる。ログの1行ではなく、
中身を見て再生できるものになる。

- dispatch/sqs.py: SqsSink / SqsSource。FIFO で MessageGroupId = thread_id。
  正しさを守っているのは重複排除ではなく**スレッド単位の順序保証**の方で、
  ラベル削除が入った後は同一スレッドへの add と remove の競合が実バグになる。
  MessageDeduplicationId は idempotency_key(スレッド+変更内容)にするので、
  同じ変更の再送は畳まれ、違う変更は畳まれない。ただし5分窓なので依存しない
- 送信は10件ずつバッチ。no-op はそもそも送らない(メッセージ1通と
  コンシューマ1回を使って同じ結論に達するだけなので)
- enqueue を applied として報告しない。適用するのはコンシューマ側で、
  ここで applied と言うのは未了の作業を主張することになる
- 壊れたメッセージは ack しない。直らないものなので、DLQ に落ちて人の目に
  触れるのが正しい
- gmail/auth.py: SsmCredentialStore。Secrets Manager ではなく Parameter
  Store を既定にする理由をコードに書いた(暗号化とアクセス制御は同等で、
  Secrets Manager が足すのは自動ローテーション等の本件に不要な機能)
- state.py: SsmStateStore(historyId は秘密ではないので平文 String)、
  S3StateStore
- backends.py: バックエンド選択を1箇所に集約。AWS の import は全て遅延
- epc login はバックエンドが指す先に保存する。ブラウザ認証はノートPCで、
  資格情報は Fargate タスクが読むパラメータへ、という運用ができる

397 tests
Delibird と同じレイアウト: environments/{env}/ に locals.tf / main.tf /
provider.tf / config.yml、modules/aws_<service>/ をリソースの役割でファイル
分割。モジュール間は {name, arn} オブジェクトで受け渡す。
environments/sample/ のみコミットし、dev/ prod/ は git 除外。

構成: ECR → Fargate タスク(ARM64) → EventBridge Scheduler
      → SQS FIFO + DLQ → apply Lambda → CloudWatch

秘密情報を Terraform state に通さない。SSM パラメータは空の placeholder で
作り lifecycle { ignore_changes = [value] } を付ける。中身は epc login と
aws ssm put-parameter で外から入れる。Terraform が所有するのはパラメータで
あって、その中身ではない。

IAM は ARN 単位で絞る:
- ECS の実行ロールとタスクロールを分ける。前者は ECS 自身がイメージを引く
  ためのもの、後者は中で動くプログラムのもの。混ぜるとプログラムに
  「イメージを引く権限」と「executor が読める全シークレット」が渡る
- apply Lambda は Gmail 認証情報だけ読める。モデルのキーは読めない。
  分類しない半分なので要らない(import 境界のテストが担保している性質)
- kms:Decrypt は ViaService 条件付き。SecureString の復号にのみ使える
- scheduler は特定タスク定義の RunTask と、その2ロールの PassRole のみ

いくつかの既定値に理由がある:
- スケジュールは enabled: false で作る。作った瞬間に走り出すと、最初の
  dry-run を読む機会が誰にもない
- SQS の visibility timeout (180s) は Lambda の timeout (120s) より長い。
  逆だと「遅い適用」が「二重適用」になる
- ECR は IMMUTABLE タグ。可変タグだと「いま何が動いているか」が答えられない
- アラームは DLQ の1本だけ。他は実行サマリで見えるか次回自己修復するので、
  鳴らすとアラーム自体が無視されるようになる

epc.aws.apply_handler も実装。config.yml もモデル設定も読まず、環境変数
3つだけで動く。部分バッチ失敗を返すので、1通の不良が9通を巻き込まない。

terraform validate / fmt は CI で実行。383 tests
ゴールデンセットは「一度作って使い回す」ものではない、という指摘から出た
設計判断を残す。同じ検討をやり直さないため。

- セットが仕様、プロンプトが実装。プロンプト変更には2種類あって、基準が
  同じで伝え方を直す場合はセットを触らない。基準そのものが変わった場合は、
  セットを直すことが「基準が変わった」と宣言する行為であって、追加の作業
  ではない。順番はむしろセットが先
- 基準変更で触るのは該当エントリだけ。作り直しではない
- 放置したセットは害になる。もう持っていない基準を正解と主張し、良い変更を
  悪化と報告する。対策として各エントリに判断理由を1行、判断日とプロンプト
  バージョンを持たせ、epc eval は点数だけでなく『前回から変わった件』を
  必ず出す。意図した変化と巻き添えの区別がこの仕組みの主目的なので
このツールの読み手は日本語話者なので、公開ドキュメントを日本語にする。
CLAUDE.md はモデルと実装者が読むものなので英語のまま。ログ・例外メッセージ・
プロンプトも英語を維持する。

翻訳のついでに、実態と合わなくなっていた記述を直した:

- 「追加操作のみ」を撤回。アクションエンジンが入り、move_to_primary は
  カテゴリラベルを外すし archive は受信トレイから出す。正しくは
  「破壊的な操作は allow_destructive を有効にしたときだけ」で、加えて
  「手で直した判定は上書きしない」を独立した方針として書いた
- 動作の流れの図を現在の実装に更新(ラベルID解決・無害化と予算配分・
  アクション計画・差分取得が抜けていた)
- 設定例を新スキーマに。maxThreads 等の旧キーが残っていた
- プライバシー節に store=false、token.json の中身、allow_destructive を追記

README に書いたコマンドとファイルが全て実在することを確認済み。
作り直しで config スキーマ・CLI・依存管理がすべて変わり、旧実装とは互換性が
ない。旧実装を 1 系とみなし、メジャーバージョンを上げる。

バージョンの定義元は pyproject.toml のみで、epc.__version__ は
importlib.metadata から導出している。uv.lock の差分は自パッケージの
version 行1行だけであることを確認済み(依存の解決結果は不変)。

prompts/system.md と policy.yml.example の version はプロンプト/ポリシーの
スキーマ版で、パッケージのバージョンとは独立しているので据え置く。
@KO1231 KO1231 changed the title 全体スクラップ&ビルド 全体作り直し Sep 16, 2026
@KO1231
KO1231 merged commit 3bc8740 into develop Sep 16, 2026
4 checks passed
@KO1231
KO1231 deleted the feature/scrap-and-build branch September 16, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant