diff --git a/docs/adr/0024-pdf-resume-import.md b/docs/adr/0024-pdf-resume-import.md new file mode 100644 index 00000000..aa381bcf --- /dev/null +++ b/docs/adr/0024-pdf-resume-import.md @@ -0,0 +1,104 @@ +# ADR-0024: 手持ち PDF 経歴書のフォーム流し込み(AI 抽出の再導入) + +## ステータス + +Accepted + +## 関連 ADR + +- 継承: [ADR-0010](0010-devforge-agent.md)(DevForge Agent の不変条件: LLM は `services/agent/` のみ・DB 非更新・エラー契約・構造化出力・リトライ 1 回)、[ADR-0023](0023-remove-billing-multiprovider.md)(LLM は Claude Haiku 固定 / ローカル Ollama) +- 手本: [ADR-0018](0018-github-resume-draft-generation.md)(構造 = ルールベース / 自然文 = LLM・構造化出力・DB 非更新でフォーム前段まで)。同期方式は `POST /agent/chat`(ADR-0010)に倣う +- 歴史的経緯(**再発防止のため必ず参照**): [ADR-0004](0004-llm-provider-abstraction.md) で「職務経歴書 PDF の AI 抽出」を導入 → [ADR-0008](0008-remove-llm-to-rule-based-design.md) で撤去した。本 ADR はこの機能を**再導入**するが、旧設計の敗因(下記)を ADR-0010 の不変条件で構造的に塞ぐ +- 前提: [#524](https://github.com/yusuke0610/devforge/issues/524)(Resume payload → フォーム state 注入機構)。本機能の抽出結果はこの共有部品に渡す + +## コンテキスト + +転職者はほぼ全員、手持ちの経歴書 PDF を持っている。それでも DevForge は**空フォームを前に手入力を始める瞬間が最大の離脱ポイント**になっている(コールドスタート問題)。手持ち PDF をアップロード → LLM で構造化抽出 → フォームに流し込むことで、ユーザは「ゼロから入力」ではなく「既存内容を確認・微修正」から始められる。 + +この機能自体は過去に存在した(ADR-0004 で導入)。**旧設計の敗因は、抽出関数 `generate()` が失敗時に空文字を返し、エラーが UI に届かなかったこと**(ユーザは「何も起きない」体験に直面した)。ADR-0008 でルールベース設計へ寄せた際に撤去された。 + +再導入にあたっては、以下の環境が旧時代と異なる: + +- **ADR-0010 の Agent 基盤が確立済み**: 構造化出力(tool use スキーマ)・明示的なエラー契約(`AGENT_LLM_ERROR` / `AGENT_PARSE_ERROR` の 502)・リトライ 1 回・DB 非更新という不変条件がある。空文字握りつぶしは契約違反として構造的に禁止できる。 +- **ADR-0018 の「構造 = ルールベース / 自然文 = LLM・DB 非更新でフォーム前段まで」の実績**がある。PDF 抽出も同じ流儀に乗せられる。 +- **ADR-0023 で LLM が Haiku 固定**になり、プロバイダ選択の複雑さが無い。 + +## 決定内容 + +**手持ち PDF 経歴書を Claude Haiku で構造化抽出し、Resume 互換 payload を返す同期エンドポイントを `services/agent/` 配下に追加する。** DB は更新せず、抽出結果はフォーム注入機構(#524)経由でユーザが確認・保存する(ADR-0010 の DB 非更新原則)。 + +### v1 スコープ: テキスト埋め込み PDF のみ + +- **対応**: テキストが埋め込まれた PDF(ワープロ / 変換出力の経歴書。大多数)。 +- **非対応**: スキャン PDF(画像)。OCR / vision が必要でコスト・失敗モードが別物のため v1 では扱わない。**抽出テキストが空 or 極端に少ない場合はスキャン PDF と判定し、日本語メッセージで明示的にエラーを返す**(「テキストを含む PDF のみ対応。スキャン画像は手入力をお願いします」等)。無言で失敗させない。 + +### PDF テキスト抽出: pypdf + +- ライブラリは **pypdf**(純 Python / MIT / ネイティブ依存なし)を採用し、`backend/pyproject.toml` に `==` 完全固定で追加する(P7 / ADR-0021 の uv2nix build)。 +- pypdf は純 Python のため `flake.nix` のネイティブ依存(devshell / backendRuntime)変更が不要。WeasyPrint(生成専用)の libvips 系ネイティブ依存とは別系統で、サプライチェーン面も軽い。 +- テキスト抽出の前処理(改ページ結合・空白正規化・スキャン判定)は**決定論ロジック**として実装し、TDD 対象とする(`.claude/rules/common/tdd.md`)。 + +### 同期方式(`POST /agent/chat` 相当) + +- PDF テキスト抽出(ms オーダー)+ Haiku 1 コールの構造化抽出で完結し、`/chat`(ADR-0010)と同等の負荷で 60s 内に収まる。**非同期タスク基盤(ADR-0020)は過剰**なため採らない。 +- エンドポイント: `POST /api/agent/resume-import/pdf`(multipart アップロード)。レスポンスは **Resume 互換 payload のみ**(DB 非更新)。 +- 構造化出力は ADR-0018 の流儀を踏襲: 機械制約(フィールド構造・文字数上限)はスキーマ(`services/agent/resume_import/output_schema.py`)に、品質制約(捏造禁止・目安字数・抽出方針)はプロンプト(`app/prompts/agent_resume_import.md`)に置く(P4)。文字数上限は `output_schema.SCOPE_FIELDS`(= `schemas/resume.py` と drift テスト済み)を正本として揃える。 + +### エラー契約(旧設計の空文字握りつぶしの再発防止) + +抽出・パースの失敗を**明示的な日本語 HTTP エラー**で返す。ADR-0010 の契約を継承する。 + +| 事象 | HTTP | ErrorCode | +|---|---|---| +| PDF 以外 / 破損 / サイズ超過 | 422 | `VALIDATION_ERROR` | +| テキスト非埋め込み(スキャン PDF 判定) | 422 | `VALIDATION_ERROR`(専用メッセージ) | +| LLM 呼び出し失敗 | 502 | `AGENT_LLM_ERROR` | +| 抽出結果のパース / スキーマ違反(リトライ後も失敗) | 502 | `AGENT_PARSE_ERROR` | +| レート制限超過 | 429 | — | + +**空の payload を「成功」として返すことを禁止する**(旧敗因の構造的封じ込め)。抽出できなければ上表のいずれかで倒す。 + +### レート制限(#521 / ADR-0023) + +`resume-import/pdf` は LLM を呼ぶ高コスト経路のため、ユーザ単位の日次上限(`enforce_daily_limit`)の対象に含める。既存の `/chat`・`/resume-draft` と同じ蓋を掛ける。 + +### ファイルアップロードのバリデーション(`security.md`) + +- MIME だけでなく PDF の magic bytes(`%PDF-`)で検証する。 +- ファイルサイズ上限をエンドポイントで強制し OOM を防ぐ(上限は妥当な既定値。必要なら env 化を #527 の stage で判断)。 +- 保存はしない(抽出してレスポンスを返すのみ・一時ファイルも即破棄)。パストラバーサルの余地を作らない。 + +## 代替案 + +- **非同期タスク化(ADR-0020 パターン)**: 抽出 + Haiku 1 コールは同期で 60s 内に収まり、タスク基盤・ポーリング・状態遷移の追加は保守コストに見合わない。却下(P1)。 +- **pdfplumber / PyMuPDF**: pdfplumber は表・レイアウト抽出に強いが依存が重く、経歴書の素テキスト抽出には pypdf で十分。PyMuPDF は高速だが AGPL ライセンス + ネイティブ(MuPDF)依存で、個人開発 SaaS のライセンス・nix build 負荷の観点から却下。 +- **スキャン PDF を v1 で OCR 対応**: OCR / vision はコスト・失敗モード・レイテンシが別物。まず大多数のテキスト埋め込み PDF で価値を出し、スキャン対応は将来判断とする(P6)。 +- **抽出結果を直接 DB へ保存**: ADR-0010 の DB 非更新原則に反する。ユーザ確認を挟まない自動保存は捏造・誤抽出をそのまま確定させる。却下(P5)。 + +## トレードオフ・既知のリスク + +- **スキャン PDF は非対応**(画像経歴書のユーザは手入力へ誘導)。テキスト埋め込みかどうかはユーザには自明でないため、判定時の日本語メッセージで「なぜ使えないか・次に何をすべきか」を明示する。 +- **抽出品質は PDF の構造に依存する**(多段組・特殊フォント・図表混在で精度が落ちる)。だからこそ DB 非更新でユーザ確認を挟む設計にし、誤抽出を保存前に直せるようにする。 +- **PDF に含まれる PII(氏名・経歴)を LLM に送る**。ただし ADR-0023 で残した Anthropic は Vertex AI(ADC) 経由(学習除外・アジア圏データ所在 / ADR-0015 の判断を継承)であり、既存の `/chat`・`/resume-draft` と同じ信頼境界に収まる(P2)。新たな境界を越えるものではない。 +- **Haiku の API コストが 1 インポートあたり発生する**(運営持ち / ADR-0023)。レート制限(#521)で abuse を抑える。 + +## 将来の移行条件 + +- **スキャン PDF 対応**: OCR / vision モデルを導入する場合は本 ADR を更新し、コスト(vision は Haiku より高い)・失敗モード・レート制限見直しを再評価する。 +- **抽出精度が用途に不足する場合**: プロンプト(品質基準・few-shot)で対処する。ADR-0023 でモデル昇格パスは撤去済みのため、上位モデル前提の改善は新規 ADR 起票が前提。 +- 本機能自体が使われない・維持コストが価値を上回ると判断した場合は、ADR-0008 の流儀で撤去する(P6。ADR-0004→0008 の前例どおり可逆に畳める)。 + +## 設計原則との関係 + +- **P1(コスト最適化)**: 同期・pypdf(純 Python)・タスク基盤なしで最小構成。非同期・重量ライブラリを避ける。 +- **P2(PII を信頼境界の外に出さない)**: 抽出の LLM は既存の Anthropic Vertex(ADC) 経路(学習除外・アジア圏所在 / ADR-0015 継承)に収め、新たな境界を作らない。抽出結果は DB に保存せずフォーム前段で留める。 +- **P4(責務を層で分離する)**: 機械制約 = スキーマ / 品質制約 = プロンプト、決定論の前処理 = ルールベース / 自然文抽出 = LLM を混ぜない。 +- **P5(LLM は対話型に限定)**: 抽出結果は自動確定せず、ユーザが確認して保存する(DB 非更新)。 +- **P6(可逆性)**: ADR-0004→0008 で一度撤去した機能の再導入。撤退条件を明記し可逆に畳める。 +- **P7(依存は固定)**: pypdf を `==` 完全固定で追加し `uv lock` を再生成する。 + +## 関連リンク + +- [#517 機能整理・体験改善ロードマップ](https://github.com/yusuke0610/devforge/issues/517)(親 issue) +- #526(本 ADR)/#527(backend 抽出エンドポイント)/#528(web アップロード UI)/#524(フォーム注入機構・前提) +- [ADR-0010](0010-devforge-agent.md) / [ADR-0018](0018-github-resume-draft-generation.md)(手本)/[ADR-0004](0004-llm-provider-abstraction.md) / [ADR-0008](0008-remove-llm-to-rule-based-design.md)(AI 抽出の導入と撤去の歴史) diff --git a/docs/adr/README.md b/docs/adr/README.md index 1bfcd951..1e89b631 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ | [ADR-0021](./0021-nix-managed-python-env.md) | backend Python 環境の Nix フルマネージド化(.venv 廃止) | 開発プロセス / 品質 | 依存 SSoT を pyproject + uv.lock に一本化し、devshell / CI / 本番イメージの 3 経路を uv2nix(flake)で統一 | | [ADR-0022](./0022-remove-blog-integration.md) | ブログ連携機能の撤去 | プロダクト / 機能整理 | 経歴書へ還流せずスコアが逆効果になり得るブログ連携(テーブル 3 つ・router / service / web 一式)を全量撤去 | | [ADR-0023](./0023-remove-billing-multiprovider.md) | プリペイド課金・マルチプロバイダの撤去と Haiku 無料一本化 | LLM / Agent | 課金の壁を撤去し Haiku 無料一本化 + ユーザ単位レート制限へ縮退。Anthropic は Vertex(ADC) 維持、Gemini/OpenAI/Stripe を撤去 | +| [ADR-0024](./0024-pdf-resume-import.md) | 手持ち PDF 経歴書のフォーム流し込み(AI 抽出の再導入) | LLM / Agent | 空フォーム離脱の解消。テキスト埋め込み PDF を pypdf 抽出 + Haiku 構造化で Resume 互換 payload に。同期・DB 非更新でフォーム注入(#524)へ渡す。ADR-0004→0008 で撤去した AI 抽出を ADR-0010 の不変条件で再導入 | ## 全 ADR 一覧 @@ -56,6 +57,7 @@ | [ADR-0021](./0021-nix-managed-python-env.md) | backend Python 環境の Nix フルマネージド化(.venv 廃止) | Accepted | 開発プロセス / 品質 | 関連: 0017(uv 非管理前提を更新)、0014(依存固定・Renovate manager)、0007(Nix devshell 規約) | P7・P3・P6 | | [ADR-0022](./0022-remove-blog-integration.md) | ブログ連携機能の撤去 | Accepted | プロダクト / 機能整理 | 手本: 0008(撤去の流儀)。関連: 0010(Agent コンテキストの入力縮小)、0016 | P6・P1 | | [ADR-0023](./0023-remove-billing-multiprovider.md) | プリペイド課金・マルチプロバイダの撤去と Haiku 無料一本化 | Accepted | LLM / Agent | Supersedes: 0012・0013・0015。関連: 0010(Agent 不変条件を継承)、0018・0020(課金配線を剥がす)、0005 | P1・P6・P2 | +| [ADR-0024](./0024-pdf-resume-import.md) | 手持ち PDF 経歴書のフォーム流し込み(AI 抽出の再導入) | Accepted | LLM / Agent | 継承: 0010(不変条件)、0023(Haiku 固定)。手本: 0018。歴史: 0004→0008(AI 抽出の導入と撤去)。前提: #524 | P1・P2・P5・P6 | ## テーマ別の決定系統 @@ -77,9 +79,12 @@ graph LR A0010 -.-> A0018["0018
経歴書ドラフト生成"] A0016 -.-> A0018 A0018 -.-> A0020["0020
ドラフト生成の非同期化"] + A0004 -.->|"再導入(0010 の不変条件で)"| A0024["0024
PDF 経歴書 AI 抽出"] + A0010 -.-> A0024 + A0023 -.-> A0024 ``` -このプロダクトで最も判断の往復が大きい系統。「LLM 抽象の先行実装(0004)→ 利用見込み薄と判断して全撤去(0008)→ 対話型として価値が明確になった時点で、0008 自身が規定した手続きに従い再導入(0010)→ 課金・マルチプロバイダ・データガバナンスへ段階拡張(0012/0013/0015)」という流れで、**撤退条件を先に書いておく運用が実際に機能した実例**になっている。0016 は 0010 の「機械検証可能な制約はコード、不能な制約はプロンプト」という責務分離を「機械=幅 / 人間=深さ」の 3 層モデルへ一般化した。0018 は 0010 の不変条件と 0016 の決定論データを前提に、経歴書ドラフト生成へ「構造=機械 / 自然文=LLM」の分離を適用した。 +このプロダクトで最も判断の往復が大きい系統。「LLM 抽象の先行実装(0004)→ 利用見込み薄と判断して全撤去(0008)→ 対話型として価値が明確になった時点で、0008 自身が規定した手続きに従い再導入(0010)→ 課金・マルチプロバイダ・データガバナンスへ段階拡張(0012/0013/0015)」という流れで、**撤退条件を先に書いておく運用が実際に機能した実例**になっている。0016 は 0010 の「機械検証可能な制約はコード、不能な制約はプロンプト」という責務分離を「機械=幅 / 人間=深さ」の 3 層モデルへ一般化した。0018 は 0010 の不変条件と 0016 の決定論データを前提に、経歴書ドラフト生成へ「構造=機械 / 自然文=LLM」の分離を適用した。0024 は 0004 で導入し 0008 で撤去した「PDF 経歴書の AI 抽出」を、0010 の不変条件(DB 非更新・明示的エラー契約)と 0023 の Haiku 固定の上で再導入するもので、**空文字握りつぶしという旧敗因を契約で構造的に塞ぐ**再導入の実例になっている。 ### 基盤(データ / インフラ / 認証)