Skip to content

# feat: 職務経歴書フォームに未保存変更インジケーター(Dirty Dot)を追加#265

Merged
yusuke0610 merged 6 commits into
mainfrom
refactor/backend/deadcode
May 22, 2026
Merged

# feat: 職務経歴書フォームに未保存変更インジケーター(Dirty Dot)を追加#265
yusuke0610 merged 6 commits into
mainfrom
refactor/backend/deadcode

Conversation

@yusuke0610

@yusuke0610 yusuke0610 commented May 22, 2026

Copy link
Copy Markdown
Owner

feat: 職務経歴書フォームに未保存変更インジケーター(Dirty Dot)を追加

概要

職務経歴書フォームの各セクション・フィールドに、未保存の変更があることを示す赤丸マーク(Dirty Dot)を表示する機能を実装した。VS Code のファイル変更マークと同様の UX を目指し、編集中のどの箇所が未保存なのかを一目で把握できるようにする。

変更内容

新規追加

ファイル 内容
frontend/src/components/ui/DirtyDot.tsx 未保存マーク UI コンポーネント(CSS による赤丸、フォント非依存)
frontend/src/components/ui/DirtyDot.module.css DirtyDot 用スタイル
frontend/src/hooks/career/useCareerDirty.ts 職務経歴書全体の dirty マップを算出するフック
frontend/src/hooks/career/useCareerDirty.test.ts useCareerDirty の単体テスト
frontend/src/hooks/career/useProjectFormDirty.ts ProjectModal 内のフィールド単位 dirty マップを算出するフック
frontend/src/hooks/career/useProjectFormDirty.test.ts useProjectFormDirty の単体テスト
frontend/e2e/career-dirty-indicator.spec.ts dirty インジケーターの E2E テスト

既存ファイル変更

  • frontend/src/hooks/useDocumentForm.ts

    • baseline state を追加(サーバ最新スナップショット。load 成功・save 成功時のみ更新)
    • commitBaseline() ヘルパーを追加(ローカル state と Redux キャッシュを同時更新)
    • setForm 内の dispatchqueueMicrotask で render phase の外に逃がし、Cannot update a component while rendering 警告を解消
    • documentIdRef を追加し、setCache 発行時に正確な documentId を書き込む
    • useDocumentForm の戻り値に baseline を追加
  • frontend/src/store/formCacheSlice.ts

    • FormCacheEntrybaseline フィールドを追加
    • setBaseline アクションを追加(サーバ同期完了時専用)
    • setCache でキャッシュ上書き時に既存 baseline を保持するよう修正
  • frontend/src/components/forms/CareerResumeForm.tsx / 各 Section コンポーネント

    • useCareerDirty を呼び出し、各見出し・フィールド横に <DirtyDot> を配置
  • frontend/src/components/forms/ProjectModal.tsx

    • useProjectFormDirty を呼び出し、モーダルタイトル・各セクション横に <DirtyDot> を配置
  • backend/app/schemas/resume.py / backend/tests/test_schemas.py

    • スキーマおよびテストの軽微な修正

設計メモ

  • baselineロード完了・保存完了のタイミングでのみ更新 する。ユーザーの編集中(setForm)では更新しない。これにより「サーバに保存済みの状態との差分」を常に正確に算出できる。
  • baseline === null(未ロード)のときは dirty マップをすべて false とし、誤検出を防ぐ。
  • DirtyDot は CSS で赤丸を描画しており、絵文字の光沢グラデーション等のフォント依存の見た目ブレを回避している。
  • 小要素が dirty → 親セクション見出しにも dirty マークを伝播させることで、折りたたみ状態でも変更箇所を把握できる。

テスト計画

  • useCareerDirty 単体テスト(追加済み)
  • useProjectFormDirty 単体テスト(追加済み)
  • E2E テスト: フィールド編集後に dirty dot が表示されること(追加済み)
  • E2E テスト: 保存後に dirty dot が消えること(追加済み)
  • 手動確認: 職務経歴書ページで各セクション・プロジェクトモーダルの赤丸表示

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Resume PDF import: upload, background processing, polling, preview/confirm modal, and merge-into-form UX.
    • Unsaved-change indicators (red dots) across career/resume forms with per-field and section visibility.
  • Improvements

    • More reliable dirty-tracking baseline to avoid spurious unsaved marks.
    • Project end-date now represents in-progress projects more accurately.
    • Centralized UI message constants and new lint checks to prevent hardcoded Japanese text.
  • Tests

    • Added unit, integration, E2E coverage for import flow and dirty detection.
  • Documentation

    • Security, secret-management, and frontend messaging rules updated; workspace dictionary extended.

Review Change Stack

@yusuke0610 yusuke0610 added the feature 新機能 label May 22, 2026
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5f48cb2-6fe1-4ed6-aa9a-c527c14c24b1

📥 Commits

Reviewing files that changed from the base of the PR and between d1e2a49 and 3966999.

📒 Files selected for processing (1)
  • scripts/lint-frontend-messages.sh

📝 Walkthrough

Walkthrough

Adds hierarchical dirty-state tracking and UI indicators for the career/resume form (baseline storage, diff hooks, DirtyDot component, UI wiring, tests, E2E). Implements resume PDF import pipeline: DB migration, ORM model, router, PDF extraction, LLM judge/extraction, task handler, frontend client/hook/UI/mappers, error mappings, tests, and linting/docs for message SSoT.

Changes

Unsaved Changes Indicator System

