diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9f3a1dfc..fd7f0e63 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -55,6 +55,7 @@ error: opening lock file "~/.cache/nix/fetcher-locks/...lock": Operation not per - **コメント・ドキュメント**: コード内コメント・docstring・JSDoc はすべて**日本語**で記述する。 - **エラーメッセージ**: HTTPException の `detail` 等、ユーザーに返すメッセージはすべて**日本語**。 +- **エラーメッセージのハードコード禁止**: ts/tsx でリテラル日本語を直接書かない(`throw new Error("...")` / `setError("...")` / `toast.error("...")` 等)。frontend 完結のメッセージは `frontend/src/constants/messages.ts` の定数を参照、API 経由のエラーは backend の `messages.json` 由来の `AppErrorResponse.message` を表示する。詳細: `.claude/rules/frontend/messages.md` - **例外の握りつぶし禁止**: `except SomeException: pass` は禁止。最低でも `logger.debug/warning/error` でログを残す。補助処理(通知生成など)で抑制する場合も `logger.warning` でログを出すこと。 - **過剰な抽象化を避ける**: PEP8 を守るな、PEP8 を理解した上で抽象化しろ。 @@ -103,9 +104,6 @@ CI 定義: `.github/workflows/ci.yml` | 種別 | 名前 | |---|---| | 職務経歴書(career history) | `Resume` / `resumes` テーブル | -| 履歴書(personal CV) | `Rirekisho` / `rirekisho` テーブル | - -> `rirekisho` は日本語ローマ字のため cSpell の警告が出るが無視してよい。 ## 環境変数 diff --git a/.claude/rules/backend/auth-security.md b/.claude/rules/backend/auth-security.md index 540dac8b..09079aef 100644 --- a/.claude/rules/backend/auth-security.md +++ b/.claude/rules/backend/auth-security.md @@ -17,8 +17,6 @@ paths: - 認証 Cookie 属性は `COOKIE_SECURE` / `COOKIE_SAMESITE` で制御する ## 暗号化 - -- 履歴書(Rirekisho)の個人情報フィールド(email / phone / postal_code / address)は `app/core/encryption.py` で暗号化保存 - 鍵は `FIELD_ENCRYPTION_KEY` 環境変数(Fernet) ## セキュリティ diff --git a/.claude/rules/backend/database.md b/.claude/rules/backend/database.md index d6f12f06..32e9ebc3 100644 --- a/.claude/rules/backend/database.md +++ b/.claude/rules/backend/database.md @@ -5,7 +5,7 @@ paths: # DB設計ルール -- `basic_info` / `resumes` / `rirekisho` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること +- `basic_info` / `resumes` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること - 可変長データを JSON カラムへ増やさないこと。資格・学歴・職歴・職務経歴の明細・ブログタグは子テーブルへ正規化すること - 日付は可能な限り DB の `DATE` / `TIMESTAMP` を使うこと - `blog_articles` は `account_id` 起点で管理し、`user_id` や `platform` を冗長保持しないこと diff --git a/.claude/rules/frontend/messages.md b/.claude/rules/frontend/messages.md new file mode 100644 index 00000000..9912aeaf --- /dev/null +++ b/.claude/rules/frontend/messages.md @@ -0,0 +1,101 @@ +# メッセージ管理ルール (frontend) + +ts/tsx でユーザーに表示される文字列を**リテラルで直接書かない**。 +必ず Single Source of Truth から取得すること。 + +## SSoT の責務分離 + +| メッセージの種類 | 正本 | 取得経路 | +|---|---|---| +| **API 経由のエラー** (backend → frontend) | `backend/app/messages.json` | `AppErrorResponse.message` を `api/client.ts:buildApiError` 経由でそのまま表示 | +| **API レスポンスに message が無い時の補完** | `frontend/src/constants/errorMessages.ts` (`ERROR_CONFIG`) | `ErrorCode` を引いて補完(既存実装) | +| **frontend 完結のメッセージ** | `frontend/src/constants/messages.ts` | import して定数参照 | + +`messages.json` から frontend 用 TS 定数を build-time 生成する仕組みは**入っていない**。 +`ERROR_CONFIG` は `backend/app/core/errors.py:ErrorCode` enum と**手動同期**する設計(型エラーで漏れを検出)。 + +## frontend 完結のメッセージとは + +backend を経由しない以下のような文言: + +- **フォームの事前バリデーション**: `payloadBuilders.ts` の「氏名を入力してください」など +- **catch ブロックの fallback メッセージ**: `e instanceof Error ? e.message : "..."` の `...` 部分 +- **ネットワーク層の fallback**: `api/client.ts` で 5xx / fetch 例外時に出す文言 +- **JSX 直書きの UI 文言**: `ErrorBoundary` のタイトルなど +- **開発者向け内部エラー**: `import_id が未設定です` のような状態管理エラー + +これらは `frontend/src/constants/messages.ts` に集約する。カテゴリ別の定数: + +- `VALIDATION_MESSAGES` — 入力バリデーション +- `NETWORK_MESSAGES` — ネットワーク / API クライアント層 +- `FALLBACK_MESSAGES` — catch fallback / toAppError fallback +- `UI_MESSAGES` — JSX 直書き文言 +- `INTERNAL_MESSAGES` — 開発者向け内部エラー +- `downloadFailureMessage(filename)` — 動的パラメータが必要なケースは関数 + +## 新規メッセージ追加の手順 + +### API 経由のエラー(backend が発火) + +1. `backend/app/messages.json` の `error.` にキー追加 +2. backend で `get_error("category.key", **kwargs)` または `raise_app_error(code=...)` で使う +3. frontend 側はとくに変更不要(`AppErrorResponse.message` が自動的に画面に出る) + +### frontend 完結のメッセージ + +1. `frontend/src/constants/messages.ts` の適切なカテゴリに定数追加 +2. 使用箇所で import して参照 +3. リテラルを書かない + +## やってはいけないこと(再発防止対象) + +以下は **ESLint または `make lint-frontend-messages` で自動検知され CI で fail する**: + +```ts +// ✗ ESLint で error +throw new Error("入力してください"); +throw new Error(`${field} を入力してください`); + +// ✗ make lint-frontend-messages で error +setError("失敗しました"); +setErrorMessage("不正な値です"); +setAccountError("取得に失敗"); +toast.error("エラー"); +alert("確認してください"); +``` + +正しい書き方: + +```ts +import { VALIDATION_MESSAGES, FALLBACK_MESSAGES } from "../constants/messages"; + +throw new Error(VALIDATION_MESSAGES.FULL_NAME_REQUIRED); +setError(FALLBACK_MESSAGES.SAVE); +``` + +## 例外: 検知から外れているもの + +以下はリテラルを書いても検知されない(許容するが推奨しない): + +- 英語の開発者向けメッセージ (`throw new Error("invariant violated")`) +- `console.error` / `console.warn`(UI に表示されないログ用途) +- テストファイル (`*.test.*`, `test/**`) +- `constants/messages.ts` 自身 + +## 検証 + +```bash +make lint-frontend # ESLint(no-restricted-syntax 含む) +make lint-frontend-messages # grep ベースの追加チェック +``` + +両方を pass させることが「テスト OK」条件の前提(`.claude/rules/frontend/test.md` 参照)。 + +## 参考 + +- `frontend/src/constants/messages.ts` — frontend 完結メッセージの SSoT +- `frontend/src/constants/errorCodes.ts` / `errorMessages.ts` — backend ErrorCode 連携 +- `backend/app/messages.json` — backend のメッセージ正本 +- `backend/app/core/errors.py` — ErrorCode enum +- `scripts/lint-frontend-messages.sh` — grep ベースの検知スクリプト +- `frontend/eslint.config.js` — no-restricted-syntax ルール定義 diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 00000000..0373f954 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,130 @@ +--- +paths: + - backend/** + - frontend/** + - infra/** +--- + +# セキュリティルール(全領域横断) + +このファイルは backend / frontend / infra すべての領域に適用される横断セキュリティルール。 +認証・JWT・Cookie 属性・暗号化・Rate Limit の詳細は `.claude/rules/backend/auth-security.md` を参照し、ここでは重複させない。 + +--- + +## 秘密情報管理(Secrets Management) + +### Git に含めてはいけないもの + +- `.env` / `.env.*` — ローカル開発用環境変数 +- `*.pem` / `*.key` — 秘密鍵・証明書 +- `*.json` のうち GCP サービスアカウント鍵に該当するもの +- `turso-auth-token` 等の認証トークンをハードコードした設定ファイル + +### 環境変数の正本 + +- **定数名の定義**: `backend/app/core/env_keys.py` +- **用途と注入経路の一覧**: `docs/api.md` の「環境変数」セクション +- **本番注入**: `infra/modules/cloud_run/main.tf` の `env` ブロック(Secret Manager 参照形式) +- **ローカル**: `docker-compose.yml` + +backend 内で文字列リテラル `os.getenv("XXX")` を使うことは禁止。`env_keys.XXX` 経由で参照する。 + +### ログへの秘密情報出力禁止 + +認証トークン・API キー・パスワード・個人情報(メールアドレス含む)をログに出力しない。 +デバッグ目的でも `logger.debug` にこれらを含めないこと。 + +--- + +## 入力バリデーション・出力エスケープ + +### Backend + +- **Pydantic バリデーション必須**: API エンドポイントへの入力はすべて `app/schemas/` の Pydantic モデルで型・制約を検証する。`Any` 型や `dict` 型の素通しは避ける +- **SQL インジェクション防止**: SQLAlchemy ORM / Core のパラメータバインドを使う。文字列連結でクエリを組み立てることは禁止 +- **LLM プロンプトへのユーザー入力**: ユーザー由来の文字列を LLM プロンプトに埋め込む場合は `backend/app/services/llm/sanitizer.py` を通す + +### Frontend + +- **`dangerouslySetInnerHTML` は原則禁止**: 外部コンテンツや動的文字列を `innerHTML` / `dangerouslySetInnerHTML` に渡さない。React の自動エスケープを信頼する +- **外部リンク**: `` には必ず `rel="noopener noreferrer"` を付ける(タブナビゲーション攻撃の防止) +- **URL パラメータの扱い**: `window.location.search` 等から取得した値を DOM に直接レンダリングしない。必ず React の `state` / `props` 経由で扱う + +--- + +## Frontend セキュリティ + +### トークン管理 + +- アクセストークン・リフレッシュトークンは `HttpOnly` + `Secure` Cookie で管理する(詳細: `.claude/rules/backend/auth-security.md`) +- `localStorage` / `sessionStorage` にトークンを保存しない(XSS で盗取されるリスク) +- Redux の `store` にも生のトークン文字列を乗せない + +### XSS 対策まとめ + +1. `dangerouslySetInnerHTML` 禁止(上述) +2. Markdown レンダラー等を使う場合は sanitize オプションを有効化する +3. `eval()` / `Function()` コンストラクタの使用禁止 + +### 依存関係 + +- `npm audit` で High / Critical CVE が検出された場合は PR マージ前に対処する +- CI が `npm audit --audit-level=high` で落ちた場合は、`--force` で無視せず脆弱なパッケージを更新すること + +--- + +## Backend セキュリティ(追加事項) + +認証・JWT・Rate Limit・CORS・INTERNAL_SECRET については `.claude/rules/backend/auth-security.md` を参照。 + +### ファイルアップロード + +ファイルアップロード機能を追加する場合は以下をすべて実装する: + +1. **MIME タイプ検証**: `Content-Type` ヘッダだけでなくファイルのバイト列(magic bytes)で検証する +2. **ファイルサイズ上限**: エンドポイント側で上限を設け、OOM を防ぐ +3. **ファイル名サニタイズ**: パストラバーサル(`../` 等)を除去する。UUIDv4 でリネームするのが最もシンプル +4. **保存先**: 本番ではローカルファイルシステムに保存せず GCS 等の外部ストレージを使う + +### 依存関係 + +- `pip audit` / `safety` で定期的に CVE チェックを行う +- CI に `pip-audit` を組み込むことを推奨(High 以上を fail 条件にする) + +--- + +## Infra セキュリティ + +### IAM 最小権限 + +- Cloud Run のサービスアカウントには必要最小限のロールのみ付与する(`infra/modules/service_account/`) +- 新規ロールを付与する際は「なぜそのロールが必要か」をコメントで残す +- `roles/owner` / `roles/editor` 等の広範なロールを Cloud Run SA に付与しない + +### Secret Manager + +- DB 接続 URL・API キー・JWT 秘密鍵等はすべて Secret Manager に格納し、Cloud Run の `secretEnv` / `secretVolume` 経由で注入する +- Terraform state に平文のシークレットが乗らないよう、`sensitive = true` を必ず付与する +- Turso auth token: state 漏洩防止のため `turso CLI` で発行 → Secret Manager に手動投入(詳細: `.claude/rules/infra/opentofu.md`) + +### デプロイ制限 + +- `tofu apply -auto-approve` をローカルから本番環境に直接流さない(詳細: `.claude/rules/infra/test.md`) +- `lifecycle { prevent_destroy = true }` 付きリソースへの破壊的変更は実行前に必ず確認する + +--- + +## セキュリティレビューチェックリスト + +AI エージェントがコードを変更した後に確認する項目: + +- [ ] 秘密情報(トークン・キー・パスワード)が Git に含まれていないか +- [ ] 環境変数を文字列リテラルで直接参照していないか(`env_keys.XXX` 経由か) +- [ ] 入力バリデーションが境界(API エンドポイント・フォーム)で行われているか +- [ ] `dangerouslySetInnerHTML` / `innerHTML` の新規使用がないか +- [ ] ログに個人情報・認証情報が出力されないか +- [ ] 新規エンドポイントに認証ガード(`get_current_user` 依存)が付いているか +- [ ] 高コスト処理(外部 API 呼び出し・LLM 実行)に rate limit があるか(`slowapi`) +- [ ] `target="_blank"` に `rel="noopener noreferrer"` が付いているか +- [ ] 新規 IAM ロール付与に最小権限の原則を守っているか diff --git a/.vscode/settings.json b/.vscode/settings.json index d194e1b5..e34fca62 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -179,14 +179,15 @@ "turso", "libsql", "Qiita", - "pyproject" + "pyproject", + "pdfgen", + "pdfplumber" ], "flake8.args": [ "--max-line-length=100" ], "editor.linkedEditing": true, "html.autoClosingTags": true, - "js/ts.autoClosingTags.enabled": true, "[terraform]": { "editor.defaultFormatter": "hashicorp.terraform", "editor.formatOnSave": true, diff --git a/Makefile b/Makefile index 6ee74ec0..a144928e 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ setup install-hooks install-backend install-frontend generate-keys \ dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only \ test test-backend test-frontend \ - lint lint-backend lint-frontend lint-fix \ + lint lint-backend lint-frontend lint-frontend-messages lint-fix \ format format-check \ ci \ dupe-check dupe-check-html dupe-clean \ @@ -38,6 +38,7 @@ help: @echo " lint 全リント (backend + frontend)" @echo " lint-backend Backend: ruff check" @echo " lint-frontend Frontend: eslint" + @echo " lint-frontend-messages Frontend: setError等にリテラル日本語が渡っていないか検知" @echo " lint-fix リント自動修正 (ruff + eslint)" @echo " format Prettier で整形" @echo " format-check Prettier チェック" @@ -125,7 +126,7 @@ test-backend: test-frontend: nix develop --command bash -c "cd frontend && npm test" -lint: lint-backend lint-frontend +lint: lint-backend lint-frontend lint-frontend-messages lint-backend: nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check app tests alembic_migrations" @@ -133,6 +134,11 @@ lint-backend: lint-frontend: nix develop --command bash -c "cd frontend && npm run lint" +# ts/tsx で setError/toast.error/alert にリテラル日本語を直接渡していないか検知。 +# ESLint は throw new Error の AST しか拾えないため、関数呼び出し系をここで補完する。 +lint-frontend-messages: + nix develop --command bash scripts/lint-frontend-messages.sh + lint-fix: nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check --fix app tests alembic_migrations" cd frontend && npm run lint:fix diff --git a/backend/alembic_migrations/versions/0032_add_resume_imports.py b/backend/alembic_migrations/versions/0032_add_resume_imports.py new file mode 100644 index 00000000..cd2e4031 --- /dev/null +++ b/backend/alembic_migrations/versions/0032_add_resume_imports.py @@ -0,0 +1,56 @@ +"""add resume_imports table + +Revision ID: 0032_add_resume_imports +Revises: 0031_add_warning_message_to_github_analysis_cache +Create Date: 2026-05-22 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0032_add_resume_imports" +down_revision: Union[str, None] = "0031_add_warning_message_to_github_analysis_cache" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "resume_imports", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "user_id", + sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("pdf_blob", sa.LargeBinary, nullable=True), + sa.Column("result_json", sa.Text, nullable=True), + sa.Column("is_resume_flag", sa.Boolean, nullable=True), + sa.Column("judge_reason", sa.Text, nullable=True), + sa.Column("error_message", sa.Text, nullable=True), + sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("resume_imports") diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index bc182bef..7c41203a 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -33,6 +33,9 @@ class ErrorCode(str, Enum): VALIDATION_ERROR = "VALIDATION_ERROR" # 外部 API QIITA_RATE_LIMITED = "QIITA_RATE_LIMITED" + # 職務経歴書インポート + RESUME_IMPORT_INVALID = "RESUME_IMPORT_INVALID" + RESUME_IMPORT_NOT_A_RESUME = "RESUME_IMPORT_NOT_A_RESUME" # アプリケーション全体 RATE_LIMITED = "RATE_LIMITED" # サーバー @@ -107,6 +110,10 @@ def infer_error_code(status_code: int, detail: Any = None) -> ErrorCode: elif isinstance(detail, str): message = detail + if "not_a_resume" in message: + return ErrorCode.RESUME_IMPORT_NOT_A_RESUME + if "スキャン PDF" in message: + return ErrorCode.RESUME_IMPORT_INVALID if "GitHubユーザーが見つかりません" in message: return ErrorCode.GITHUB_USER_NOT_FOUND if "タイムアウト" in message: diff --git a/backend/app/main.py b/backend/app/main.py index dede70b2..1acbdf45 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,6 +37,7 @@ internal_router, master_data_router, notifications_router, + resume_imports_router, resumes_router, ) @@ -209,6 +210,7 @@ async def dispatch(self, request: Request, call_next) -> Response: app.include_router(health_router) app.include_router(career_analysis_router) app.include_router(auth_router) +app.include_router(resume_imports_router) app.include_router(resumes_router) app.include_router(intelligence_router) app.include_router(blog_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b5da8951..9a0641c4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,10 +15,12 @@ ResumeProjectTechnologyStack, ResumeQualification, ) +from .resume_import import ResumeImport from .user import User __all__ = [ "CareerAnalysis", + "ResumeImport", "BlogAccount", "BlogArticle", "BlogArticleTag", diff --git a/backend/app/models/resume_import.py b/backend/app/models/resume_import.py new file mode 100644 index 00000000..c489f7e0 --- /dev/null +++ b/backend/app/models/resume_import.py @@ -0,0 +1,59 @@ +"""PDF 職務経歴書インポートタスクのキャッシュレコード。""" + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..db import Base + + +def _default_expires_at() -> datetime: + return datetime.now(timezone.utc) + timedelta(days=1) + + +class ResumeImport(Base): + """PDF インポートタスクのキャッシュレコード。 + + run 完了または失敗後に pdf_blob は null クリアされる。 + resumes テーブルへの INSERT は行わず、フォームへの反映はフロントエンド側が担う。 + """ + + __tablename__ = "resume_imports" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending", server_default="pending" + ) + pdf_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + result_json: Mapped[str | None] = mapped_column(Text, nullable=True) + is_resume_flag: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + judge_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + retry_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_default_expires_at + ) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=func.now(), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=func.now(), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 9eebe54f..387c3773 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -6,6 +6,7 @@ from .internal import router as internal_router from .master_data import router as master_data_router from .notifications import router as notifications_router +from .resume_imports import router as resume_imports_router from .resumes import router as resumes_router __all__ = [ @@ -17,5 +18,6 @@ "internal_router", "master_data_router", "notifications_router", + "resume_imports_router", "resumes_router", ] diff --git a/backend/app/routers/resume_imports.py b/backend/app/routers/resume_imports.py new file mode 100644 index 00000000..303b98c1 --- /dev/null +++ b/backend/app/routers/resume_imports.py @@ -0,0 +1,192 @@ +""" +職務経歴書 PDF インポート API エンドポイント。 + +POST /api/resumes/import — PDF アップロード、インポート開始(202 非同期) +GET /api/resumes/import/{id}/status — ポーリング用ステータス確認 +GET /api/resumes/import/{id}/result — 抽出結果取得(completed のみ 200) +""" + +import io +import json +import logging + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Request, UploadFile +from sqlalchemy.orm import Session + +from ..core.errors import ErrorCode, raise_app_error, resolve_async_error_code +from ..core.security.auth import get_current_user +from ..core.security.dependencies import limiter +from ..db import get_db +from ..models import User +from ..models.resume_import import ResumeImport +from ..schemas.resume import ResumeBase +from ..schemas.resume_import import ( + ResumeImportResultResponse, + ResumeImportStartResponse, + ResumeImportStatusResponse, +) +from ..services.tasks import AsyncTaskCacheService, TaskType + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/resumes/import", tags=["resume-imports"]) + +_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +_MAX_PAGES = 20 +_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB ずつ読み込み、サイズ超過を早期検知する + + +@router.post("", response_model=ResumeImportStartResponse, status_code=202) +@limiter.limit("10/minute") +async def start_import( + request: Request, + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """PDF をアップロードしてインポートタスクを開始する。""" + if file.content_type != "application/pdf": + raise_app_error( + status_code=422, + code=ErrorCode.RESUME_IMPORT_INVALID, + message="PDF をアップロードしてください。", + action="ファイル種別を確認して再試行してください", + ) + + buffer = bytearray() + while True: + chunk = await file.read(_UPLOAD_CHUNK_SIZE) + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > _MAX_FILE_SIZE: + await file.close() + raise_app_error( + status_code=422, + code=ErrorCode.RESUME_IMPORT_INVALID, + message="ファイルサイズは 10 MB 以下にしてください。", + action="ファイルを圧縮するか別の PDF をお試しください", + ) + pdf_bytes = bytes(buffer) + + # ページ数チェック(pdfplumber は純粋 Python のため router 内で使用可能) + try: + import pdfplumber + + with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: + if len(pdf.pages) > _MAX_PAGES: + raise_app_error( + status_code=422, + code=ErrorCode.RESUME_IMPORT_INVALID, + message=f"PDF は {_MAX_PAGES} ページ以下にしてください。", + action="ページ数を確認して再試行してください", + ) + except Exception as exc: + # raise_app_error は HTTPException を継承しているので再 raise + from fastapi import HTTPException + + if isinstance(exc, HTTPException): + raise + logger.warning("PDF ページ数チェックに失敗しました", exc_info=True) + raise_app_error( + status_code=422, + code=ErrorCode.RESUME_IMPORT_INVALID, + message="PDF の読み込みに失敗しました。別のファイルをお試しください。", + action="PDF ファイルが破損していないか確認してください", + ) + + record = ResumeImport(user_id=current_user.id, pdf_blob=pdf_bytes) + db.add(record) + db.commit() + db.refresh(record) + + service = AsyncTaskCacheService(db, record) + try: + await service.dispatch( + background_tasks, + TaskType.RESUME_IMPORT, + {"user_id": current_user.id, "import_id": record.id}, + failure_message="インポートタスクの開始に失敗しました", + logger=logger, + ) + except Exception: + # dispatch 失敗時、service 側で status=dead_letter / error_message は設定済み。 + # ここでは加えて pdf_blob をクリアし、機微データを残さない。 + try: + record.pdf_blob = None + db.commit() + logger.info( + "ResumeImport の pdf_blob をクリアしました (dispatch 失敗)", + extra={"import_id": record.id}, + ) + except Exception: + logger.warning( + "pdf_blob のクリアに失敗しました (無視)", + exc_info=True, + extra={"import_id": record.id}, + ) + raise_app_error( + status_code=500, + code=ErrorCode.INTERNAL_ERROR, + message="インポートタスクの開始に失敗しました。しばらく待ってから再試行してください。", + action="しばらく待ってから再試行してください", + ) + + return ResumeImportStartResponse(import_id=record.id) + + +@router.get("/{import_id}/status", response_model=ResumeImportStatusResponse) +def get_import_status( + import_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """インポートタスクのステータスを返す(軽量ポーリング用)。""" + record = db.query(ResumeImport).filter_by(id=import_id, user_id=current_user.id).first() + if not record: + raise_app_error( + status_code=404, + code=ErrorCode.VALIDATION_ERROR, + message="インポートレコードが見つかりません。", + action="インポートをやり直してください", + ) + + return ResumeImportStatusResponse( + status=record.status, + error_message=record.error_message, + error_code=resolve_async_error_code(record.error_message), + judge_reason=record.judge_reason, + ) + + +@router.get("/{import_id}/result", response_model=ResumeImportResultResponse) +def get_import_result( + import_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """抽出結果を返す。completed 以外のステータスでは 409 を返す。""" + record = db.query(ResumeImport).filter_by(id=import_id, user_id=current_user.id).first() + if not record: + raise_app_error( + status_code=404, + code=ErrorCode.VALIDATION_ERROR, + message="インポートレコードが見つかりません。", + action="インポートをやり直してください", + ) + + if record.status != "completed": + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=f"インポートはまだ完了していません(現在: {record.status})。", + action="しばらく待ってから再試行してください", + ) + + parsed = json.loads(record.result_json) + return ResumeImportResultResponse( + result=ResumeBase(**parsed), + is_resume=bool(record.is_resume_flag), + judge_reason=record.judge_reason, + ) diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index 85ebdbc8..e770038d 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -57,7 +57,10 @@ class ProjectTeam(BaseModel): class Project(BaseModel): name: str = Field(max_length=200, default="") start_date: str = Field(max_length=30, default="") - end_date: str = Field(max_length=30, default="") + # 参画中(is_current=True)のプロジェクトは DB 上 end_date が NULL となり、 + # ResumeProject.end_date プロパティが None を返す。 + # Experience.end_date と同様に str | None を許容することでレスポンスの整合性を取る。 + end_date: str | None = Field(default=None, max_length=30) is_current: bool = False role: str = Field(max_length=200, default="") description: str = Field(max_length=1500, default="") @@ -81,8 +84,11 @@ def _migrate_scale_to_team(cls, data: dict) -> dict: @model_validator(mode="after") def validate_date_range(self) -> "Project": - """終了日が開始日より前でないことを検証する。""" - if self.start_date and self.end_date and not self.is_current: + """参画中なら end_date を None に正規化し、そうでなければ日付範囲を検証する。""" + if self.is_current: + self.end_date = None + return self + if self.start_date and self.end_date: if self.end_date < self.start_date: raise ValueError(get_error("validation.date_range_invalid")) return self diff --git a/backend/app/schemas/resume_import.py b/backend/app/schemas/resume_import.py new file mode 100644 index 00000000..c8a18324 --- /dev/null +++ b/backend/app/schemas/resume_import.py @@ -0,0 +1,30 @@ +"""職務経歴書 PDF インポートの API スキーマ。""" + +from uuid import UUID + +from pydantic import BaseModel + +from .resume import ResumeBase + + +class ResumeImportStartResponse(BaseModel): + """POST /api/resumes/import の 202 レスポンス。""" + + import_id: UUID + + +class ResumeImportStatusResponse(BaseModel): + """GET /api/resumes/import/{id}/status のレスポンス。""" + + status: str + error_message: str | None = None + error_code: str | None = None + judge_reason: str | None = None + + +class ResumeImportResultResponse(BaseModel): + """GET /api/resumes/import/{id}/result のレスポンス。""" + + result: ResumeBase + is_resume: bool + judge_reason: str | None = None diff --git a/backend/app/services/resume_import/__init__.py b/backend/app/services/resume_import/__init__.py new file mode 100644 index 00000000..dc6bcc8b --- /dev/null +++ b/backend/app/services/resume_import/__init__.py @@ -0,0 +1 @@ +"""職務経歴書 PDF インポートサービス。""" diff --git a/backend/app/services/resume_import/llm_extractor.py b/backend/app/services/resume_import/llm_extractor.py new file mode 100644 index 00000000..314c8ce5 --- /dev/null +++ b/backend/app/services/resume_import/llm_extractor.py @@ -0,0 +1,104 @@ +"""LLM を使った職務経歴書判定と構造化抽出。""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from ..intelligence.llm.base import LLMClient +from ..tasks.exceptions import NonRetryableError, RetryableError + +logger = logging.getLogger(__name__) + +_PROMPTS_DIR = Path(__file__).parent / "prompts" +# 判定に使うテキストの最大文字数(冒頭部分のみ渡す) +_JUDGE_TEXT_MAX_CHARS = 3000 +# LLM 判定で is_resume=True かつ confidence がこれ以上のときのみ次のステップへ進む +_CONFIDENCE_THRESHOLD = 0.6 + + +@dataclass +class JudgeResult: + is_resume: bool + confidence: float + reason: str + + +def _load_prompt(filename: str) -> str: + path = _PROMPTS_DIR / filename + if not path.exists(): + raise FileNotFoundError(f"プロンプトファイルが見つかりません: {path}") + return path.read_text(encoding="utf-8").strip() + + +def _strip_code_block(text: str) -> str: + """LLM がコードブロックで包んで返した場合に除去する。""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + lines = [ln for ln in lines if not ln.strip().startswith("```")] + text = "\n".join(lines).strip() + return text + + +async def judge_is_resume(text: str, llm_client: LLMClient) -> JudgeResult: + """テキストが職務経歴書かどうかを LLM で判定する。 + + confidence が _CONFIDENCE_THRESHOLD 未満または is_resume=False の場合は + is_resume=False を返す。LLM 呼び出し失敗は RetryableError / NonRetryableError を再 raise する。 + """ + system_prompt = _load_prompt("judge_resume.md") + user_prompt = text[:_JUDGE_TEXT_MAX_CHARS] + + raw = await llm_client.generate(system_prompt, user_prompt) + if raw is None: + raise RetryableError("LLM 判定に失敗しました(応答なし)") + + try: + data = json.loads(_strip_code_block(raw)) + is_resume = bool(data.get("is_resume", False)) + confidence = float(data.get("confidence", 0.0)) + reason = str(data.get("reason", "")) + except (json.JSONDecodeError, ValueError, TypeError) as exc: + logger.warning( + "LLM 判定レスポンスのパースに失敗しました (response length=%d)", len(raw) + ) + raise NonRetryableError(f"LLM 判定レスポンスのパースに失敗しました: {exc}") from exc + + if confidence < _CONFIDENCE_THRESHOLD: + is_resume = False + reason = f"確信度が低いため非職務経歴書と判定しました(confidence={confidence:.2f})" + + logger.info( + "職務経歴書判定完了", + extra={"is_resume": is_resume, "confidence": confidence, "reason": reason}, + ) + return JudgeResult(is_resume=is_resume, confidence=confidence, reason=reason) + + +async def extract_structured(text: str, llm_client: LLMClient) -> dict: + """職務経歴書テキストを CareerResumePayload 互換の dict に構造化する。 + + LLM 呼び出し失敗や JSON パース失敗は NonRetryableError を raise する。 + """ + system_prompt = _load_prompt("extract_resume.md") + + raw = await llm_client.generate(system_prompt, text) + if raw is None: + raise RetryableError("LLM 抽出に失敗しました(応答なし)") + + try: + data = json.loads(_strip_code_block(raw)) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning( + "LLM 抽出レスポンスのパースに失敗しました (response length=%d)", len(raw) + ) + raise NonRetryableError(f"LLM 抽出レスポンスのパースに失敗しました: {exc}") from exc + + # 必須フィールドの存在確認 + for key in ("full_name", "career_summary", "self_pr", "experiences", "qualifications"): + if key not in data: + data[key] = [] if key in ("experiences", "qualifications") else "" + + logger.info("職務経歴書構造化抽出完了", extra={"field_count": len(data)}) + return data diff --git a/backend/app/services/resume_import/pdf_extractor.py b/backend/app/services/resume_import/pdf_extractor.py new file mode 100644 index 00000000..db3ef125 --- /dev/null +++ b/backend/app/services/resume_import/pdf_extractor.py @@ -0,0 +1,62 @@ +"""pdfplumber を使った PDF テキスト抽出ユーティリティ。""" + +import io +import logging +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# 1ページあたりのテキスト文字数がこの値未満のページが全体の半数以上ならスキャン PDF とみなす +_MIN_CHARS_PER_PAGE = 30 +MAX_PAGES = 20 + + +@dataclass +class ExtractedText: + text: str + page_count: int + has_text_layer: bool + + +def extract_text(pdf_bytes: bytes, max_pages: int = MAX_PAGES) -> ExtractedText: + """PDF バイト列からテキストを抽出する。 + + スキャン PDF(テキストレイヤーなし)の場合は has_text_layer=False を返す。 + max_pages を超えるページは無視する。 + """ + import pdfplumber + + with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: + pages = pdf.pages[:max_pages] + page_count = len(pdf.pages) + + if not pages: + full_text = "" + has_text_layer = False + else: + texts: list[str] = [] + sparse_pages = 0 + for page in pages: + page_text = page.extract_text() or "" + texts.append(page_text) + if len(page_text.strip()) < _MIN_CHARS_PER_PAGE: + sparse_pages += 1 + + full_text = "\n".join(texts).strip() + # 半数以上のページがテキスト希薄ならスキャン PDF と判定 + has_text_layer = sparse_pages < max(1, len(pages) / 2) + + logger.info( + "PDF テキスト抽出完了", + extra={ + "page_count": page_count, + "extracted_pages": len(pages), + "has_text_layer": has_text_layer, + "char_count": len(full_text), + }, + ) + return ExtractedText( + text=full_text, + page_count=page_count, + has_text_layer=has_text_layer, + ) diff --git a/backend/app/services/resume_import/prompts/extract_resume.md b/backend/app/services/resume_import/prompts/extract_resume.md new file mode 100644 index 00000000..6a10765d --- /dev/null +++ b/backend/app/services/resume_import/prompts/extract_resume.md @@ -0,0 +1,75 @@ +あなたは職務経歴書の情報抽出専門家です。 +以下の職務経歴書テキストを解析し、指定の JSON スキーマに従って構造化データとして出力してください。 + +## 出力 JSON スキーマ + +```json +{ + "full_name": "氏名(姓名)", + "career_summary": "職歴概要・自己紹介文(最大2000文字)", + "self_pr": "自己PR・強み(最大2000文字)", + "experiences": [ + { + "company": "会社名(必須)", + "business_description": "事業内容・業種(必須)", + "start_date": "入社年月 YYYY-MM 形式", + "end_date": "退社年月 YYYY-MM 形式(在籍中は null)", + "is_current": false, + "employee_count": "従業員数(文字列、例: '100名')", + "capital": "資本金(文字列、例: '1億円')", + "clients": [ + { + "name": "クライアント名または常駐先名", + "has_client": true, + "projects": [ + { + "name": "案件名・プロジェクト名", + "start_date": "開始年月 YYYY-MM 形式", + "end_date": "終了年月 YYYY-MM 形式(進行中は null)", + "is_current": false, + "role": "担当役割(例: バックエンドエンジニア)", + "description": "案件概要・業務内容", + "challenge": "課題・問題点", + "action": "取り組み・対応内容", + "result": "成果・実績", + "team": { + "total": "チーム総人数(文字列)", + "members": [ + {"role": "役割名", "count": 1} + ] + }, + "technology_stacks": [ + { + "category": "language | framework | os | db | cloud_provider | container | iac | vcs | ci_cd | project_tool | monitoring | middleware | ai_agent", + "name": "技術名" + } + ], + "phases": ["要件定義", "基本設計", "詳細設計", "開発", "テスト", "リリース", "保守運用"] + } + ] + } + ] + } + ], + "qualifications": [ + { + "acquired_date": "取得年月 YYYY-MM 形式", + "name": "資格名" + } + ] +} +``` + +## 抽出ルール + +- 日付は YYYY-MM 形式(例: 2022-04)。年のみの場合は YYYY-01 とする +- 不明な項目は空文字列 "" または空配列 [] とする(null は end_date / is_current: true の場合のみ) +- クライアント名が不明な場合、has_client=false・name="" で1件作成する +- プロジェクトが職務経歴に直接記載されている場合(常駐先なし)は has_client=false で包む +- technology_stacks の category は最も近いカテゴリを選択する +- phases の値は「要件定義」「基本設計」「詳細設計」「開発」「テスト」「リリース」「保守運用」から選ぶ +- career_summary と self_pr が区別できない場合、同じ内容を両方に入れてよい + +## 出力形式 + +上記 JSON スキーマに準拠した JSON のみを出力してください(コードブロック・説明文は不要)。 diff --git a/backend/app/services/resume_import/prompts/judge_resume.md b/backend/app/services/resume_import/prompts/judge_resume.md new file mode 100644 index 00000000..903dacd8 --- /dev/null +++ b/backend/app/services/resume_import/prompts/judge_resume.md @@ -0,0 +1,21 @@ +あなたは職務経歴書の判定専門家です。 +以下のテキストが「職務経歴書」かどうかを判定してください。 + +## 判定基準 + +職務経歴書とみなす条件(1つ以上該当すれば True): +- 氏名、職歴、スキル、プロジェクト経験などが記載されている +- 特定の個人の職業上の経歴を説明している文書である +- 転職・就職活動に使用されることを意図した文書である + +職務経歴書とみなさない条件(いずれかに該当すれば False): +- 請求書・見積書・契約書・注文書などのビジネス書類 +- 技術マニュアル・仕様書・設計書などのドキュメント +- 学術論文・研究レポート +- 会社・製品・サービスの紹介資料 +- 法的文書・規約・ポリシー + +## 出力形式 + +以下の JSON のみを出力してください(コードブロック不要): +{"is_resume": true, "confidence": 0.9, "reason": "職歴・スキルが記載されている"} diff --git a/backend/app/services/tasks/base.py b/backend/app/services/tasks/base.py index b79b6478..9039a6a5 100644 --- a/backend/app/services/tasks/base.py +++ b/backend/app/services/tasks/base.py @@ -10,6 +10,7 @@ class TaskType(str, Enum): GITHUB_ANALYSIS = "github_analysis" BLOG_SUMMARIZE = "blog_summarize" CAREER_ANALYSIS = "career_analysis" + RESUME_IMPORT = "resume_import" # 手動再実行を許可するキャッシュレコードのステータス集合。 diff --git a/backend/app/services/tasks/handlers/__init__.py b/backend/app/services/tasks/handlers/__init__.py index d0ce4fc9..743a1ff8 100644 --- a/backend/app/services/tasks/handlers/__init__.py +++ b/backend/app/services/tasks/handlers/__init__.py @@ -11,11 +11,13 @@ from .blog_summarize import BlogSummarizeHandler from .career_analysis import CareerAnalysisHandler from .github_analysis import GitHubAnalysisHandler +from .resume_import import ResumeImportHandler _HANDLERS: Dict[TaskType, TaskHandler] = { TaskType.GITHUB_ANALYSIS: GitHubAnalysisHandler(), TaskType.BLOG_SUMMARIZE: BlogSummarizeHandler(), TaskType.CAREER_ANALYSIS: CareerAnalysisHandler(), + TaskType.RESUME_IMPORT: ResumeImportHandler(), } diff --git a/backend/app/services/tasks/handlers/resume_import.py b/backend/app/services/tasks/handlers/resume_import.py new file mode 100644 index 00000000..cef8d515 --- /dev/null +++ b/backend/app/services/tasks/handlers/resume_import.py @@ -0,0 +1,96 @@ +"""職務経歴書 PDF インポートタスクのハンドラ。""" + +import json +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ....models.resume_import import ResumeImport +from ..exceptions import NonRetryableError +from .base import TaskHandler + +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class ResumeImportHandler(TaskHandler): + """PDF を pdfplumber でテキスト抽出し LLM で構造化するタスク。""" + + def get_record(self, db: Session, payload: dict) -> ResumeImport | None: + user_id = payload.get("user_id") + import_id = payload.get("import_id") + if not user_id or not import_id: + return None + return db.query(ResumeImport).filter_by(id=import_id, user_id=user_id).first() + + async def run(self, db: Session, payload: dict) -> None: + from ....services.intelligence.llm import get_llm_client + from ....services.resume_import import llm_extractor, pdf_extractor + + user_id = payload.get("user_id") + import_id = payload.get("import_id") + missing = [name for name, v in (("user_id", user_id), ("import_id", import_id)) if not v] + if missing: + message = "インポートタスクのペイロードに必須キーがありません" + logger.error(message, extra={"missing_keys": missing}) + raise NonRetryableError(f"{message} (missing={missing})") + + record = db.query(ResumeImport).filter_by(id=import_id, user_id=user_id).first() + if not record: + message = "ResumeImport レコードが見つかりません" + logger.error(message, extra={"import_id": import_id, "user_id": user_id}) + raise NonRetryableError(f"{message} (import_id={import_id})") + + record.status = "processing" + record.started_at = _now() + db.commit() + + pdf_bytes = record.pdf_blob + if not pdf_bytes: + record.error_message = "PDF データが見つかりません" + record.status = "dead_letter" + record.completed_at = _now() + db.commit() + raise NonRetryableError("PDF データが見つかりません") + + # テキスト抽出 + extracted = pdf_extractor.extract_text(pdf_bytes) + if not extracted.has_text_layer: + record.is_resume_flag = False + record.judge_reason = "テキストレイヤーのない PDF です" + record.error_message = "スキャン PDF のためテキスト抽出不可" + record.pdf_blob = None + record.status = "dead_letter" + record.completed_at = _now() + db.commit() + raise NonRetryableError("スキャン PDF のためテキスト抽出不可") + + llm_client = get_llm_client() + + # LLM による職務経歴書判定 + judge_result = await llm_extractor.judge_is_resume(extracted.text, llm_client) + if not judge_result.is_resume: + record.is_resume_flag = False + record.judge_reason = judge_result.reason + record.error_message = f"not_a_resume: {judge_result.reason}" + record.pdf_blob = None + record.status = "dead_letter" + record.completed_at = _now() + db.commit() + raise NonRetryableError(f"not_a_resume: {judge_result.reason}") + + # 構造化抽出 + structured = await llm_extractor.extract_structured(extracted.text, llm_client) + + record.result_json = json.dumps(structured, ensure_ascii=False) + record.is_resume_flag = True + record.judge_reason = judge_result.reason + record.pdf_blob = None + record.status = "completed" + record.error_message = None + record.completed_at = _now() + db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt index ee357a3a..5ace7712 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,3 +25,5 @@ black==26.3.1 pytest-cov==6.1.0 google-cloud-tasks>=2.16,<3 pyasn1>=0.6.3 +pdfplumber>=0.11.0 +python-multipart>=0.0.27 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ccb2afcb..3e8d403c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -83,6 +83,7 @@ def _generate_test_rsa_keys() -> tuple[str, str]: MQualification, MTechnologyStack, Resume, + ResumeImport, User, ) from app.repositories import UserRepository # noqa: E402 diff --git a/backend/tests/test_resume_import_handler.py b/backend/tests/test_resume_import_handler.py new file mode 100644 index 00000000..618016b6 --- /dev/null +++ b/backend/tests/test_resume_import_handler.py @@ -0,0 +1,195 @@ +"""ResumeImportHandler の単体テスト。LLM はモック化し、DB は実 SQLite セッションを使用。""" + +import asyncio +import io +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from app.models.resume_import import ResumeImport +from app.services.tasks.exceptions import NonRetryableError +from app.services.tasks.handlers.resume_import import ResumeImportHandler + + +def _run(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_minimal_pdf() -> bytes: + """テキストレイヤーを持つ最小限の PDF バイト列を生成する。""" + import reportlab.lib.pagesizes as ps + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=ps.A4) + c.drawString(72, 720, "山田 太郎") + c.drawString(72, 700, "職務経歴書") + c.drawString(72, 680, "バックエンドエンジニアとして5年の経験があります。") + c.save() + return buf.getvalue() + + +def _make_scan_pdf() -> bytes: + """テキストなし(スキャン相当)の PDF バイト列を生成する。""" + from reportlab.pdfgen import canvas + + buf = io.BytesIO() + c = canvas.Canvas(buf) + c.save() + return buf.getvalue() + + +def _create_import_record(db_session, user_id: str, pdf_bytes: bytes) -> ResumeImport: + record = ResumeImport( + user_id=user_id, + pdf_blob=pdf_bytes, + status="pending", + expires_at=datetime.now(timezone.utc) + timedelta(days=1), + ) + db_session.add(record) + db_session.commit() + db_session.refresh(record) + return record + + +@pytest.fixture() +def test_user(db_session): + from app.repositories import UserRepository + + repo = UserRepository(db_session) + if not repo.get_by_username("handler-test-user"): + repo.create("handler-test-user", hashed_password=None, email="handler-test@example.com") + return repo.get_by_username("handler-test-user") + + +# ── 正常系 ────────────────────────────────────────────────────────────────── + + +def test_run_success(db_session, test_user): + """正常な職務経歴書 PDF → status=completed / result_json が保存される。""" + pdf_bytes = _make_minimal_pdf() + record = _create_import_record(db_session, test_user.id, pdf_bytes) + + mock_llm = AsyncMock() + judge_response = json.dumps({"is_resume": True, "confidence": 0.9, "reason": "職歴が記載されている"}) + extract_response = json.dumps({ + "full_name": "山田 太郎", + "career_summary": "バックエンドエンジニア", + "self_pr": "API 設計が得意", + "experiences": [], + "qualifications": [], + }) + mock_llm.generate = AsyncMock(side_effect=[judge_response, extract_response]) + + handler = ResumeImportHandler() + with patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm): + _run(handler.run(db_session, {"user_id": test_user.id, "import_id": record.id})) + + db_session.refresh(record) + assert record.status == "completed" + assert record.result_json is not None + result = json.loads(record.result_json) + assert result["full_name"] == "山田 太郎" + assert record.pdf_blob is None + assert record.is_resume_flag is True + + +# ── 異常系: スキャン PDF ────────────────────────────────────────────────── + + +def test_run_scan_pdf_raises_non_retryable(db_session, test_user): + """テキストレイヤーのない PDF → NonRetryableError / error_code が RESUME_IMPORT_INVALID。""" + pdf_bytes = _make_scan_pdf() + record = _create_import_record(db_session, test_user.id, pdf_bytes) + + handler = ResumeImportHandler() + + # pdfplumber のテキストレイヤー判定はファイル依存で揺れるため、抽出結果を直接モックする + from app.services.resume_import.pdf_extractor import ExtractedText + + scan_result = ExtractedText(text="", page_count=1, has_text_layer=False) + with patch( + "app.services.resume_import.pdf_extractor.extract_text", + return_value=scan_result, + ): + with pytest.raises(NonRetryableError, match="スキャン PDF"): + _run(handler.run(db_session, {"user_id": test_user.id, "import_id": record.id})) + + db_session.refresh(record) + assert record.error_message is not None + assert "スキャン PDF" in record.error_message + assert record.pdf_blob is None + + from app.core.errors import resolve_async_error_code + + assert resolve_async_error_code(record.error_message) == "RESUME_IMPORT_INVALID" + + +# ── 異常系: 職務経歴書でない ────────────────────────────────────────────── + + +def test_run_not_a_resume_raises_non_retryable(db_session, test_user): + """LLM が職務経歴書でないと判定 → NonRetryableError / error_code が RESUME_IMPORT_NOT_A_RESUME。""" + pdf_bytes = _make_minimal_pdf() + record = _create_import_record(db_session, test_user.id, pdf_bytes) + + mock_llm = AsyncMock() + judge_response = json.dumps({"is_resume": False, "confidence": 0.1, "reason": "請求書です"}) + mock_llm.generate = AsyncMock(return_value=judge_response) + + handler = ResumeImportHandler() + with patch("app.services.intelligence.llm.get_llm_client", return_value=mock_llm): + with pytest.raises(NonRetryableError, match="not_a_resume"): + _run(handler.run(db_session, {"user_id": test_user.id, "import_id": record.id})) + + db_session.refresh(record) + assert record.is_resume_flag is False + assert "not_a_resume" in record.error_message + assert record.pdf_blob is None + + from app.core.errors import resolve_async_error_code + + assert resolve_async_error_code(record.error_message) == "RESUME_IMPORT_NOT_A_RESUME" + + +# ── 異常系: ペイロード不正 ─────────────────────────────────────────────── + + +def test_run_missing_payload_raises_non_retryable(db_session): + """必須キー欠落 → NonRetryableError。""" + handler = ResumeImportHandler() + with pytest.raises(NonRetryableError, match="必須キー"): + _run(handler.run(db_session, {})) + + +# ── get_record ─────────────────────────────────────────────────────────── + + +def test_get_record_returns_record(db_session, test_user): + """get_record が正しいレコードを返すこと。""" + record = ResumeImport( + user_id=test_user.id, + status="pending", + expires_at=datetime.now(timezone.utc) + timedelta(days=1), + ) + db_session.add(record) + db_session.commit() + db_session.refresh(record) + + handler = ResumeImportHandler() + found = handler.get_record(db_session, {"user_id": test_user.id, "import_id": record.id}) + assert found is not None + assert found.id == record.id + + +def test_get_record_returns_none_for_missing(db_session, test_user): + """存在しない import_id → None を返す。""" + handler = ResumeImportHandler() + result = handler.get_record(db_session, {"user_id": test_user.id, "import_id": "nonexistent-id"}) + assert result is None diff --git a/backend/tests/test_resume_imports_router.py b/backend/tests/test_resume_imports_router.py new file mode 100644 index 00000000..c7e8f6e2 --- /dev/null +++ b/backend/tests/test_resume_imports_router.py @@ -0,0 +1,180 @@ +"""resume_imports ルーターの統合テスト。""" + +import io +import json + +import reportlab.lib.pagesizes as ps +from fastapi.testclient import TestClient +from httpx import Response +from reportlab.pdfgen import canvas + +from conftest import auth_header + + +def _make_pdf_bytes(text: str = "山田 太郎\n職務経歴書\nバックエンドエンジニア") -> bytes: + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=ps.A4) + for i, line in enumerate(text.split("\n")): + c.drawString(72, 720 - i * 20, line) + c.save() + return buf.getvalue() + + +def _upload_pdf(client: TestClient, headers: dict[str, str], pdf_bytes: bytes) -> Response: + resp = client.post( + "/api/resumes/import", + files={"file": ("resume.pdf", pdf_bytes, "application/pdf")}, + headers=headers, + ) + return resp + + +# ── POST /api/resumes/import ───────────────────────────────────────────── + + +def test_start_import_returns_202_with_import_id(client: TestClient) -> None: + """正常な PDF アップロード → 202 で import_id が返る。""" + headers = auth_header(client, "import-test-user-1") + pdf_bytes = _make_pdf_bytes() + + resp = _upload_pdf(client, headers, pdf_bytes) + assert resp.status_code == 202 + data = resp.json() + assert "import_id" in data + assert len(data["import_id"]) > 0 + + +def test_start_import_rejects_non_pdf(client: TestClient) -> None: + """PDF 以外のファイル → 422 / RESUME_IMPORT_INVALID。""" + headers = auth_header(client, "import-test-user-2") + resp = client.post( + "/api/resumes/import", + files={"file": ("resume.txt", b"not a pdf", "text/plain")}, + headers=headers, + ) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == "RESUME_IMPORT_INVALID" + + +def test_start_import_rejects_oversized_file(client: TestClient) -> None: + """10 MB 超過 → 422 / RESUME_IMPORT_INVALID。""" + headers = auth_header(client, "import-test-user-3") + oversized = b"%PDF-1.4 " + b"x" * (11 * 1024 * 1024) + resp = client.post( + "/api/resumes/import", + files={"file": ("big.pdf", oversized, "application/pdf")}, + headers=headers, + ) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == "RESUME_IMPORT_INVALID" + + +def test_start_import_requires_auth(client: TestClient) -> None: + """認証なし → 401。""" + pdf_bytes = _make_pdf_bytes() + resp = client.post( + "/api/resumes/import", + files={"file": ("resume.pdf", pdf_bytes, "application/pdf")}, + ) + assert resp.status_code == 401 + + +# ── GET /api/resumes/import/{id}/status ───────────────────────────────── + + +def test_get_status_returns_pending(client: TestClient) -> None: + """作成直後は status=pending が返る。""" + headers = auth_header(client, "import-status-user-1") + pdf_bytes = _make_pdf_bytes() + + resp = _upload_pdf(client, headers, pdf_bytes) + assert resp.status_code == 202 + import_id = resp.json()["import_id"] + + status_resp = client.get(f"/api/resumes/import/{import_id}/status", headers=headers) + assert status_resp.status_code == 200 + data = status_resp.json() + assert data["status"] in ("pending", "processing") + + +def test_get_status_returns_404_for_unknown(client: TestClient) -> None: + """存在しない import_id → 404。""" + headers = auth_header(client, "import-status-user-2") + resp = client.get("/api/resumes/import/nonexistent-id/status", headers=headers) + assert resp.status_code == 404 + + +def test_get_status_user_isolation(client: TestClient, db_session) -> None: + """他ユーザーのレコードは取得できない(404)。""" + from datetime import datetime, timedelta, timezone + + from app.models.resume_import import ResumeImport + from app.repositories import UserRepository + + # user A のレコードを DB に直接作成する(セッション切り替えを避けるため) + repo = UserRepository(db_session) + if not repo.get_by_username("import-isolation-a"): + repo.create("import-isolation-a", hashed_password=None, email="import-isolation-a@example.com") + user_a = repo.get_by_username("import-isolation-a") + + record = ResumeImport( + user_id=user_a.id, + status="pending", + expires_at=datetime.now(timezone.utc) + timedelta(days=1), + ) + db_session.add(record) + db_session.commit() + db_session.refresh(record) + import_id = record.id + + # user B として認証し、user A のレコードを取得 → 404 + headers_b = auth_header(client, "import-isolation-b") + status_resp = client.get(f"/api/resumes/import/{import_id}/status", headers=headers_b) + assert status_resp.status_code == 404 + + +# ── GET /api/resumes/import/{id}/result ───────────────────────────────── + + +def test_get_result_returns_409_while_pending(client: TestClient) -> None: + """pending 状態での result 取得 → 409。""" + headers = auth_header(client, "import-result-user-1") + pdf_bytes = _make_pdf_bytes() + + resp = _upload_pdf(client, headers, pdf_bytes) + import_id = resp.json()["import_id"] + + result_resp = client.get(f"/api/resumes/import/{import_id}/result", headers=headers) + assert result_resp.status_code == 409 + + +def test_get_result_returns_200_when_completed(client: TestClient, db_session) -> None: + """completed レコードの result 取得 → 200 / result が返る。""" + headers = auth_header(client, "import-result-user-2") + pdf_bytes = _make_pdf_bytes() + + resp = _upload_pdf(client, headers, pdf_bytes) + import_id = resp.json()["import_id"] + + # DB 上で直接 completed に更新する + from app.models.resume_import import ResumeImport + + record = db_session.query(ResumeImport).filter_by(id=import_id).first() + record.status = "completed" + record.is_resume_flag = True + record.result_json = json.dumps({ + "full_name": "山田 太郎", + "career_summary": "バックエンドエンジニア", + "self_pr": "API 設計が得意", + "experiences": [], + "qualifications": [], + }) + db_session.commit() + + result_resp = client.get(f"/api/resumes/import/{import_id}/result", headers=headers) + assert result_resp.status_code == 200 + data = result_resp.json() + assert data["result"]["full_name"] == "山田 太郎" + assert data["is_resume"] is True diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index ce3e165c..0266ef26 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -202,14 +202,39 @@ def test_project_end_date_after_start_date_is_accepted() -> None: def test_project_null_end_date_is_accepted() -> None: - """プロジェクト: 終了日が空(参画中)は正常に保存されること。""" + """プロジェクト: 参画中(is_current=True)は end_date 未指定または None を許容すること。 + + DB 側は end_date が NULL で保存され、ResumeProject.end_date プロパティは None を返す。 + Experience.end_date と同様にスキーマも str | None を許容し、レスポンス検証が失敗しないようにする。 + """ + # 未指定(デフォルト) proj = Project( name="テスト", start_date="2021-04", is_current=True, technology_stacks=[], ) - assert proj.end_date == "" + assert proj.end_date is None + + # 明示的に None + proj_none = Project( + name="テスト", + start_date="2021-04", + end_date=None, + is_current=True, + technology_stacks=[], + ) + assert proj_none.end_date is None + + # 空文字列も受け付け、is_current=True なら None に正規化する(Experience と同じ挙動) + proj_empty = Project( + name="テスト", + start_date="2021-04", + end_date="", + is_current=True, + technology_stacks=[], + ) + assert proj_empty.end_date is None def test_blog_summary_request_limits_article_count() -> None: diff --git a/frontend/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts new file mode 100644 index 00000000..61fcc6b6 --- /dev/null +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -0,0 +1,244 @@ +import { test, expect, type Page } from "@playwright/test"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * 職務経歴書の未保存マーク(🔴)E2E。 + * + * シナリオ: + * 1. 職務経歴書を開く → 🔴 なし + * 2. 氏名を編集 → 氏名横と保存ボタン横に 🔴 + * 3. キャリア分析タブへ遷移して職務経歴書に戻る → 🔴 が維持されている(Redux 保持) + * 4. 保存(PUT /api/resumes/{id})→ baseline 更新で 🔴 が全消失 + */ + +/** 職務経歴書の API モックをまとめてセットする */ +async function setupResumeApi(page: Page) { + const baseResume = { + id: "resume-1", + full_name: "山田 太郎", + career_summary: "サマリー", + self_pr: "自己PR", + experiences: [ + { + company: "株式会社A", + business_description: "受託開発", + start_date: "2020-04", + end_date: null, + is_current: true, + employee_count: "100", + capital: "1", + clients: [], + }, + ], + qualifications: [], + }; + + // マスタデータ + await page.route("**/api/master-data/qualification", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/master-data/technology-stack", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + + // 最新取得 + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(baseResume), + }), + ); + + // 更新(PUT)。レスポンスは編集後の値で返す前提だが、テストでは保存後 🔴 が消えれば良いので + // request 内容をそのまま baseline として返す。 + await page.route("**/api/resumes/resume-1", async (route) => { + if (route.request().method() === "PUT") { + const body = JSON.parse(route.request().postData() ?? "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...baseResume, ...body }), + }); + return; + } + await route.fallback(); + }); + + // キャリア分析ページ用モック(タブ遷移先の 404 ループを避ける) + await page.route("**/api/career-analysis/**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); +} + +test.describe("職務経歴書 未保存マーク", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupResumeApi(page); + }); + + test("ProjectModal 内でフィールドを編集すると該当ラベル横に 🔴 が出る", async ({ page }) => { + // baseResume を上書きして 1 件のプロジェクトを持たせる + const baseResumeWithProject = { + id: "resume-1", + full_name: "山田 太郎", + career_summary: "サマリー", + self_pr: "自己PR", + experiences: [ + { + company: "株式会社A", + business_description: "受託開発", + start_date: "2020-04", + end_date: null, + is_current: true, + employee_count: "100", + capital: "1", + clients: [ + { + name: "クライアントA", + has_client: true, + projects: [ + { + name: "既存プロジェクト", + start_date: "2021-04", + end_date: "2022-03", + is_current: false, + role: "Eng", + description: "", + challenge: "", + action: "", + result: "", + team: { total: "5", members: [] }, + technology_stacks: [], + phases: [], + }, + ], + }, + ], + }, + ], + qualifications: [], + }; + await page.unroute("**/api/resumes/latest"); + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(baseResumeWithProject), + }), + ); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 初期状態: dirty なし + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + + // プロジェクト「編集」ボタンを押す + await page.getByRole("button", { name: "編集" }).click(); + + // モーダル内のプロジェクト名を変更 + const projectNameInput = page.getByPlaceholder( + "例: エネルギー業界 IoT Web API アプリ新規開発", + ); + await projectNameInput.fill("既存プロジェクト改"); + + // モーダル内で 🔴 が表示される(タイトル横とプロジェクト名ラベル横) + await expect(page.getByTestId("dirty-dot").first()).toBeVisible(); + const dotCount = await page.getByTestId("dirty-dot").count(); + expect(dotCount).toBeGreaterThanOrEqual(2); + }); + + test("新規ユーザー(DB データなし)で氏名を編集すると 🔴 が表示される", async ({ page }) => { + // 初回ログインで職務経歴データがないユーザー(loadLatest が 404)を再現 + await page.unroute("**/api/resumes/latest"); + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }), + }), + ); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 初期状態: baseline が初期空フォームに確定するため、編集前は 🔴 なし + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + + // 氏名を入力 → 初期空フォーム ("") と比較して dirty に + await page.getByPlaceholder("例: 山田 太郎").fill("テスト 太郎"); + await expect(page.getByTestId("dirty-dot").first()).toBeVisible(); + }); + + test("新規ユーザー(DB データなし)でフォーム編集しても React の setState 警告が出ない", async ({ page }) => { + // /api/resumes/latest を 404 にして「初回ログインで職務経歴データがないユーザー」を再現する + await page.unroute("**/api/resumes/latest"); + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }), + }), + ); + + // コンソール警告/エラーを収集する + const consoleIssues: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "warning" || msg.type() === "error") { + consoleIssues.push(`[${msg.type()}] ${msg.text()}`); + } + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 何文字か入力して setForm を複数回トリガーする + const nameInput = page.getByPlaceholder("例: 山田 太郎"); + await nameInput.fill("テスト 太郎"); + + // microtask の dispatch が消化されるまで少し待つ + await page.waitForTimeout(100); + + // 「Cannot update a component while rendering a different component」警告が無いこと + const setStateWarnings = consoleIssues.filter((m) => + m.includes("Cannot update a component"), + ); + expect(setStateWarnings, setStateWarnings.join("\n")).toEqual([]); + }); + + test("編集 → 🔴 表示 → タブ遷移後も維持 → 保存で消える", async ({ page }) => { + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 1. 初期状態: 🔴 は出ていない + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + + // 2. 氏名を編集 + const nameInput = page.getByPlaceholder("例: 山田 太郎"); + await nameInput.fill("佐藤 花子"); + + // 🔴 が氏名 label 横に現れる + await expect(page.getByTestId("dirty-dot").first()).toBeVisible(); + const dirtyCountAfterEdit = await page.getByTestId("dirty-dot").count(); + expect(dirtyCountAfterEdit).toBeGreaterThanOrEqual(1); + + // 3. キャリア分析タブへ遷移 + await page.getByRole("link", { name: "キャリア分析" }).click(); + await expect(page).toHaveURL(/\/career_analysis/); + + // 職務経歴書に戻る + await page.getByRole("link", { name: "職務経歴書" }).click(); + await expect(page).toHaveURL(/\/career$/); + await waitForAuthenticatedLayout(page); + + // 編集値と 🔴 が維持されている + await expect(nameInput).toHaveValue("佐藤 花子"); + const dirtyCountAfterReturn = await page.getByTestId("dirty-dot").count(); + expect(dirtyCountAfterReturn).toBeGreaterThanOrEqual(1); + + // 4. 保存 → 🔴 が全消失 + await page.getByRole("button", { name: /更新する|保存する/ }).click(); + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 06dfa78f..8bf77276 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -20,6 +20,32 @@ export default tseslint.config( rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + // ts/tsx に日本語リテラルのエラーメッセージを直接書かない。 + // frontend/src/constants/messages.ts の定数を参照すること。 + // 詳細: .claude/rules/frontend/messages.md + "no-restricted-syntax": [ + "error", + { + selector: + "ThrowStatement > NewExpression[callee.name='Error'] > Literal[value=/[\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF]/]", + message: + "throw new Error にリテラル日本語を直接書かない。frontend/src/constants/messages.ts の定数を参照すること。", + }, + { + selector: + "ThrowStatement > NewExpression[callee.name='Error'] > TemplateLiteral:has(TemplateElement[value.raw=/[\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF]/])", + message: + "throw new Error にテンプレートリテラルで日本語を直接書かない。frontend/src/constants/messages.ts の関数(downloadFailureMessage など)を使うこと。", + }, + ], + }, + }, + // テストファイルはフィクスチャ用の throw でリテラルを書くケースがあるため、 + // メッセージハードコード検知ルールを除外する。 + { + files: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/test/**/*.{ts,tsx}"], + rules: { + "no-restricted-syntax": "off", }, }, ); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a347011d..dc84980b 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,3 +1,4 @@ +import { FALLBACK_MESSAGES } from "../constants/messages"; import { API_BASE_URL, request } from "./client"; import { PATHS } from "./paths"; @@ -11,7 +12,7 @@ export async function getCurrentUser(): Promise { return null; } if (!response.ok) { - throw new Error("ログイン状態の確認に失敗しました。"); + throw new Error(FALLBACK_MESSAGES.AUTH_CHECK); } return (await response.json()) as AuthResponse; } @@ -34,7 +35,7 @@ export async function initiateGitHubLogin(returnTo: string): Promise { const response = await fetch(`${API_BASE_URL}${PATHS.auth.githubLoginUrl}?${params.toString()}`, { credentials: "include", }); - if (!response.ok) throw new Error("GitHub OAuth の開始に失敗しました"); + if (!response.ok) throw new Error(FALLBACK_MESSAGES.GITHUB_OAUTH_START); const data = (await response.json()) as { authorization_url: string; state: string }; // CSRF 検証用に state を sessionStorage へ保存する(コールバックで照合) sessionStorage.setItem(GITHUB_OAUTH_STATE_STORAGE_KEY, data.state); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2d9ee23c..8cb5810b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import { isErrorCode } from "../constants/errorCodes"; import { ERROR_CONFIG } from "../constants/errorMessages"; +import { NETWORK_MESSAGES } from "../constants/messages"; import { ApiError } from "../utils/appError"; import { generateErrorId } from "../utils/errorId"; @@ -73,7 +74,7 @@ function buildUnauthorizedError(): ApiError { _onUnauthorized?.(); return new ApiError({ code: "AUTH_REQUIRED", - message: "認証が必要です。再度ログインしてください。", + message: NETWORK_MESSAGES.AUTH_REQUIRED, action: "ログインし直してください", }); } @@ -133,7 +134,7 @@ export async function request( } catch { throw new ApiError({ code: "INTERNAL_ERROR", - message: "サーバーに接続できません。ネットワーク接続を確認してください。", + message: NETWORK_MESSAGES.CONNECTION_FAILED, }); } @@ -154,8 +155,8 @@ export async function request( const body = await getErrorBody(response); const fallbackMessage = response.status >= 500 - ? "サーバーエラーが発生しました。しばらくしてから再度お試しください。" - : "リクエストの処理に失敗しました。"; + ? NETWORK_MESSAGES.SERVER_ERROR + : NETWORK_MESSAGES.REQUEST_FAILED; const apiError = buildApiError(response, body, fallbackMessage); if (apiError.code === "AUTH_EXPIRED" || apiError.code === "AUTH_REQUIRED") { diff --git a/frontend/src/api/download.ts b/frontend/src/api/download.ts index eb363238..58cb4869 100644 --- a/frontend/src/api/download.ts +++ b/frontend/src/api/download.ts @@ -1,3 +1,4 @@ +import { downloadFailureMessage, FALLBACK_MESSAGES } from "../constants/messages"; import { API_BASE_URL } from "./client"; export async function downloadBlob( @@ -10,7 +11,7 @@ export async function downloadBlob( credentials: "include", }); if (!response.ok) { - throw new Error(`ダウンロードに失敗しました: ${filename}`); + throw new Error(downloadFailureMessage(filename)); } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); @@ -26,7 +27,7 @@ export async function getBlobUrl(url: string): Promise { credentials: "include", }); if (!response.ok) { - throw new Error("プレビューの取得に失敗しました"); + throw new Error(FALLBACK_MESSAGES.PREVIEW_FETCH); } const blob = await response.blob(); return URL.createObjectURL(blob); diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index dad4f512..a1251f3c 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -71,6 +71,11 @@ export const PATHS = { summaryCacheStatus: "/api/blog/summary-cache/status", score: "/api/blog/score", }, + resumeImports: { + start: "/api/resumes/import", + status: (id: string) => `/api/resumes/import/${id}/status`, + result: (id: string) => `/api/resumes/import/${id}/result`, + }, aiResume: { generate: "/api/ai-resume/generate", snapshots: "/api/ai-resume/snapshots", diff --git a/frontend/src/api/resumeImports.ts b/frontend/src/api/resumeImports.ts new file mode 100644 index 00000000..ba74cf11 --- /dev/null +++ b/frontend/src/api/resumeImports.ts @@ -0,0 +1,73 @@ +import type { CareerResumePayload } from "../types"; +import { ApiError } from "../utils/appError"; +import { API_BASE_URL, request } from "./client"; +import { PATHS } from "./paths"; + +/** POST /api/resumes/import のレスポンス */ +type ResumeImportStartResponse = { + import_id: string; +}; + +/** GET /api/resumes/import/{id}/status のレスポンス */ +export type ResumeImportStatusResponse = { + status: string; + error_message?: string; + error_code?: string; + judge_reason?: string; +}; + +/** GET /api/resumes/import/{id}/result のレスポンス */ +export type ResumeImportResultResponse = { + result: CareerResumePayload; + is_resume: boolean; + judge_reason?: string; +}; + +/** PDF をアップロードしてインポートタスクを開始する。 */ +export async function startResumeImport(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + + // multipart/form-data は Content-Type を手動で指定しない(ブラウザが boundary 付きで設定する) + // CSRF トークンは Cookie から取得して X-CSRF-Token ヘッダに付与する + const csrfToken = document.cookie.match(/csrf_token=([^;]+)/)?.[1] ?? ""; + + const response = await fetch(`${API_BASE_URL}${PATHS.resumeImports.start}`, { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + body: formData, + }); + + if (!response.ok) { + let body: { code?: string; message?: string; action?: string } | null = null; + try { + body = await response.json(); + } catch { + // parse 失敗は無視 + } + throw new ApiError({ + code: body?.code ?? "INTERNAL_ERROR", + message: body?.message ?? "アップロードに失敗しました。", + action: body?.action ?? null, + }); + } + + return response.json() as Promise; +} + +/** インポートタスクのステータスをポーリングする。 */ +export async function getResumeImportStatus( + importId: string, +): Promise { + return request(PATHS.resumeImports.status(importId)); +} + +/** 抽出結果を取得する(completed のみ 200)。 */ +export async function getResumeImportResult( + importId: string, +): Promise { + return request(PATHS.resumeImports.result(importId)); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 9fda2d86..15ca04ef 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { UI_MESSAGES } from "../constants/messages"; import { generateErrorId } from "../utils/errorId"; import styles from "./ErrorBoundary.module.css"; @@ -24,10 +25,8 @@ export default class ErrorBoundary extends Component {

