Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/adr/0024-pdf-resume-import.md
Original file line number Diff line number Diff line change
@@ -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 抽出の導入と撤去の歴史)
7 changes: 6 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 一覧

Expand Down Expand Up @@ -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 |

## テーマ別の決定系統

Expand All @@ -77,9 +79,12 @@ graph LR
A0010 -.-> A0018["0018<br/>経歴書ドラフト生成"]
A0016 -.-> A0018
A0018 -.-> A0020["0020<br/>ドラフト生成の非同期化"]
A0004 -.->|"再導入(0010 の不変条件で)"| A0024["0024<br/>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 固定の上で再導入するもので、**空文字握りつぶしという旧敗因を契約で構造的に塞ぐ**再導入の実例になっている。

### 基盤(データ / インフラ / 認証)

Expand Down