Layer / File(s) Summary
Baseline state management and persistence
frontend/src/store/formCacheSlice.ts, frontend/src/hooks/useDocumentForm.ts
Adds baseline to form cache, preserves baseline on cache updates, provides setBaseline reducer, and exposes baseline from useDocumentForm; commits baseline after load/save/delete and defers cache dispatches with queueMicrotask.
Career form dirty state detection
frontend/src/hooks/career/useCareerDirty.ts, frontend/src/hooks/career/useCareerDirty.test.ts
New useCareerDirty hook and exported dirty types compute a hierarchical dirty map (fields/clients/projects/qualifications) by deep-comparing form vs baseline; includes unit tests covering adds/removes/field edits and baseline-null behavior.
Project form dirty detection
frontend/src/hooks/career/useProjectFormDirty.ts, frontend/src/hooks/career/useProjectFormDirty.test.ts
useProjectFormDirty computes field-level and section-level dirty flags for a project form via deep equality, handling original === null (new project) and providing ProjectFormDirty type; covered by unit tests.
DirtyDot UI component and styling
frontend/src/components/ui/DirtyDot.tsx, frontend/src/components/ui/DirtyDot.module.css, frontend/src/components/forms/MarkdownTextarea.tsx
Introduces DirtyDot red-dot component with accessibility attributes and CSS; extends MarkdownTextarea to accept labelAdornment so indicators can be composed into labels.
CareerResumeForm orchestration
frontend/src/components/forms/CareerResumeForm.tsx
Integrates useCareerDirty(form, baseline), surfaces import UI/confirm modal, aggregates errors with importState, and passes dirty props into child sections.
Form section components
frontend/src/components/forms/sections/*
Sections accept and render dirty-state props: CareerBasicInfoSection (fullNameDirty, careerSummaryDirty), CareerExperienceSection (experiencesDirty, sectionDirty), CareerQualificationsSection (qualificationsDirty, sectionDirty), and CareerSelfPrSection (dirty).
CareerExperienceEditor and ProjectModal field indicators
frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx, frontend/src/components/forms/ProjectModal.tsx
Editors and modal compute/receive dirty maps and render DirtyDot next to company/name/dates/role/team/description/technology/phases fields; ProjectModal uses useProjectFormDirty.
E2E test for dirty indicator
frontend/e2e/career-dirty-indicator.spec.ts
Playwright spec validating unsaved-mark behavior across project modal edits, new-user flows, avoidance of setState-in-render warnings, and persistence/clearing across navigation and save.
Form mappers and import UI
frontend/src/formMappers.ts, frontend/src/components/forms/ImportResumeButton.tsx, frontend/src/components/forms/ResumeImportConfirmModal.tsx
Adds mergeImportedResume to non-destructively merge imported resume payload into existing form, import button component to upload PDFs, and confirm modal to preview/confirm merging. Unit tests validate merge logic.

Resume PDF Import (backend + frontend)

Layer / File(s) Summary
Migration & ORM model
backend/alembic_migrations/versions/0032_add_resume_imports.py, backend/app/models/resume_import.py
Adds resume_imports table migration and ResumeImport SQLAlchemy model storing PDF blob, result JSON, flags, status, retries, timestamps, and expiration default.
Router & schemas
backend/app/routers/resume_imports.py, backend/app/schemas/resume_import.py, backend/app/main.py
Adds POST /api/resumes/import (accept PDF, size/page limits, persist blob, enqueue task), GET status and GET result endpoints, and registers router in app; Pydantic response schemas added.
PDF extraction & LLM extraction
backend/app/services/resume_import/pdf_extractor.py, backend/app/services/resume_import/llm_extractor.py, backend/app/services/resume_import/prompts/*
Implements PDF text extraction with heuristic for text layer; LLM-based judge_is_resume and extract_structured using prompts for judge+extract steps with robust parsing and error classification.
Task handler & registry
backend/app/services/tasks/handlers/resume_import.py, backend/app/services/tasks/handlers/__init__.py, backend/app/services/tasks/base.py
Adds TaskType.RESUME_IMPORT, registers ResumeImportHandler, implements handler that loads record, extracts text, runs judge/extractor, updates record (result_json/is_resume_flag/status), clears PDF blob, and commits; maps non-retryable conditions and errors.
Frontend client & hook
frontend/src/api/resumeImports.ts, frontend/src/hooks/career/useResumeImport.ts, frontend/src/hooks/career/__tests__/useResumeImport.test.ts
Frontend API client for start/status/result, useResumeImport hook to validate client-side (PDF type, 10MB), upload, poll via task polling, handle completion/errors, and tests for phases and error handling.
Frontend integration
frontend/src/api/paths.ts, frontend/src/components/forms/ImportResumeButton.tsx, frontend/src/components/forms/ResumeImportConfirmModal.tsx, frontend/src/constants/*
New PATHS entries, import button, confirm modal, error codes/messages mapping and UI wiring; mergeImportedResume used to merge parsed payload on confirm.
Tests & requirements
backend/tests/*, backend/requirements.txt, frontend/src/hooks/career/useProjectFormDirty.test.ts
Unit and integration tests for handler and router (success, non-retryable failure cases, auth, size/type limits), requirement additions (pdfplumber, python-multipart), and hook tests for project dirty detection.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • yusuke0610/devforge#261: Overlapping career form refactor that reorganized section components—relevant to dirty-prop integration.

"I nibble at baseline roots and hop with ease;
Red dots bloom where edits tease.
PDFs whisper pages, LLMs decide,
Merged and saved — the dots subside.
A rabbit cheers: form tidy, please!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/backend/deadcode

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
frontend/e2e/career-dirty-indicator.spec.ts (1)

200-201: ⚡ Quick win

固定 wait は避けて条件待ちにしてください。

waitForTimeout(100) は CI 負荷で不安定化しやすいので、条件ベース(expect.poll など)に置き換えるのが安全です。

Diff suggestion
-    // microtask の dispatch が消化されるまで少し待つ
-    await page.waitForTimeout(100);
+    // 固定 sleep ではなく条件待ち
+    await expect
+      .poll(
+        () => consoleIssues.filter((m) => m.includes("Cannot update a component")).length,
+        { timeout: 1500 },
+      )
+      .toBe(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/career-dirty-indicator.spec.ts` around lines 200 - 201, Replace
the fixed sleep (await page.waitForTimeout(100)) with a condition-based wait
using Playwright's expect.poll or a page.waitForFunction-style poll: identify a
stable observable that indicates microtasks have been processed (e.g., a DOM
attribute/text change, a specific element state, or a small page.evaluate check
that resolves only after queued microtasks run) and use expect.poll(() =>
page.evaluate(/* check */)).toResolve()/toEqual(...) or
page.waitForFunction(...) instead of page.waitForTimeout; update the assertion
call near the original wait to poll that condition until it succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/schemas/resume.py`:
- Around line 60-63: The ResumeProject model is returning non-None end_date
values (including empty strings) when is_current is True; add a Pydantic
validator (e.g., a field validator or root_validator on ResumeProject) that
checks is_current and normalizes end_date to None when is_current is True,
ensuring ResumeProject.end_date always returns None for ongoing projects and
matches Experience behavior.

In `@frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx`:
- Around line 190-215: The dirty indicator can vanish when toggling
client.has_client because DirtyDot is only rendered inside the client name
block; move or duplicate the DirtyDot so it’s rendered alongside the checkbox
(outside the {client.has_client && ...} block) so clientDirty?.self remains
visible even when the name input is hidden; update the JSX around
client.has_client, the label containing clientNameLabel and the clientCheckbox
input and ensure onUpdateClientHasClient still toggles using expIndex and
clientIndex so the visible DirtyDot reflects changes to client.has_client as
well as name edits.

In `@frontend/src/hooks/career/useCareerDirty.ts`:
- Around line 99-113: buildCleanExperience currently sets every client's
projects to an empty array, which mismatches the form shape when baseline is
null and can cause nested projects[i] access errors; update buildCleanExperience
so for each client in experience.clients (in function buildCleanExperience and
the returned ExperienceDirty.clients) you preserve the per-client projects array
shape by mapping each client's actual projects length into a same-length
projects array of clean flags (e.g., map client.projects to an array of false
values) instead of always using [] so the baseline structure aligns with the
form's nested projects.

In `@frontend/src/hooks/useDocumentForm.ts`:
- Around line 146-152: The catch block currently treats any load failure as a
404 and calls commitBaseline(createInitialForm()), which can mask non-404 errors
and create a different snapshot for baseline than for the live form; change it
to inspect the caught error and only handle HTTP 404 by creating a single
initial snapshot (call createInitialForm() once into a const) and apply that
same snapshot to both the live form state (e.g., setForm or initializeForm) and
commitBaseline, while rethrowing or surfacing other errors so transient/API
failures are not treated as “no document”; update error handling inside the
catch that surrounds commitBaseline/createInitialForm accordingly.

---

Nitpick comments:
In `@frontend/e2e/career-dirty-indicator.spec.ts`:
- Around line 200-201: Replace the fixed sleep (await page.waitForTimeout(100))
with a condition-based wait using Playwright's expect.poll or a
page.waitForFunction-style poll: identify a stable observable that indicates
microtasks have been processed (e.g., a DOM attribute/text change, a specific
element state, or a small page.evaluate check that resolves only after queued
microtasks run) and use expect.poll(() => page.evaluate(/* check
*/)).toResolve()/toEqual(...) or page.waitForFunction(...) instead of
page.waitForTimeout; update the assertion call near the original wait to poll
that condition until it succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 150cbe1d-9467-495b-a033-e21c8e388bc8

📥 Commits

Reviewing files that changed from the base of the PR and between 8dc8909 and fee7e3c.

📒 Files selected for processing (23)
  • .claude/CLAUDE.md
  • .claude/rules/backend/auth-security.md
  • .claude/rules/backend/database.md
  • backend/app/schemas/resume.py
  • backend/tests/test_schemas.py
  • frontend/e2e/career-dirty-indicator.spec.ts
  • frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx
  • frontend/src/components/forms/CareerResumeForm.tsx
  • frontend/src/components/forms/MarkdownTextarea.tsx
  • frontend/src/components/forms/ProjectModal.tsx
  • frontend/src/components/forms/sections/CareerBasicInfoSection.tsx
  • frontend/src/components/forms/sections/CareerExperienceSection.tsx
  • frontend/src/components/forms/sections/CareerQualificationsSection.tsx
  • frontend/src/components/forms/sections/CareerSelfPrSection.tsx
  • frontend/src/components/ui/DirtyDot.module.css
  • frontend/src/components/ui/DirtyDot.tsx
  • frontend/src/hooks/career/useCareerDirty.test.ts
  • frontend/src/hooks/career/useCareerDirty.ts
  • frontend/src/hooks/career/useProjectFormDirty.test.ts
  • frontend/src/hooks/career/useProjectFormDirty.ts
  • frontend/src/hooks/useDocumentForm.ts
  • frontend/src/store/formCacheSlice.ts
  • infra/modules/cloud_run/main.tf
💤 Files with no reviewable changes (3)
  • infra/modules/cloud_run/main.tf
  • .claude/CLAUDE.md
  • .claude/rules/backend/auth-security.md

Comment thread backend/app/schemas/resume.py
Comment thread frontend/src/hooks/career/useCareerDirty.ts Outdated
Comment on lines 146 to 152
} catch {
if (!active) return;
// DB に未登録(404)のユーザー向け: 初期空フォームを baseline として確定する。
// これにより以後のユーザー編集はすべて baseline との差分として検出され、
// 各フィールド・配下要素(プロジェクト等)の未保存マークが正しく表示される。
commitBaseline(createInitialForm());
} finally {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-404 load failures separately, and commit the exact same initial snapshot to both form and baseline.

Line 146 currently treats every load failure as “no existing document”. That can mask transient/API failures and later overwrite existing data. Also, Line 151 creates a fresh initial snapshot only for baseline, which can desync from current form if createInitialForm() is non-deterministic.

💡 Suggested fix
-      } catch {
+      } catch (e) {
         if (!active) return;
-        // DB に未登録(404)のユーザー向け: 初期空フォームを baseline として確定する。
-        // これにより以後のユーザー編集はすべて baseline との差分として検出され、
-        // 各フィールド・配下要素(プロジェクト等)の未保存マークが正しく表示される。
-        commitBaseline(createInitialForm());
+        // 404 のみ「未登録」とみなして初期状態を確定する
+        // (判定は既存 API エラーモデルに合わせて実装)
+        if (isNotFoundError(e)) {
+          const initial = createInitialForm();
+          setFormRaw(initial);
+          commitBaseline(initial);
+        } else {
+          const message =
+            e instanceof Error ? e.message : "読み込み中に不明なエラーが発生しました。";
+          setError(message);
+        }
       } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useDocumentForm.ts` around lines 146 - 152, The catch
block currently treats any load failure as a 404 and calls
commitBaseline(createInitialForm()), which can mask non-404 errors and create a
different snapshot for baseline than for the live form; change it to inspect the
caught error and only handle HTTP 404 by creating a single initial snapshot
(call createInitialForm() once into a const) and apply that same snapshot to
both the live form state (e.g., setForm or initializeForm) and commitBaseline,
while rethrowing or surfacing other errors so transient/API failures are not
treated as “no document”; update error handling inside the catch that surrounds
commitBaseline/createInitialForm accordingly.

@yusuke0610

Copy link
Copy Markdown
Owner Author

職務経歴書 PDF インポート機能の追加