Application Error

-

予期しないエラーが発生しました

-

- ページの表示中に問題が発生しました。再読み込みするか、ホームへ戻ってください。 -

+

{UI_MESSAGES.ERROR_BOUNDARY_TITLE}

+

{UI_MESSAGES.ERROR_BOUNDARY_BODY}

- -
-
- ))} + + {proj.name || "(未入力)"} + + + {projectSummary(proj)} +
+ + +
+
+ ); + })} + + + - - - - ))} + ); + })} diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 70228e02..9fa5a2f6 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -10,6 +10,8 @@ import { updateCareerResume, } from "../../api"; import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers"; +import { useCareerDirty } from "../../hooks/career/useCareerDirty"; +import { useResumeImport } from "../../hooks/career/useResumeImport"; import { useDocumentForm } from "../../hooks/useDocumentForm"; import { buildCareerPayload } from "../../payloadBuilders"; import type { CareerTextFieldKey } from "../../formTypes"; @@ -18,7 +20,9 @@ import { usePdfActions } from "../../hooks/usePdfActions"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; import { Skeleton } from "../ui/Skeleton"; +import { ImportResumeButton } from "./ImportResumeButton"; import { PdfPreviewModal } from "./PdfPreviewModal"; +import { ResumeImportConfirmModal } from "./ResumeImportConfirmModal"; import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection"; import { CareerExperienceSection } from "./sections/CareerExperienceSection"; import { CareerQualificationsSection } from "./sections/CareerQualificationsSection"; @@ -26,9 +30,11 @@ import { CareerSelfPrSection } from "./sections/CareerSelfPrSection"; export function CareerResumeForm() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const importState = useResumeImport(); const { form, setForm, + baseline, documentId: resumeId, loading, saving, @@ -54,6 +60,9 @@ export function CareerResumeForm() { const { items: qualificationOptions, loading: qualLoading } = useQualifications(); const qualificationNames = qualificationOptions.map((item) => item.name); + /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ + const dirty = useCareerDirty(form, baseline); + const { downloading, previewUrl, @@ -70,7 +79,7 @@ export function CareerResumeForm() { }); /** PDF アクションまたはフォーム保存のエラー・成功メッセージを統合して表示する */ - const error = pdfError ?? formError; + const error = pdfError ?? formError ?? importState.error?.message ?? null; const success = pdfSuccess ?? formSuccess; /** フォームデータ・技術スタック・資格の3つが揃ってから送信可能 */ @@ -102,10 +111,23 @@ export function CareerResumeForm() { /> )} {previewUrl && } + {importState.phase === "ready" && importState.parsedData && ( + { + setForm(merged); + importState.reset(); + }} + onCancel={() => importState.reset()} + /> + )}