Summary

  • 既存の職務経歴書 PDF をアップロードして、pdfplumber + Gemini 2.5 Flash で内容を抽出し、CareerResumeForm に「確認モーダル経由でオーバーレイマージ」する非破壊インポート機能を追加。
  • 既存の非同期タスク基盤(AsyncTaskCacheService / TaskHandler / useTaskPolling)に RESUME_IMPORT タスク種別を追加し、POST → 202 → ステータスポーリング → 結果取得 という他の非同期処理と同じパターンに揃えた。
  • エラーコード RESUME_IMPORT_INVALID / RESUME_IMPORT_NOT_A_RESUME を BE/FE で同期し、スキャン PDF・職務経歴書でない PDF・サイズ超過などをユーザーに区別可能なメッセージで返す。

変更点

Backend

  • モデル: ResumeImport テーブル追加(PDF blob・抽出結果 JSON・LLM 判定理由・ステータス・有効期限を保持。完了/失敗時に pdf_blob を NULL クリア)。マイグレーション 0032_add_resume_imports.py
  • サービス層:
    • services/resume_import/pdf_extractor.py: pdfplumber でテキスト抽出。ページの半数以上がテキスト希薄(30 文字未満)ならスキャン PDF と判定
    • services/resume_import/llm_extractor.py: judge_is_resume()(confidence < 0.6 で is_resume=False)→ extract_structured() の二段構成。プロンプトは prompts/ に分離。
  • タスクハンドラ: services/tasks/handlers/resume_import.py。失敗パスは NonRetryableError を raise(黙って return しない)。スキャン PDF / 職務経歴書ではない判定はそれぞれ専用のエラーメッセージで識別可能。
  • ルーター: routers/resume_imports.py に以下 3 エンドポイントを追加(@limiter.limit("10/minute") で多重アップロード防止)。
    • POST /api/resumes/import (202): MIME / サイズ(10MB)/ ページ数の境界バリデーション → DB 登録 → タスク dispatch
    • GET /api/resumes/import/{id}/status: ステータス・エラーコード・判定理由を返す
    • GET /api/resumes/import/{id}/result (409 if not completed): 構造化結果を返す
  • エラーコード: app/core/errors.pyRESUME_IMPORT_INVALID / RESUME_IMPORT_NOT_A_RESUME を追加し、infer_error_code() でメッセージから逆引きできるようにした。

Frontend

  • API クライアント: api/resumeImports.ts(FormData アップロードは request() ラッパーが Content-Type: application/json を強制するため raw fetch を使用)。
  • フック: hooks/career/useResumeImport.tsidle → uploading → polling → ready | error のステートマシン。useTaskPolling を共通化。
  • コンポーネント:
    • ImportResumeButton: 非表示の <input type="file"> をトリガーするボタン。フェーズに応じてラベルが切替わる。
    • ResumeImportConfirmModal: 抽出結果プレビュー + 既存フォームが dirty なら警告。「反映する」で mergeImportedResume() 経由で親フォームへ非破壊マージ。
  • マージロジック (formMappers.mergeImportedResume): 既存フォームが空フィールドのみインポートで埋める。職務経歴 / 資格は「初期状態(1 件分の空行)」なら置換、それ以外なら追記、で意図しない上書きを防ぐ。
  • CareerResumeForm へ統合(pageHeaderActions にボタン、phase が ready なら確認モーダル)。

Tests

  • Backend:
    • tests/test_resume_import_handler.py: 成功 / スキャン PDF NonRetryable / 職務経歴書でない NonRetryable / ペイロード欠落 / get_record の 6 ケース。pdfplumber のテキストレイヤー判定がファイル依存で揺れるため、スキャンケースは pdf_extractor.extract_text を直接モックし has_text_layer=False を確定的に再現。
    • tests/test_resume_imports_router.py: 202 アップロード / 非 PDF 422 / 10MB 超過 422 / 認証なし 401 / status pending / 不明 ID 404 / ユーザー分離(user A のレコードを DB 直接挿入し、user B 認証で 404)/ result 409 (pending) / result 200 (completed) の 9 ケース。
  • Frontend:
    • formMappers.mergeImportedResume.test.ts: 初期フォーム置換 / 既存文字列保持 / 職歴追記 / 資格追記 / 空 import / 部分空フィールドの 6 ケース。
    • hooks/career/__tests__/useResumeImport.test.ts: 初期 idle / 非 PDF エラー / サイズ超過 / 正常 polling 遷移 / reset の 5 ケース。
  • make ci (= make lint + make test + make build-frontend) すべて green。
    • backend: 522 passed
    • frontend vitest: 148 passed / payloadBuilders (node:test): 4 passed
    • build: tsc + vite build OK

Test plan

  • PDF アップロード → 202 → ステータスポーリング → 確認モーダル → 反映 のゴールデンパス
  • スキャン PDF(テキストレイヤーなし)→ RESUME_IMPORT_INVALID の日本語メッセージ
  • 職務経歴書でない PDF(請求書等)→ RESUME_IMPORT_NOT_A_RESUME + judge_reason 表示
  • 10MB 超過 → フロント事前バリデーションで弾く
  • 非 PDF(.txt 等)→ フロント事前バリデーションで弾く
  • 既存フォームが dirty な状態でインポート → 警告表示・キャンセル動作
  • 既存職歴あり状態でインポート → 既存職歴の後に追記される(上書きされない)
  • 他ユーザー作成の import_id を別ユーザーで取得 → 404(ユーザー分離)
  • Cloud Tasks / BackgroundTasks 両経路でタスク dispatch されること

関連 ADR / 設計判断

  • 既存非同期タスクパターン(CareerAnalysis / BlogSummary)に揃えた。新規の dispatch 経路は設けず、TaskType enum に 1 行追加するだけで Cloud Run 本番でも local でも動く。
  • PDF は DB blob 保持 → タスク完了/失敗時にクリア。GCS 移行は将来 ADR を切る前提(現状は短命データ + 10MB 上限のため DB 内保持で問題なし)。
  • LLM は二段呼び出し(判定 → 抽出)にし、職務経歴書以外で抽出コストを浪費しないようにした。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (5)
frontend/src/api/paths.ts (1)

74-78: 💤 Low value

Consider nesting under PATHS.resumes for consistency.

The new resumeImports endpoints start with /api/resumes/import, which suggests they logically belong to the resumes resource. Organizing them as PATHS.resumes.import rather than a top-level PATHS.resumeImports would make the hierarchy more intuitive and consistent with the URL structure.

If the separation is intentional to mirror the backend router organization, consider adding a comment explaining the reasoning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/paths.ts` around lines 74 - 78, The resumeImports endpoints
are defined at top-level as PATHS.resumeImports but their URLs start with
/api/resumes/import and should be nested under the existing PATHS.resumes for
consistency; move the resumeImports object into PATHS.resumes as an import
property (e.g., PATHS.resumes.import.start, .status(id), .result(id)) or, if
intentional, add a clarifying comment near the PATHS.resumeImports definition
explaining why it is kept top-level to mirror backend routing.
frontend/src/api/resumeImports.ts (2)

58-58: ⚡ Quick win

Response type casting lacks runtime validation.

The cast to Promise<ResumeImportStartResponse> provides compile-time safety but no runtime validation. If the API returns an unexpected structure (e.g., due to a backend bug or API version mismatch), the error will surface later when accessing import_id, making debugging harder.

Consider adding runtime validation using a schema validator (e.g., Zod) or at minimum an assertion that checks for required fields.

♻️ Example with basic validation
- return response.json() as Promise<ResumeImportStartResponse>;
+ const data = await response.json();
+ if (!data.import_id || typeof data.import_id !== 'string') {
+   throw new ApiError({
+     code: 'INTERNAL_ERROR',
+     message: 'Invalid response from server',
+     action: null,
+   });
+ }
+ return data as ResumeImportStartResponse;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/resumeImports.ts` at line 58, The current return casts
response.json() to Promise<ResumeImportStartResponse> without runtime checks;
update the code that returns response.json() to validate the parsed payload
before returning (e.g., use a Zod schema or a small assertion) to ensure
required fields like import_id exist and have the expected types, and throw or
return a typed error if validation fails; reference the
ResumeImportStartResponse shape and the response.json() call so you validate the
parsed object and only then return it as a ResumeImportStartResponse.

33-33: 💤 Low value

Consider using a dedicated cookie parsing utility.

The regex pattern works but is simplistic. If cookies have URL-encoded values or complex formats, it may not extract the token correctly. Consider using a dedicated cookie parsing function for more robust handling.

♻️ Example using a helper function
function getCookie(name: string): string {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop()?.split(';').shift() ?? '';
  return '';
}

const csrfToken = getCookie('csrf_token');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/api/resumeImports.ts` at line 33, Replace the simplistic regex
cookie extraction for csrfToken with a robust cookie parsing helper: add a
getCookie(name: string) function and use const csrfToken =
getCookie('csrf_token'); update any direct uses of document.cookie in
resumeImports.ts to call getCookie('csrf_token') so URL-encoded or complex
cookie values are handled correctly (refer to the csrfToken constant and
implement getCookie in the same module or a shared util).
frontend/src/components/forms/ResumeImportConfirmModal.tsx (1)

31-33: ⚡ Quick win

Add dialog semantics to the modal container.

Line 32 renders a modal visually, but it lacks role="dialog", aria-modal="true", and an accessible label link (e.g., aria-labelledby to the title at Line 33). This improves screen reader navigation without changing behavior.

Proposed patch
-    <div className={styles.overlay} onClick={onCancel}>
-      <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
-        <h2 className={styles.title}>PDF から読み取った内容</h2>
+    <div className={styles.overlay} onClick={onCancel}>
+      <div
+        className={styles.modal}
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="resume-import-confirm-title"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <h2 id="resume-import-confirm-title" className={styles.title}>PDF から読み取った内容</h2>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/forms/ResumeImportConfirmModal.tsx` around lines 31 -
33, The modal container rendered in ResumeImportConfirmModal is missing dialog
semantics; update the div with className={styles.modal} to include
role="dialog", aria-modal="true", and aria-labelledby that points to the modal
title, and add a stable id to the title element (the h2 with
className={styles.title}) so screen readers can reference it; keep the existing
onClick={(e) => e.stopPropagation()} and onCancel handler unchanged.
backend/tests/test_resume_import_handler.py (1)

124-128: ⚡ Quick win

Assert terminal status in failure-path tests as well.

These tests already check error details and blob cleanup; adding status == "dead_letter" assertions will lock in expected terminal-state behavior.

Also applies to: 151-154

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_resume_import_handler.py` around lines 124 - 128, Add an
assertion that the ResumeImport record reaches the terminal failure status by
asserting record.status == "dead_letter" after refreshing the record in the
failure-path tests (the block using db_session.refresh(record) and asserting
record.error_message and record.pdf_blob). Apply the same addition for the other
failure test mentioned (the similar block around lines 151-154) so both tests
explicitly check the terminal status on failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/alembic_migrations/versions/0032_add_resume_imports.py`:
- Around line 46-51: The migration sets updated_at with only
server_default=sa.func.now() while the model (resume_import.py) relies on
SQLAlchemy's onupdate=func.now(); to cover non-ORM updates add a DB-side UPDATE
mechanism: modify the migration (0032_add_resume_imports.py) to create a trigger
function that sets NEW.updated_at = now() on row UPDATE and attach a trigger to
the resume_imports table in the upgrade() and drop it in downgrade(); keep the
existing Column(...) definition but add the trigger creation SQL (and trigger
drop) so updated_at is updated for raw SQL/other clients as well.

In `@backend/app/routers/resume_imports.py`:
- Around line 56-64: The current code awaits file.read() and loads the entire
upload into pdf_bytes before checking _MAX_FILE_SIZE; change to stream the
upload in chunks (e.g., repeatedly call await file.read(CHUNK_SIZE) and append
into a bytearray buffer) while keeping a running total and immediately call
raise_app_error (same status_code, code, message, action) and stop reading/close
the file if the total exceeds _MAX_FILE_SIZE; use the existing names (file,
pdf_bytes/buffer, _MAX_FILE_SIZE, raise_app_error) so the size check happens
incrementally and large uploads never get fully loaded into memory.
- Around line 165-168: Guard the JSON parsing of record.result_json to avoid
uncaught exceptions: instead of directly calling parsed =
json.loads(record.result_json), first check that record.result_json is truthy
and wrap json.loads in a try/except catching json.JSONDecodeError (and Exception
as fallback); on failure return a safe domain response (e.g.,
ResumeImportResultResponse with result set to None or a default ResumeBase
placeholder) and preserve record.is_resume_flag, and optionally log the error;
update the code around ResumeImportResultResponse / ResumeBase to accept the
safe fallback.
- Around line 97-112: When dispatching the resume import task via
AsyncTaskCacheService.dispatch (TaskType.RESUME_IMPORT) fails, update the
persisted import record to mark it as a terminal/failed state and clear its
pdf_blob to avoid leaving sensitive data and stuck rows; catch the Exception
around service.dispatch, set record.status (or the model's terminal/status
field) to the terminal error value and set record.pdf_blob = None (or empty),
persist the change via the same db/session used by AsyncTaskCacheService (e.g.,
db.add/db.commit or record.save), then log the change and re-raise the HTTP 500
via raise_app_error as before. Ensure you reference AsyncTaskCacheService,
dispatch, TaskType.RESUME_IMPORT, record, and pdf_blob when locating the code to
modify.