職務経歴書

+ @@ -155,6 +177,8 @@ export function CareerResumeForm() { careerSummary={form.career_summary} loading={loading} onChange={onChangeField} + fullNameDirty={dirty.full_name} + careerSummaryDirty={dirty.career_summary} /> {/* 職務経歴セクション */} @@ -173,6 +197,8 @@ export function CareerResumeForm() { experiences={form.experiences} setForm={setForm} techStackOptions={techStackOptions} + experiencesDirty={dirty.experiences} + sectionDirty={dirty.experiencesAny} /> )} @@ -182,6 +208,8 @@ export function CareerResumeForm() { qualificationNames={qualificationNames} loading={loading} setForm={setForm} + qualificationsDirty={dirty.qualifications} + sectionDirty={dirty.qualificationsAny} /> {/* 自己PR */} @@ -189,6 +217,7 @@ export function CareerResumeForm() { selfPr={form.self_pr} loading={loading} onChange={(v) => onChangeField("self_pr", v)} + dirty={dirty.self_pr} />
diff --git a/frontend/src/components/forms/ImportResumeButton.tsx b/frontend/src/components/forms/ImportResumeButton.tsx new file mode 100644 index 00000000..c4ac1f53 --- /dev/null +++ b/frontend/src/components/forms/ImportResumeButton.tsx @@ -0,0 +1,51 @@ +import { useRef } from "react"; + +import type { UseResumeImportReturn } from "../../hooks/career/useResumeImport"; + +type Props = { + importState: UseResumeImportReturn; +}; + +/** + * 職務経歴書 PDF インポートボタン。 + * クリックでファイルピッカーを開き、選択後にインポート処理を開始する。 + */ +export function ImportResumeButton({ importState }: Props) { + const inputRef = useRef(null); + const { phase, start } = importState; + + const isLoading = phase === "uploading" || phase === "polling"; + + const handleClick = () => { + if (isLoading) return; + inputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // 同じファイルを再選択できるよう値をリセットする + e.target.value = ""; + await start(file); + }; + + return ( + <> + + + + ); +} diff --git a/frontend/src/components/forms/MarkdownTextarea.tsx b/frontend/src/components/forms/MarkdownTextarea.tsx index 5edf6a31..b2ac5f09 100644 --- a/frontend/src/components/forms/MarkdownTextarea.tsx +++ b/frontend/src/components/forms/MarkdownTextarea.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; import { marked } from "marked"; import shared from "../../styles/shared.module.css"; import styles from "./MarkdownTextarea.module.css"; @@ -16,12 +16,14 @@ type Props = { placeholder?: string; /** 必須フィールドかどうか */ required?: boolean; + /** ラベル横に追加する装飾要素(例: 未保存マーク 🔴) */ + labelAdornment?: ReactNode; }; /** * Markdownテキストエリア。入力内容をリアルタイムでプレビュー表示する。 */ -export function MarkdownTextarea({ label, value, onChange, rows = 3, placeholder, required }: Props) { +export function MarkdownTextarea({ label, value, onChange, rows = 3, placeholder, required, labelAdornment }: Props) { const renderedHtml = useMemo(() => { if (!value) return ""; return marked.parse(value, { async: false }) as string; @@ -32,6 +34,7 @@ export function MarkdownTextarea({ label, value, onChange, rows = 3, placeholder {label} {required && 必須} + {labelAdornment}