In `@backend/app/services/resume_import/llm_extractor.py`:
- Line 53: The LLM calls to llm_client.generate (invoked with system_prompt and
user_prompt) need explicit timeouts to avoid hanging; update both call sites
(the generate at raw = await llm_client.generate(system_prompt, user_prompt) and
the similar call around line 84) to either pass a supported timeout argument to
LLMClient.generate or wrap the await in asyncio.wait_for with a sensible timeout
(e.g., 30s), catch asyncio.TimeoutError and re-raise a RetryableError (or the
module's retryable exception) with a clear message so the worker can recover
instead of blocking indefinitely.
- Line 63: The current logger.warning call exposes PII by printing raw[:200];
update the logging in llm_extractor.py to avoid emitting resume content by
replacing the raw snippet with non-PII metadata (e.g., log the response length
using len(raw), a status code or parsing outcome, or a fixed message). Locate
the logger.warning usage(s) (the call referencing raw and the logger in this
module, also the similar occurrence around lines 91) and change the message to
something like "LLM 判定レスポンスのパースに失敗しました: response length=%d" or a fixed warning
string so no personal data from raw is written to logs.

In `@backend/app/services/resume_import/pdf_extractor.py`:
- Around line 30-43: The code currently treats an empty pages list as having a
text layer; update the logic in pdf_extractor.py to explicitly handle an empty
extracted page set by setting has_text_layer = False (and full_text = "" if
needed) when pages is empty; locate the block using pdf.pages, pages,
sparse_pages, _MIN_CHARS_PER_PAGE, full_text and has_text_layer and add an early
check like "if not pages: full_text = ''; has_text_layer = False" before
computing sparse_pages and the existing half-pages condition.

In `@backend/app/services/resume_import/prompts/extract_resume.md`:
- Line 47: The phases values are inconsistent between the JSON schema's "phases"
array and the extraction rules that list separate design phases; update them to
match. Pick one approach (recommended: expand the schema) and edit the "phases"
enum/array to include "要件定義", "基本設計", "詳細設計", "開発", "テスト", "リリース", "保守運用", and
ensure the extraction instruction text that currently lists
「要件定義」「基本設計」「詳細設計」「開発」「テスト」「リリース」「保守運用」 exactly matches those strings; also
update any validation logic or enums that reference "phases" (e.g.,
extraction/validation routines) to use the same set.

In `@backend/app/services/tasks/handlers/resume_import.py`:
- Around line 62-83: The scan-PDF and not-a-resume branches update record fields
then commit and raise NonRetryableError but do not set the terminal status
field, leaving status at "processing"; update both branches (the
extracted.has_text_layer false branch and the if not judge_result.is_resume
branch around llm_extractor.judge_is_resume) to assign the same terminal status
value used by the missing-PDF path (e.g., record.status = "failed" or the
project's defined terminal status), then set record.is_resume_flag,
record.judge_reason, record.error_message, record.pdf_blob, record.completed_at,
call db.commit(), and raise NonRetryableError as before so the terminal status
is persisted.

In `@backend/requirements.txt`:
- Around line 28-29: Tighten the python-multipart requirement in
backend/requirements.txt to avoid known vulnerabilities by changing the lower
bound from python-multipart>=0.0.9 to at least python-multipart>=0.0.27 (or pin
to a known safe version such as 0.0.29); edit the requirements entry for
python-multipart so dependency resolution will not install vulnerable versions
while leaving pdfplumber>=0.11.0 unchanged.

In `@frontend/src/components/forms/CareerResumeForm.tsx`:
- Line 118: The isDirty prop is computed from
Object.values(dirty).some(Boolean), which can return true for non-boolean truthy
entries and thus show dirty even when nothing changed; update the aggregation to
only consider explicit boolean flags (e.g. replace
Object.values(dirty).some(Boolean) with Object.values(dirty).some(v => v ===
true)) or introduce/maintain a single boolean like hasUnsavedChanges and pass
that to isDirty; look for the dirty variable and the isDirty prop usage in
CareerResumeForm (the component) and change the expression to only consider true
booleans or an aggregated boolean flag.

In `@frontend/src/formMappers.ts`:
- Around line 22-29: The blank-detection in _isBlankExperiences currently only
checks company, business_description and start_date, which can mark
partially-filled entries as blank and overwrite user data; update
_isBlankExperiences to consider all form fields for the single experience (e.g.,
use a check that iterates over the experience object's string properties and
returns true only if every property .trim() is empty) and apply the same fix to
the other blank-detection logic referenced later in the file so that only truly
empty experience objects are treated as blank.
- Around line 59-60: The current check treats importedForm.experiences as empty
if every experience has an empty company, which can discard useful data in other
fields; change the predicate used in the if so it only treats an experience as
empty when all relevant fields are blank (e.g., company, title, period,
description) — either inline the check or add a helper like
isExperienceEmpty(ex) that trims and tests all fields, and use
importedForm.experiences.every(isExperienceEmpty) before substituting
existing.experiences.

In `@frontend/src/hooks/career/useResumeImport.ts`:
- Around line 94-97: The polling race is caused by setImportId being async so
checkStatus (used by startPolling) reads a stale importId; replace the importId
state with a ref (e.g., importIdRef) and update importIdRef.current = import_id
inside the start/resume logic (the code around startResumeImport and start
functions), then have checkStatus/readers reference importIdRef.current instead
of the importId state; also update reset to clear importIdRef.current and remove
or keep a minimal state-only representation for UI if needed so startPolling
always sees the current import id.

---

Nitpick comments:
In `@backend/tests/test_resume_import_handler.py`:
- Around line 124-128: Add an assertion that the ResumeImport record reaches the
terminal failure status by asserting record.status == "dead_letter" after
refreshing the record in the failure-path tests (the block using
db_session.refresh(record) and asserting record.error_message and
record.pdf_blob). Apply the same addition for the other failure test mentioned
(the similar block around lines 151-154) so both tests explicitly check the
terminal status on failure.

In `@frontend/src/api/paths.ts`:
- Around line 74-78: The resumeImports endpoints are defined at top-level as
PATHS.resumeImports but their URLs start with /api/resumes/import and should be
nested under the existing PATHS.resumes for consistency; move the resumeImports
object into PATHS.resumes as an import property (e.g.,
PATHS.resumes.import.start, .status(id), .result(id)) or, if intentional, add a
clarifying comment near the PATHS.resumeImports definition explaining why it is
kept top-level to mirror backend routing.

In `@frontend/src/api/resumeImports.ts`:
- Line 58: The current return casts response.json() to
Promise<ResumeImportStartResponse> without runtime checks; update the code that
returns response.json() to validate the parsed payload before returning (e.g.,
use a Zod schema or a small assertion) to ensure required fields like import_id
exist and have the expected types, and throw or return a typed error if
validation fails; reference the ResumeImportStartResponse shape and the
response.json() call so you validate the parsed object and only then return it
as a ResumeImportStartResponse.
- Line 33: Replace the simplistic regex cookie extraction for csrfToken with a
robust cookie parsing helper: add a getCookie(name: string) function and use
const csrfToken = getCookie('csrf_token'); update any direct uses of
document.cookie in resumeImports.ts to call getCookie('csrf_token') so
URL-encoded or complex cookie values are handled correctly (refer to the
csrfToken constant and implement getCookie in the same module or a shared util).

In `@frontend/src/components/forms/ResumeImportConfirmModal.tsx`:
- Around line 31-33: The modal container rendered in ResumeImportConfirmModal is
missing dialog semantics; update the div with className={styles.modal} to
include role="dialog", aria-modal="true", and aria-labelledby that points to the
modal title, and add a stable id to the title element (the h2 with
className={styles.title}) so screen readers can reference it; keep the existing
onClick={(e) => e.stopPropagation()} and onCancel handler unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2ec81d4a-78d4-4a3a-aae5-2e6f15301ade

📥 Commits

Reviewing files that changed from the base of the PR and between fee7e3c and 5f2c74b.

📒 Files selected for processing (39)
  • .claude/rules/security.md
  • .vscode/settings.json
  • backend/alembic_migrations/versions/0032_add_resume_imports.py
  • backend/app/core/errors.py
  • backend/app/main.py
  • backend/app/models/__init__.py
  • backend/app/models/resume_import.py
  • backend/app/routers/__init__.py
  • backend/app/routers/resume_imports.py
  • backend/app/schemas/resume.py
  • backend/app/schemas/resume_import.py
  • backend/app/services/resume_import/__init__.py
  • backend/app/services/resume_import/llm_extractor.py
  • backend/app/services/resume_import/pdf_extractor.py
  • backend/app/services/resume_import/prompts/extract_resume.md
  • backend/app/services/resume_import/prompts/judge_resume.md
  • backend/app/services/tasks/base.py
  • backend/app/services/tasks/handlers/__init__.py
  • backend/app/services/tasks/handlers/resume_import.py
  • backend/requirements.txt
  • backend/tests/conftest.py
  • backend/tests/test_resume_import_handler.py
  • backend/tests/test_resume_imports_router.py
  • backend/tests/test_schemas.py
  • frontend/src/api/paths.ts
  • frontend/src/api/resumeImports.ts
  • frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx
  • frontend/src/components/forms/CareerResumeForm.tsx
  • frontend/src/components/forms/ImportResumeButton.tsx
  • frontend/src/components/forms/ResumeImportConfirmModal.module.css
  • frontend/src/components/forms/ResumeImportConfirmModal.tsx
  • frontend/src/constants/errorCodes.ts
  • frontend/src/constants/errorMessages.ts
  • frontend/src/formMappers.mergeImportedResume.test.ts
  • frontend/src/formMappers.ts
  • frontend/src/hooks/career/__tests__/useResumeImport.test.ts
  • frontend/src/hooks/career/useCareerDirty.test.ts
  • frontend/src/hooks/career/useCareerDirty.ts
  • frontend/src/hooks/career/useResumeImport.ts
✅ Files skipped from review due to trivial changes (4)
  • backend/app/services/resume_import/prompts/judge_resume.md
  • backend/app/models/init.py
  • backend/app/services/resume_import/init.py
  • backend/app/services/tasks/base.py

Comment on lines +46 to +51
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),

@coderabbitai coderabbitai Bot May 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the ORM model defines onupdate for updated_at

rg -A 5 "class ResumeImport" --type py --glob '**/models/resume_import.py'

# Look for updated_at column definition in the model
rg "updated_at.*Column" --type py --glob '**/models/resume_import.py' -A 2

Repository: yusuke0610/devforge

Length of output: 440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the migration's updated_at column definition
echo "=== Migration: updated_at column ==="
sed -n '1,120p' backend/alembic_migrations/versions/0032_add_resume_imports.py | nl -ba | sed -n '35,80p' || true

# Inspect the model for ResumeImport.updated_at and any onupdate/onupdate events
echo "=== Model: backend/app/models/resume_import.py (updated_at occurrences) ==="
rg -n "updated_at" backend/app/models/resume_import.py || true

echo "=== Model: lines around updated_at definition ==="
rg -n "updated_at" -n backend/app/models/resume_import.py -n --context 10 || true

# Also scan for mapper events / SQLAlchemy event listeners that might update updated_at
echo "=== Model: SQLAlchemy event hooks near ResumeImport ==="
rg -n "event\.listen|mapper|before_update|onupdate" backend/app/models/resume_import.py || true

Repository: yusuke0610/devforge

Length of output: 1135


Fix the updated_at auto-update concern—ORM already updates it

backend/app/alembic_migrations/versions/0032_add_resume_imports.py defines updated_at with only server_default=sa.func.now() (no DB-side update trigger), but backend/app/models/resume_import.py sets updated_at with onupdate=func.now(), so SQLAlchemy ORM-generated UPDATEs will refresh updated_at.

If resume_imports rows can be modified outside the ORM (raw SQL/other jobs), updated_at won’t auto-update in that path—add a DB trigger if that’s a requirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/alembic_migrations/versions/0032_add_resume_imports.py` around lines
46 - 51, The migration sets updated_at with only server_default=sa.func.now()
while the model (resume_import.py) relies on SQLAlchemy's onupdate=func.now();
to cover non-ORM updates add a DB-side UPDATE mechanism: modify the migration
(0032_add_resume_imports.py) to create a trigger function that sets
NEW.updated_at = now() on row UPDATE and attach a trigger to the resume_imports
table in the upgrade() and drop it in downgrade(); keep the existing Column(...)
definition but add the trigger creation SQL (and trigger drop) so updated_at is
updated for raw SQL/other clients as well.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキップしました — このコードベースでは検出結果が無効です。

理由:

  1. データベースエンジンの不一致。データベースはTurso/libSQL(SQLite互換)であり、PostgreSQLではありません。提案された修正では、PL/pgSQLのトリガー関数構文(CREATE FUNCTION内のNEW.updated_at = now())が使用されていますが、libSQLはこの構文をサポートしていません。SQLiteのトリガーは異なる形式を使用しており、backend/alembic_migrations/versions/ のマイグレーションではトリガーは一切使用されていません。これは確立されたパターンからの例外的な変更となります。

  2. ORM以外のライターが存在しません。resume_imports / ResumeImportをgrepした結果、すべての書き込みパスがSQLAlchemy ORM(routers/resume_imports.py、
    services/tasks/handlers/resume_import.py、およびdb_session経由のテスト)を経由していることが分かりました。 ORMのみを使用するライターの場合、モデルのonupdate=func.now() (resume_import.py:57) は更新のたびに実行されます。

この発見で想定されている「生のSQL/その他のクライアント」は存在しません。

  1. 兄弟テーブルとの一貫性。blog.py、cache.py、resume.pyはすべて、データベース側のトリガーなしで、onupdate=func.now()のみを使用する同じパターンを使用しています。resume_importsにのみトリガーを追加すると、

実際の問題を解決することなく、一貫性のない規約を作成することになります。

コードの変更はありません。検証対象はありません。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusuke0610 ご説明ありがとうございます。3点の理由はいずれも正確で、私の指摘は誤りでした。Turso/libSQL(SQLite 互換)環境では PL/pgSQL のトリガー構文は使用できず、書き込みパスもすべて SQLAlchemy ORM 経由であるため onupdate=func.now() で十分に機能します。また、既存テーブルとの一貫性を保つ判断も理にかなっています。失礼しました。

(´・ω・`)


✏️ Learnings added
Learnt from: yusuke0610
Repo: yusuke0610/devforge PR: 265
File: backend/alembic_migrations/versions/0032_add_resume_imports.py:46-51
Timestamp: 2026-05-22T06:21:50.368Z
Learning: The database engine for this project (yusuke0610/devforge) is Turso/libSQL (SQLite-compatible), NOT PostgreSQL. PostgreSQL-specific features such as PL/pgSQL trigger functions (CREATE FUNCTION ... RETURNS TRIGGER) are not supported. All migration files under backend/alembic_migrations/versions/ use SQLite-compatible SQL only, and no DB-side triggers are used anywhere in the codebase. All write paths go through the SQLAlchemy ORM, so onupdate=func.now() in model definitions (e.g., backend/app/models/) is sufficient for auto-updating timestamp columns like updated_at. Sibling models (blog.py, cache.py, resume.py) all follow this same pattern without DB-side triggers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread backend/app/routers/resume_imports.py Outdated
Comment thread backend/app/routers/resume_imports.py
Comment on lines +165 to +168
parsed = json.loads(record.result_json)
return ResumeImportResultResponse(
result=ResumeBase(**parsed),
is_resume=bool(record.is_resume_flag),

@coderabbitai coderabbitai Bot May 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard result_json parsing to avoid uncontrolled 500s.

json.loads(record.result_json) assumes a valid non-null payload. If DB state is inconsistent, this throws and returns an internal error without domain-specific handling.

Proposed fix
-    parsed = json.loads(record.result_json)
+    try:
+        if not record.result_json:
+            raise ValueError("empty result_json")
+        parsed = json.loads(record.result_json)
+    except Exception:
+        raise_app_error(
+            status_code=409,
+            code=ErrorCode.VALIDATION_ERROR,
+            message="抽出結果がまだ利用可能ではありません。",
+            action="しばらく待ってから再試行してください",
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
parsed = json.loads(record.result_json)
return ResumeImportResultResponse(
result=ResumeBase(**parsed),
is_resume=bool(record.is_resume_flag),
try:
if not record.result_json:
raise ValueError("empty result_json")
parsed = json.loads(record.result_json)
except Exception:
raise_app_error(
status_code=409,
code=ErrorCode.VALIDATION_ERROR,
message="抽出結果がまだ利用可能ではありません。",
action="しばらく待ってから再試行してください",
)
return ResumeImportResultResponse(
result=ResumeBase(**parsed),
is_resume=bool(record.is_resume_flag),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/routers/resume_imports.py` around lines 165 - 168, Guard the JSON
parsing of record.result_json to avoid uncaught exceptions: instead of directly
calling parsed = json.loads(record.result_json), first check that
record.result_json is truthy and wrap json.loads in a try/except catching
json.JSONDecodeError (and Exception as fallback); on failure return a safe
domain response (e.g., ResumeImportResultResponse with result set to None or a
default ResumeBase placeholder) and preserve record.is_resume_flag, and
optionally log the error; update the code around ResumeImportResultResponse /
ResumeBase to accept the safe fallback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキップ — 検出結果が明示的なプロジェクトルールと強力なドメイン不変条件に矛盾するため。

理由:

  1. 不変条件:status=="completed" ⇒ 有効な result_json。status = "completed" を設定する唯一のライターは services/tasks/handlers/resume_import.py:87-94 であり、record.result_json =

json.dumps(structured, ensure_ascii=False) は record.status = "completed" と同じコミットで設定されています。有効な json.dumps 出力を書き込まずにレコードを completed に昇格させるパスは存在しません。

resume_imports.py:179 の if record.status != "completed" ガードは、他のすべての状態を 409 エラーで拒否しています。したがって、json.loads(record.result_json) は

ここで失敗するはずがありません。

  1. この防御策に対する明示的なプロジェクトルール。 .claude/CLAUDE.md(タスクの実行セクション):「発生し得ないシナリオに対して、エラー処理、フォールバック、または検証を追加しないでください。

内部コードとフレームワークの保証を信頼してください。検証はシステム境界(ユーザー入力、外部API)でのみ行ってください。」 record.result_json は、ステータスの反転と同じトランザクションで、独自のハンドラによって書き込まれる内部データであり、境界ではありません。

  1. 提案されている「安全なフォールバック」は事態を悪化させます。ResumeImportResultResponse.result は ResumeBase 型です(必須、schemas/resume_import.py:28)。result=None を返すと、

スキーマを ResumeBase | None に拡張する必要があり、すべてのフロントエンド呼び出し元が、契約上存在し得ない「完了したが結果がない」状態を処理することを強制されます。さらに悪いことに、

実際のデータ破損を、アラートをトリガーする 500 として顕在化させる代わりに、サイレント 200 で隠蔽することになります。

  1. 現在の動作は、失敗時に既に正しく動作しています。 result_jsonが何らかの理由で無効になった場合(データベースの破損、手動による介入など)、FastAPIは捕捉されなかったJSONDecodeErrorを500エラーに変換します。

これは望ましい動作です。不変条件違反を隠蔽するのではなく、表面化させるべきです。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusuke0610 ご説明ありがとうございます。すべての点に同意します。

  • 不変条件の保証result_jsonstatus="completed" が同一トランザクション内で書き込まれることは、コードから明確に確認できます。
  • プロジェクトルールの優先.claude/CLAUDE.md の「発生し得ないシナリオに対してエラー処理を追加しない」という方針は合理的であり、今回のケースに正確に該当します。
  • フォールバックの弊害result=None を許容するとスキーマ・フロントエンド双方に不要な複雑性が生じ、実際の不整合を隠蔽するリスクもあります。

この提案は取り下げます。

🐇


✏️ Learnings added
Learnt from: yusuke0610
Repo: yusuke0610/devforge PR: 265
File: backend/app/routers/resume_imports.py:165-168
Timestamp: 2026-05-22T11:50:08.476Z
Learning: In the `devforge` repository, the project rule (defined in `.claude/CLAUDE.md`) explicitly prohibits adding error handling, fallbacks, or validation for scenarios that cannot occur. Internal data written by the system's own handlers in the same DB transaction is trusted without defensive guards. Validation is only applied at system boundaries (user input, external APIs). Example: `json.loads(record.result_json)` in `backend/app/routers/resume_imports.py` is intentionally unguarded because `result_json` is always set valid in the same commit that sets `status="completed"`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

system_prompt = _load_prompt("judge_resume.md")
user_prompt = text[:_JUDGE_TEXT_MAX_CHARS]

raw = await llm_client.generate(system_prompt, user_prompt)

@coderabbitai coderabbitai Bot May 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider adding timeout to LLM calls.

The LLM generate() calls lack explicit timeout parameters. If the LLM service becomes unresponsive, these calls could block indefinitely, tying up worker resources.

While llm_client might have internal timeouts, making them explicit here would improve reliability and make the behavior more predictable.

⏱️ Example: Add timeout parameter
-raw = await llm_client.generate(system_prompt, user_prompt)
+raw = await llm_client.generate(system_prompt, user_prompt, timeout=30)

Note: Verify that LLMClient.generate() supports a timeout parameter. If not, consider wrapping the call with asyncio.wait_for():

import asyncio

try:
    raw = await asyncio.wait_for(
        llm_client.generate(system_prompt, user_prompt),
        timeout=30.0
    )
except asyncio.TimeoutError:
    raise RetryableError("LLM 呼び出しがタイムアウトしました")

Also applies to: 84-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/resume_import/llm_extractor.py` at line 53, The LLM
calls to llm_client.generate (invoked with system_prompt and user_prompt) need
explicit timeouts to avoid hanging; update both call sites (the generate at raw
= await llm_client.generate(system_prompt, user_prompt) and the similar call
around line 84) to either pass a supported timeout argument to
LLMClient.generate or wrap the await in asyncio.wait_for with a sensible timeout
(e.g., 30s), catch asyncio.TimeoutError and re-raise a RetryableError (or the
module's retryable exception) with a clear message so the worker can recover
instead of blocking indefinitely.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキップ — タイムアウトは既に適切なレイヤーで再試行可能なセマンティクスで実装されており、提案された30秒のラッパーは設定値と競合します。

理由:

  1. OllamaClientは既にタイムアウトを強制しています。ollama_client.py:34では、HTTP呼び出しをhttpx.AsyncClient(timeout=self.timeout)でラップしています。ここでself.timeoutはOLLAMA_TIMEOUT(デフォルト値は1200秒、

ollama_client.py:24で低速なローカルLLM向けに意図的に設定)です。httpx.TimeoutExceptionはollama_client.py:55-66で捕捉され、RetryableError("Ollama タイムアウト: ...")として再スローされます。

これはまさに、今回の検出で求められているリカバリパスです。

  1. VertexClientは既にタイムアウトを強制しています。 vertex_client.py:107-113 は、google-genai SDK から asyncio.TimeoutError / TimeoutError をキャッチし、RetryableError("Vertex AI

遅延: ...") として再発生させます。SDK はさらに gax.DeadlineExceeded を発生させますが、_classify_vertex_exception (vertex_client.py:164-177) はこれを RetryableError にマッピングします。

  1. 提案されている 30 秒のラッパーはこのドメインには不適切です。Ollama は gemma3:4b をローカルで実行していますが、コールドスタートと最初のトークン取得の遅延が 30 秒を超えることが頻繁に発生するため、環境のデフォルト値は 1200 秒になっています。

呼び出し箇所を asyncio.wait_for(..., timeout=30) でラップすると、正当な呼び出しが時期尚早に中断され、誤った RetryableError が発生し、リトライ予算が無駄に消費されます。 LLMClient.generate コントラクトは、呼び出しごとのタイムアウト パラメータを意図的に公開していません。タイムアウト ポリシーは、環境変数 (OLLAMA_TIMEOUT、Vertex SDK の deadline) を介してプロバイダごとに設定されます。

  1. 無限にハングアップするパスは存在しません。両方のプロバイダが接続/読み取り/プールのタイムアウトを処理します。ワーカー レイヤー (services/tasks/worker.py) も独自のタスク レベルのタイムアウト処理を備えています (.claude/rules/backend/test.md で参照されている tests/test_worker_timeout.py を参照)。llm_extractor 内に 3 番目のタイムアウト レイヤーを追加すると冗長になり、どのレイヤーがタイムアウトを発生させたのかが不明瞭になります。

コードの変更はありません。検証対象はありません。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusuke0610 理解しました。ご説明ありがとうございます。

確認させてください:

  • OllamaClienthttpx.AsyncClient(timeout=self.timeout)OLLAMA_TIMEOUT(デフォルト 1200 秒)を適用し、httpx.TimeoutExceptionRetryableError に変換済み。
  • VertexClientasyncio.TimeoutError / gax.DeadlineExceededRetryableError にマッピング済み。
  • ワーカー層にも独自のタスクレベルタイムアウトが存在する。

これらを踏まえると、llm_extractor.py 内に 30 秒ラッパーを追加することは冗長かつ有害(ローカル LLM のコールドスタートを誤って中断する)であることは明らかです。コメントは取り下げます。


✏️ Learnings added
Learnt from: yusuke0610
Repo: yusuke0610/devforge PR: 265
File: backend/app/services/resume_import/llm_extractor.py:53-53
Timestamp: 2026-05-22T11:51:44.629Z
Learning: In yusuke0610/devforge, LLM timeout handling is implemented at the provider layer, not at the call site in llm_extractor.py. OllamaClient (backend/app/services/intelligence/llm/ollama_client.py) wraps HTTP calls with httpx.AsyncClient(timeout=self.timeout) where self.timeout=OLLAMA_TIMEOUT (default 1200s for slow local LLMs), and converts httpx.TimeoutException to RetryableError. VertexClient (backend/app/services/intelligence/llm/vertex_client.py) catches asyncio.TimeoutError and gax.DeadlineExceeded and maps them to RetryableError. The worker layer (services/tasks/worker.py) also has task-level timeout handling. Do NOT suggest adding asyncio.wait_for or per-call timeout wrappers in llm_extractor.py or any LLMClient call site — this would conflict with intentional provider-level timeout configuration and cause premature cancellation of legitimate slow LLM calls.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: yusuke0610
Repo: yusuke0610/devforge PR: 265
File: backend/app/routers/resume_imports.py:165-168
Timestamp: 2026-05-22T11:50:08.476Z
Learning: Follow the devforge project rule in `.claude/CLAUDE.md`: during code review, do NOT ask for extra error handling/fallbacks/defensive validation for states that are guaranteed not to occur by system invariants. Treat data written by the system’s own handlers within the same DB transaction (and tied to the same commit/invariant—e.g., fields associated with `status="completed"`) as trusted. Apply validation only at system boundaries (user input and external APIs) or when data can change across transaction boundaries / originate outside the trusted handler path. Example: keep `json.loads(record.result_json)` unguarded in `backend/app/routers/resume_imports.py` when `result_json` is guaranteed valid by the same handler that sets the corresponding completion status.

Comment thread backend/requirements.txt Outdated
Comment thread frontend/src/components/forms/CareerResumeForm.tsx Outdated
Comment thread frontend/src/formMappers.ts Outdated
Comment thread frontend/src/formMappers.ts Outdated
Comment on lines +94 to +97
const { import_id } = await startResumeImport(file);
setImportId(import_id);
setPhase("polling");
startPolling();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix race condition: importId state may be stale when polling starts.

The setImportId(import_id) call at line 95 is asynchronous and doesn't immediately update the state. When startPolling() is called at line 97, the checkStatus callback (line 35-38) reads the importId state, which is likely still null because React hasn't re-rendered yet. This causes the "import_id が未設定です" error.

Use a ref instead of state for importId to avoid closure/stale-state issues:

🔧 Proposed fix using useRef
+import { useCallback, useRef, useState } from "react";
 
 export function useResumeImport(): UseResumeImportReturn {
   const [phase, setPhase] = useState<ResumeImportPhase>("idle");
   const [parsedData, setParsedData] = useState<ResumeImportResultResponse | null>(null);
   const [error, setError] = useState<AppErrorState | null>(null);
-  const [importId, setImportId] = useState<string | null>(null);
+  const importIdRef = useRef<string | null>(null);
 
   const { startPolling } = useTaskPolling({
     checkStatus: async () => {
-      if (!importId) throw new Error("import_id が未設定です");
-      return getResumeImportStatus(importId);
+      if (!importIdRef.current) throw new Error("import_id が未設定です");
+      return getResumeImportStatus(importIdRef.current);
     },
     onCompleted: async () => {
-      if (!importId) return;
+      if (!importIdRef.current) return;
       try {
-        const result = await getResumeImportResult(importId);
+        const result = await getResumeImportResult(importIdRef.current);
         setParsedData(result);
         // ...

Update the start function:

       try {
         const { import_id } = await startResumeImport(file);
-        setImportId(import_id);
+        importIdRef.current = import_id;
         setPhase("polling");
         startPolling();

Update the reset function:

   const reset = useCallback(() => {
     setPhase("idle");
     setParsedData(null);
     setError(null);
-    setImportId(null);
+    importIdRef.current = null;
   }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/career/useResumeImport.ts` around lines 94 - 97, The
polling race is caused by setImportId being async so checkStatus (used by
startPolling) reads a stale importId; replace the importId state with a ref
(e.g., importIdRef) and update importIdRef.current = import_id inside the
start/resume logic (the code around startResumeImport and start functions), then
have checkStatus/readers reference importIdRef.current instead of the importId
state; also update reset to clear importIdRef.current and remove or keep a
minimal state-only representation for UI if needed so startPolling always sees
the current import id.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
frontend/src/hooks/career/useResumeImport.ts (1)

41-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix stale importId read before polling starts.

Line 100 updates state asynchronously, but Line 102 can start polling before checkStatus sees the new value, so Line 41 may throw on first poll.

Proposed fix
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
@@
-  const [importId, setImportId] = useState<string | null>(null);
+  const importIdRef = useRef<string | null>(null);
@@
     checkStatus: async () => {
-      if (!importId) throw new Error(INTERNAL_MESSAGES.RESUME_IMPORT_NO_ID);
-      return getResumeImportStatus(importId);
+      if (!importIdRef.current) throw new Error(INTERNAL_MESSAGES.RESUME_IMPORT_NO_ID);
+      return getResumeImportStatus(importIdRef.current);
     },
     onCompleted: async () => {
-      if (!importId) return;
+      if (!importIdRef.current) return;
       try {
-        const result = await getResumeImportResult(importId);
+        const result = await getResumeImportResult(importIdRef.current);
@@
-        setImportId(import_id);
+        importIdRef.current = import_id;
         setPhase("polling");
         startPolling();
@@
-    setImportId(null);
+    importIdRef.current = null;

Also applies to: 99-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/career/useResumeImport.ts` around lines 41 - 42, The
polling routine reads importId from closure which may be stale because the state
update at line ~100 is async, causing getResumeImportStatus(importId) to throw;
fix by ensuring polling uses the committed importId value instead of the
closed-over state—either capture the new importId and pass it explicitly into
checkStatus/getResumeImportStatus when starting the poll (e.g., change
checkStatus to accept an importId parameter and call
getResumeImportStatus(passedImportId)), or delay starting the poll until the
stateful importId is set (e.g., gate the poll start on importId truthiness in
the effect). Update references to importId in useResumeImport.ts and adjust
checkStatus/getResumeImportStatus calls accordingly.
🧹 Nitpick comments (1)
frontend/eslint.config.js (1)

46-46: ⚡ Quick win

ESLint exemption glob excludes *.spec.tsx, but the repo has none
No *.spec.tsx files exist in this repo (search under frontend/src and globally returned none), so the omission won’t currently cause unexpected linting.

Optional future-proof patch
-    files: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/test/**/*.{ts,tsx}"],
+    files: [
+      "src/**/*.test.ts",
+      "src/**/*.test.tsx",
+      "src/**/*.spec.ts",
+      "src/**/*.spec.tsx",
+      "src/test/**/*.{ts,tsx}",
+    ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/eslint.config.js` at line 46, The ESLint config's files array
currently lists "src/**/*.spec.ts" but omits spec files with .tsx extension;
update the files array in eslint.config.js (the files property) to include
.spec.tsx — either add "src/**/*.spec.tsx" or replace "src/**/*.spec.ts" with a
combined pattern like "src/**/*.spec.{ts,tsx}" (or adjust
"src/test/**/*.{ts,tsx}") so .spec.tsx files are covered by ESLint.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/lint-frontend-messages.sh`:
- Around line 14-16: The comment above the PATTERN variable is inaccurate: it
says "シングルクォート文字列とバッククォート文字列" but the regex character class ["`] matches
double-quote and backtick, not single-quote; either change the comment to
"ダブルクォート文字列とバッククォート文字列" to match the existing PATTERN or modify the PATTERN from
["`] to ['\`] so it actually matches single-quote and backtick; keep the PATTERN
identifier and the Unicode property usage (\p{Hiragana}\p{Katakana}\p{Han})
unchanged and note that ShellCheck SC2016 is a false positive for these PCRE2
properties.

---

Duplicate comments:
In `@frontend/src/hooks/career/useResumeImport.ts`:
- Around line 41-42: The polling routine reads importId from closure which may
be stale because the state update at line ~100 is async, causing
getResumeImportStatus(importId) to throw; fix by ensuring polling uses the
committed importId value instead of the closed-over state—either capture the new
importId and pass it explicitly into checkStatus/getResumeImportStatus when
starting the poll (e.g., change checkStatus to accept an importId parameter and
call getResumeImportStatus(passedImportId)), or delay starting the poll until
the stateful importId is set (e.g., gate the poll start on importId truthiness
in the effect). Update references to importId in useResumeImport.ts and adjust
checkStatus/getResumeImportStatus calls accordingly.

---

Nitpick comments:
In `@frontend/eslint.config.js`:
- Line 46: The ESLint config's files array currently lists "src/**/*.spec.ts"
but omits spec files with .tsx extension; update the files array in
eslint.config.js (the files property) to include .spec.tsx — either add
"src/**/*.spec.tsx" or replace "src/**/*.spec.ts" with a combined pattern like
"src/**/*.spec.{ts,tsx}" (or adjust "src/test/**/*.{ts,tsx}") so .spec.tsx files
are covered by ESLint.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: baa98c7a-0297-476a-9c22-5522448ef2ed

📥 Commits

Reviewing files that changed from the base of the PR and between 5f2c74b and d1e2a49.

📒 Files selected for processing (25)
  • .claude/CLAUDE.md
  • .claude/rules/frontend/messages.md
  • Makefile
  • backend/app/routers/resume_imports.py
  • backend/app/services/resume_import/llm_extractor.py
  • backend/app/services/resume_import/pdf_extractor.py
  • backend/app/services/resume_import/prompts/extract_resume.md
  • backend/app/services/tasks/handlers/resume_import.py
  • backend/requirements.txt
  • frontend/eslint.config.js
  • frontend/src/api/auth.ts
  • frontend/src/api/client.ts
  • frontend/src/api/download.ts
  • frontend/src/components/ErrorBoundary.tsx
  • frontend/src/components/forms/CareerResumeForm.tsx
  • frontend/src/constants/messages.ts
  • frontend/src/formMappers.ts
  • frontend/src/hooks/blog/useBlogAccountManager.ts
  • frontend/src/hooks/blog/useBlogSummaryPolling.ts
  • frontend/src/hooks/career/useCareerAnalysisPage.ts
  • frontend/src/hooks/career/useResumeImport.ts
  • frontend/src/hooks/useDocumentForm.ts
  • frontend/src/hooks/usePdfActions.ts
  • frontend/src/payloadBuilders.ts
  • scripts/lint-frontend-messages.sh
✅ Files skipped from review due to trivial changes (3)
  • frontend/src/api/download.ts
  • .claude/CLAUDE.md
  • backend/app/services/resume_import/prompts/extract_resume.md

Comment thread scripts/lint-frontend-messages.sh
@yusuke0610

Copy link
Copy Markdown
Owner Author

frontend: エラーメッセージのハードコード除去と再発防止機構の追加

Summary

  • ts/tsx 内でユーザー向け日本語リテラルを直書きしていた 38 件(バリデーション・catch fallback・JSX・ネットワーク層など)を、新規 SSoT ファイル frontend/src/constants/messages.ts に集約して定数参照へ置換。
  • ハイブリッド SSoT 設計を明確化: API 経由のエラーは backend (messages.jsonAppErrorResponse.message)frontend 完結のメッセージは constants/messages.ts を正本とする。
  • 再発防止として 4 層の検知機構を追加: ESLint no-restricted-syntax / Makefile + shell script の grep チェック / AI エージェント向け運用ルール (.claude/rules/frontend/messages.md) / 共通規約 (CLAUDE.md) への追記。

Context

調査でユーザー認識と実態にズレが判明した:

  • ユーザー認識: 「backend/app/messages.json がエラーメッセージの SSoT」
  • 実態: messages.json は backend のみで参照され、frontend は一切参照していなかった。frontend は独自に constants/errorMessages.tsERROR_CONFIG を持ち、それすら通らない直書きリテラルが 38 件残存していた。

このまま放置すると、ユーザー向け文言の修正・i18n 化・レビューの抜けが発生しやすい。設計判断として「messages.json を build-time 生成して frontend に流す」案は投資対効果が低いと判断し、ハイブリッド SSoT に整理した上で、ハードコード復活を機械的に防ぐ仕組みを入れる方針を取った。

変更点

Frontend(メッセージ集約)

新規ファイル: frontend/src/constants/messages.ts

frontend 完結のメッセージを論理カテゴリで分けた readonly 定数として集約:

定数名 用途 件数
VALIDATION_MESSAGES フォーム事前バリデーション 9
NETWORK_MESSAGES API クライアント層の fallback 4
FALLBACK_MESSAGES catch ブロック / toAppError fallback 22
UI_MESSAGES JSX 直書き文言(ErrorBoundary 等) 2
INTERNAL_MESSAGES 開発者向け内部エラー 1
downloadFailureMessage(filename) 動的パラメータが必要な関数版 -

置換ファイル(38 件 → 0 件)

ファイル 件数 主な置換先
payloadBuilders.ts 8 VALIDATION_MESSAGES.*
api/client.ts 4 NETWORK_MESSAGES.*
api/download.ts 2 FALLBACK_MESSAGES.DOWNLOAD / .PREVIEW_FETCH / downloadFailureMessage()
api/auth.ts 2 FALLBACK_MESSAGES.AUTH_CHECK / .GITHUB_OAUTH_START
hooks/usePdfActions.ts 3 FALLBACK_MESSAGES.PDF_DOWNLOAD / .MARKDOWN_DOWNLOAD / .PREVIEW
hooks/useDocumentForm.ts 2 FALLBACK_MESSAGES.SAVE / .DELETE
hooks/career/useResumeImport.ts 4 VALIDATION_MESSAGES.RESUME_PDF_* / FALLBACK_MESSAGES.RESUME_EXTRACT / INTERNAL_MESSAGES.RESUME_IMPORT_NO_ID
hooks/career/useCareerAnalysisPage.ts 3 FALLBACK_MESSAGES.ANALYSIS*
hooks/blog/useBlogAccountManager.ts 6 FALLBACK_MESSAGES.BLOG_*
hooks/blog/useBlogSummaryPolling.ts 3 FALLBACK_MESSAGES.BLOG_SUMMARY_*(grep 検知で追加発見)
components/ErrorBoundary.tsx 2 UI_MESSAGES.ERROR_BOUNDARY_*
合計 38

対象外:

  • pages/GitHubCallbackPage.tsx の URL クエリ (invalid_callback 等) はエラーコードでありメッセージではない
  • hooks/useAuthSession.ts:115console.warn は UI に出ない開発者ログ

再発防止機構(4 層)

1. ESLint: no-restricted-syntax

frontend/eslint.config.js に追加。Unicode プロパティで日本語(ひらがな・カタカナ・漢字)を含むリテラルのみを抑止し、英語の開発者向けエラーは許容する。

"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 にテンプレートリテラルで日本語を直接書かない。...",
  },
],

テストファイル(*.test.ts, *.spec.ts, src/test/**)はフィクスチャ throw を許容するため override で off にしている。

2. Makefile + shell script: make lint-frontend-messages

ESLint AST では拾いきれない関数呼び出し系(setError("...") / setSummaryError("...") / toast.error("...") / alert("..."))を scripts/lint-frontend-messages.sh の ripgrep \p{Hiragana}\p{Katakana}\p{Han} パターンで補完。make lint ターゲットに組み込まれているため CI で自動的に走る。

3. AI エージェント向け運用ルール: .claude/rules/frontend/messages.md

frontend を編集するときに自動ロードされる場所に、SSoT の責務分離・新規メッセージ追加手順・やってはいけないこと・正しい書き方を明文化。

4. 共通規約への追記: .claude/CLAUDE.md

「コーディング規約(共通)」セクションに「エラーメッセージのハードコード禁止」ルールを追記し、詳細リンクを .claude/rules/frontend/messages.md に集約。

Test plan

  • make lint-frontend — ESLint pass(新ルール含む)
  • make lint-frontend-messages — grep 検知 pass(残存 0 件)
  • make build-frontend — tsc + vite ビルド成功
  • make test-frontend — node:test 4 件 + vitest 148 件すべて pass
  • ESLint ルールの自己検証: 一時的に違反コード (throw new Error("これはテスト用の違反です")) を入れて make lint-frontend が error で fail することを確認 → クリーンアップ済み
  • grep 検知の自己検証: 初回実行で useBlogSummaryPolling.ts の漏れ 3 件を検知 → 追加修正済み(仕組みが意図通り効いている証拠)

動作確認の推奨手順(レビュアー向け)

ブラウザでの目視確認はレビュー時に以下を推奨:

  • 職務経歴フォームの「氏名未入力で保存」→ VALIDATION_MESSAGES.FULL_NAME_REQUIRED が画面に出ること
  • 開発サーバ停止状態で API を呼ぶ → NETWORK_MESSAGES.CONNECTION_FAILED が ErrorToast に出ること
  • ErrorBoundary を強制発火 → UI_MESSAGES.ERROR_BOUNDARY_* が出ること

E2E トリガー条件(新規ページ・認証フロー・レイアウト変更)には該当しないため、npm run test:e2e は変更内容に対しては影響なし(既存 E2E が通れば OK)。

影響範囲

新規:

  • frontend/src/constants/messages.ts
  • scripts/lint-frontend-messages.sh
  • .claude/rules/frontend/messages.md

編集:

  • frontend/src/payloadBuilders.ts
  • frontend/src/api/client.ts download.ts auth.ts
  • frontend/src/hooks/usePdfActions.ts useDocumentForm.ts
  • frontend/src/hooks/career/useResumeImport.ts useCareerAnalysisPage.ts
  • frontend/src/hooks/blog/useBlogAccountManager.ts useBlogSummaryPolling.ts
  • frontend/src/components/ErrorBoundary.tsx
  • frontend/eslint.config.js
  • Makefile
  • .claude/CLAUDE.md

変更行数: 約 +250 / -90(うち新規定数ファイルが約 75 行、ルール docs が約 90 行、置換は 38 箇所)

やらないこと(スコープ外)

  • messages.json から TS 定数を build-time 生成するパイプライン(既存 ERROR_CONFIG が手動同期で機能しているため投資対効果が低い)
  • i18n(i18next 等)の導入(現状すべて日本語のため不要)
  • GitHubCallbackPage.tsx の OAuth エラーコード定数化(コードであってメッセージではない)
  • backend 側の messages.py / messages.json 構造の改修
  • ErrorCode enum と messages.json の同期チェック CI(別タスク)

関連

  • 詳細設計プラン: /Users/wadayuusuke/.claude/plans/ts-tsx-users-wadayuusuke-documents-dev-d-warm-squid.md
  • 既存の関連ファイル: frontend/src/constants/errorCodes.ts errorMessages.ts / backend/app/core/errors.py messages.json

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 新機能

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant