From 1a555a8a014e90019e5b09c19b2081732027ab86 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 25 Jul 2026 16:03:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=E6=89=8B=E6=8C=81=E3=81=A1=20?= =?UTF-8?q?PDF=20=E7=B5=8C=E6=AD=B4=E6=9B=B8=E3=81=AE=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=83=A0=E6=B5=81=E3=81=97=E8=BE=BC=E3=81=BF=20UI=20?= =?UTF-8?q?=E3=81=A8=E6=B3=A8=E5=85=A5=E6=A9=9F=E6=A7=8B=EF=BC=88ADR-0024?= =?UTF-8?q?=20/=20#524=E3=83=BB#528=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #527 の抽出エンドポイントを使い、空フォームの新規ユーザが PDF から入力を自動作成 できる導線を追加。抽出結果はフォーム state に注入するだけで DB は更新しない(ADR-0010)。 #524 注入機構: - utils/resumeImport.ts: ResumeImportResponse → CareerFormState の決定論変換(TDD)。 非空の抽出値のみ上書き(欠落は既存値保持)、experiences はフラット値から構築、 email/github_url/qualifications は現状維持 #528 web: - hooks/useResumeImport.ts: アップロード → 抽出の状態管理(TDD: loading/success/error) - components/forms/ResumeImportPanel.tsx: ドラッグ&ドロップ/選択・ローディング・ 入力途中データがある場合の上書き確認ダイアログ・成功トースト - CareerResumeForm: 空フォーム時のみフォーム上部にパネルを表示 - api/agent.ts importResumePdf + api/client.ts の FormData(multipart)対応 - messages.ts に RESUME_IMPORT_MESSAGES / FALLBACK 追加 その他: - #523 の撤去漏れ(stryker mutate の削除済み agentModels.ts)を掃除 - E2E: 空フォーム→アップロード→反映 / スキャン PDF エラー Co-Authored-By: Claude Opus 4.8 --- web/e2e/resume-import.spec.ts | 107 ++++++++++++ web/src/api/agent.ts | 15 ++ web/src/api/client.ts | 6 +- web/src/api/paths.ts | 1 + web/src/api/types.ts | 3 + web/src/components/forms/CareerResumeForm.tsx | 19 +++ .../forms/ResumeImportPanel.module.css | 74 +++++++++ .../components/forms/ResumeImportPanel.tsx | 155 ++++++++++++++++++ web/src/constants/messages.ts | 24 +++ web/src/hooks/useResumeImport.test.ts | 80 +++++++++ web/src/hooks/useResumeImport.ts | 39 +++++ web/src/utils/resumeImport.test.ts | 111 +++++++++++++ web/src/utils/resumeImport.ts | 58 +++++++ web/stryker.conf.json | 1 - 14 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 web/e2e/resume-import.spec.ts create mode 100644 web/src/components/forms/ResumeImportPanel.module.css create mode 100644 web/src/components/forms/ResumeImportPanel.tsx create mode 100644 web/src/hooks/useResumeImport.test.ts create mode 100644 web/src/hooks/useResumeImport.ts create mode 100644 web/src/utils/resumeImport.test.ts create mode 100644 web/src/utils/resumeImport.ts diff --git a/web/e2e/resume-import.spec.ts b/web/e2e/resume-import.spec.ts new file mode 100644 index 00000000..a651984f --- /dev/null +++ b/web/e2e/resume-import.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { RESUME_IMPORT_MESSAGES } from "../src/constants/messages"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * 手持ち PDF 経歴書のフォーム流し込み(ADR-0024 / #528)E2E。 + * + * シナリオ: + * 1. 空フォームの新規ユーザは「PDF から自動入力」パネルを見る + * 2. PDF をアップロード(POST /api/agent/resume-import/pdf をモック) + * 3. 抽出結果がフォーム(氏名・職務要約・自己PR)に反映される(DB 非更新) + * 4. 反映成功のトーストが出る + */ + +/** 職務経歴書の最小 API モック。latest は 404 = 空フォーム(パネルが出る条件)。 */ +async function setupEmptyResume(page: Page) { + 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: 404, + contentType: "application/json", + body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }), + }), + ); +} + +test.describe("PDF 経歴書インポート", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupEmptyResume(page); + }); + + test("空フォームで PDF をアップロードすると抽出結果が反映される", async ({ page }) => { + await page.route("**/api/agent/resume-import/pdf", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + full_name: "山田 太郎", + career_summary: "バックエンドエンジニアとして 5 年の経験があります。", + self_pr: "品質と保守性を重視した設計が得意です。", + experiences: [ + { + company: "株式会社サンプル", + business_description: "受託開発", + start_date: "2020-04", + end_date: "2023-03", + description: "API 開発を担当。", + }, + ], + }), + }); + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 空フォームの新規ユーザにパネルが見える + await expect(page.getByText(RESUME_IMPORT_MESSAGES.HEADING)).toBeVisible(); + + // 隠しファイル input に PDF を投入する(マジックバイト付きのダミー) + await page + .getByLabel(RESUME_IMPORT_MESSAGES.UPLOAD_LABEL) + .setInputFiles({ + name: "resume.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.7\n...dummy..."), + }); + + // 抽出結果がフォームへ反映される(空フォームなので確認ダイアログは出ない) + await expect(page.getByText(RESUME_IMPORT_MESSAGES.APPLIED_TOAST)).toBeVisible(); + // 氏名入力に抽出値が入る + await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田 太郎"); + }); + + test("非対応 PDF(スキャン)はエラーメッセージを表示する", async ({ page }) => { + await page.route("**/api/agent/resume-import/pdf", async (route) => { + await route.fulfill({ + status: 422, + contentType: "application/json", + body: JSON.stringify({ + code: "VALIDATION_ERROR", + message: "テキストを含む PDF のみ対応しています。スキャン画像の PDF は読み取れないため、お手数ですが手入力をお願いします。", + }), + }); + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + await page + .getByLabel(RESUME_IMPORT_MESSAGES.UPLOAD_LABEL) + .setInputFiles({ + name: "scan.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.7\n...scan..."), + }); + + await expect(page.getByText(/テキストを含む PDF のみ対応/)).toBeVisible(); + }); +}); diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index 4ab009cd..a3204535 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -5,6 +5,7 @@ import { PATHS } from "./paths"; import type { AgentChatRequest, AgentChatResponse, + ResumeImportResponse, TaskAcceptedResponse, TaskStatusResponse, } from "./types"; @@ -51,3 +52,17 @@ export function fetchResumeDraftPdfBlobUrl(): Promise { FALLBACK_MESSAGES.RESUME_DRAFT, ); } + +/** + * 手持ちの PDF 経歴書をアップロードして構造化抽出する(ADR-0024 / #527)。 + * テキスト埋め込み PDF のみ対応。DB は更新されず、抽出結果(Resume 互換 payload)を返す。 + * 失敗時は ApiError(422 = 非対応/破損 PDF・サイズ超過 / 502 = 抽出失敗 / 429 = レート上限)を送出する。 + */ +export function importResumePdf(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return request(PATHS.agent.resumeImportPdf, { + method: "POST", + body: formData, + }); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 71799572..c0d57656 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -133,8 +133,12 @@ export async function request( _isRetry = false, ): Promise { const method = (options.method ?? "GET").toUpperCase(); + // FormData(multipart アップロード)のときは Content-Type を指定しない。 + // ブラウザが boundary 付きの multipart/form-data を自動設定するため、明示すると壊れる。 + const isFormData = + typeof FormData !== "undefined" && options.body instanceof FormData; const headers: Record = { - "Content-Type": "application/json", + ...(isFormData ? {} : { "Content-Type": "application/json" }), ...((options.headers as Record) ?? {}), }; diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts index 9aa17c7e..85df26ae 100644 --- a/web/src/api/paths.ts +++ b/web/src/api/paths.ts @@ -32,6 +32,7 @@ export const PATHS = { resumeDraftRun: "/api/agent/resume-draft/run", resumeDraftStatus: "/api/agent/resume-draft/status", resumeDraftPdf: "/api/agent/resume-draft/pdf", + resumeImportPdf: "/api/agent/resume-import/pdf", }, resumes: { base: "/api/resumes", diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 06391c7a..cb29783b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -145,6 +145,9 @@ export type AgentChatRequest = Schemas["AgentChatRequest"]; /** Agent チャットのレスポンス。backend `schemas/agent.py:AgentChatResponse`。 */ export type AgentChatResponse = Schemas["AgentChatResponse"]; +/** PDF 経歴書の抽出結果。backend `schemas/agent.py:ResumeImportResponse`(ADR-0024)。 */ +export type ResumeImportResponse = Schemas["ResumeImportResponse"]; + /** resume state へ適用する差分。backend `schemas/agent.py:AgentOperation`。 */ export type AgentOperation = Schemas["AgentOperation"]; diff --git a/web/src/components/forms/CareerResumeForm.tsx b/web/src/components/forms/CareerResumeForm.tsx index 8dc69b45..b32fceed 100644 --- a/web/src/components/forms/CareerResumeForm.tsx +++ b/web/src/components/forms/CareerResumeForm.tsx @@ -22,6 +22,7 @@ import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterDat import { useCareerExportActions } from "../../hooks/career/useCareerExportActions"; import { useMessageToast } from "../ui/toast"; import { AgentChatWidget } from "./AgentChatWidget"; +import { ResumeImportPanel } from "./ResumeImportPanel"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; import { useLoginPrompt } from "../auth/loginPromptContext"; @@ -125,6 +126,14 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ const dirty = useCareerDirty(form, baseline); + // 空フォーム(新規ユーザ)のときだけ PDF 自動入力の導線を出す(ADR-0024 / #528)。 + // 入力・抽出後は内容が入るため自然に消える。既存経歴書のユーザには出さない。 + const isFormEmpty = + !form.full_name.trim() && + !form.career_summary.trim() && + !form.self_pr.trim() && + !form.experiences.some((e) => e.company.trim() || e.description.trim()); + /** Skeleton 表示・入力ロックの統合フラグ */ const formLocked = loading; @@ -235,6 +244,16 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
{validationError &&

{validationError}

} + {/* 空フォームの新規ユーザ向け: 手持ち PDF から自動入力(ADR-0024 / #528) */} + {isFormEmpty && ( + + )} + {/* 基本情報: 氏名・連絡先・職務要約 */} CareerFormState) => void; + /** 未ログイン時はアップロードせずログイン導線へ流す。 */ + isAuthenticated: boolean; + requestLogin: () => void; +}; + +/** フォームに意味のある入力があるか(上書き確認の要否判定)。 */ +function hasMeaningfulInput(form: CareerFormState): boolean { + return Boolean( + form.full_name.trim() || + form.career_summary.trim() || + form.self_pr.trim() || + form.experiences.some((e) => e.company.trim() || e.description.trim()), + ); +} + +/** + * 手持ち PDF 経歴書のアップロード → 抽出 → フォーム反映の導線(ADR-0024 / #528)。 + * + * 空フォームの新規ユーザ向けにフォーム上部へ置く。抽出結果はフォーム state に注入するだけで + * DB は更新しない(ADR-0010)。入力途中データがある場合は上書き確認ダイアログを挟む。 + */ +export function ResumeImportPanel({ form, onApply, isAuthenticated, requestLogin }: Props) { + const { importing, error, importPdf, clearError } = useResumeImport(); + const { showSuccess, showError } = useToast(); + useAppErrorToast(error); + const inputRef = useRef(null); + // 上書き確認待ちの抽出結果(入力途中データがあるときだけセットする) + const [pending, setPending] = useState(null); + const [dragOver, setDragOver] = useState(false); + + const applyPayload = (payload: ResumeImportResponse) => { + onApply((prev) => applyResumeImportToForm(prev, payload)); + showSuccess(RESUME_IMPORT_MESSAGES.APPLIED_TOAST); + }; + + const handleFile = async (file: File) => { + clearError(); + const isPdf = + file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + if (!isPdf) { + showError(RESUME_IMPORT_MESSAGES.INVALID_FILE); + return; + } + const payload = await importPdf(file); + if (!payload) return; // 失敗時は useMessageToast がエラーを表示済み + if (hasMeaningfulInput(form)) { + setPending(payload); + } else { + applyPayload(payload); + } + }; + + const openFileDialog = () => { + if (!isAuthenticated) { + requestLogin(); + return; + } + inputRef.current?.click(); + }; + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (importing) return; + if (!isAuthenticated) { + requestLogin(); + return; + } + const file = e.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }; + + return ( +
+
+

{RESUME_IMPORT_MESSAGES.HEADING}

+
+

{RESUME_IMPORT_MESSAGES.HINT}

+ +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + role="button" + tabIndex={0} + aria-disabled={importing} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openFileDialog(); + } + }} + > + {importing ? ( + + + {RESUME_IMPORT_MESSAGES.IMPORTING} + + ) : ( + <> + {RESUME_IMPORT_MESSAGES.DROP_HINT} + {RESUME_IMPORT_MESSAGES.UPLOAD_LABEL} + + )} +
+ + { + const file = e.target.files?.[0]; + // 同じファイルを連続選択しても onChange が発火するよう value をリセットする + e.target.value = ""; + if (file) void handleFile(file); + }} + /> + + {pending && ( + { + applyPayload(pending); + setPending(null); + }} + onCancel={() => setPending(null)} + /> + )} +
+ ); +} diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts index fc334916..e452b5d9 100644 --- a/web/src/constants/messages.ts +++ b/web/src/constants/messages.ts @@ -62,6 +62,7 @@ export const FALLBACK_MESSAGES = { GITHUB_LINK: "連携に失敗しました", AGENT_CHAT: "AI への送信に失敗しました", RESUME_DRAFT: "経歴書ドラフトの生成に失敗しました", + RESUME_IMPORT: "PDF の読み取りに失敗しました", SKILL_FETCH: "スキルの取得に失敗しました", SKILL_DISPLAY_PROPOSE: "表示名の提案に失敗しました", SKILL_DISPLAY_CONFIRM: "表示名の確定に失敗しました", @@ -198,6 +199,29 @@ export const RESUME_DRAFT_MESSAGES = { NOT_SAVED_NOTE: "生成した内容は職務経歴書として保存されません。必要な部分は職務経歴書フォームへ転記してください。", } as const; +/** 手持ち PDF 経歴書のフォーム流し込み(ADR-0024)の UI 文言。 */ +export const RESUME_IMPORT_MESSAGES = { + /** パネル見出し。 */ + HEADING: "PDF から自動入力", + /** 機能説明。 */ + HINT: "手持ちの職務経歴書 PDF を読み込むと、氏名・職務要約・自己PR・職歴をフォームに反映します。内容を確認して保存してください(保存するまで DB は更新されません)。テキストを含む PDF のみ対応です。", + /** アップロード導線のラベル。 */ + UPLOAD_LABEL: "PDF を選択", + /** ドラッグ&ドロップ領域のヒント。 */ + DROP_HINT: "PDF をここにドラッグ&ドロップ、またはクリックして選択", + /** アップロード/抽出中のラベル。 */ + IMPORTING: "PDF を読み込み中...", + /** クライアント側の拡張子事前チェック。 */ + INVALID_FILE: "PDF ファイルを選択してください。", + /** 上書き確認(入力途中データがある場合)。 */ + OVERWRITE_CONFIRM: + "現在の入力内容の一部を、PDF から読み取った内容で上書きします。よろしいですか?(空欄は現在の値を保持します)", + /** 上書き確認の実行ボタン。 */ + OVERWRITE_CONFIRM_LABEL: "上書きして反映", + /** 反映成功のトースト。 */ + APPLIED_TOAST: "PDF の内容をフォームに反映しました。内容を確認して保存してください。", +} as const; + /** スキル表示名の human-in-the-loop 確定(ADR-0016 D11)の UI 文言。 */ export const SKILL_DISPLAY_MESSAGES = { /** セクション見出し。 */ diff --git a/web/src/hooks/useResumeImport.test.ts b/web/src/hooks/useResumeImport.test.ts new file mode 100644 index 00000000..d0194d9f --- /dev/null +++ b/web/src/hooks/useResumeImport.test.ts @@ -0,0 +1,80 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "../api"; +import { useResumeImport } from "./useResumeImport"; + +vi.mock("../api/agent", () => ({ + importResumePdf: vi.fn(), +})); + +import { importResumePdf } from "../api/agent"; + +const mockImport = vi.mocked(importResumePdf); + +function _pdfFile(): File { + return new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { + type: "application/pdf", + }); +} + +describe("useResumeImport", () => { + beforeEach(() => { + mockImport.mockReset(); + }); + + it("初期状態は importing=false・error=null", () => { + const { result } = renderHook(() => useResumeImport()); + expect(result.current.importing).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("成功時は抽出 payload を返し error は立たない", async () => { + const payload = { + full_name: "山田 太郎", + career_summary: "要約", + self_pr: "PR", + experiences: [], + }; + mockImport.mockResolvedValueOnce(payload); + + const { result } = renderHook(() => useResumeImport()); + let returned: unknown; + await act(async () => { + returned = await result.current.importPdf(_pdfFile()); + }); + + expect(returned).toEqual(payload); + expect(result.current.error).toBeNull(); + expect(result.current.importing).toBe(false); + expect(mockImport).toHaveBeenCalledTimes(1); + }); + + it("失敗時は null を返し error に AppErrorState を立てる", async () => { + mockImport.mockRejectedValueOnce( + new ApiError({ code: "VALIDATION_ERROR", message: "テキストを含む PDF のみ対応しています。" }), + ); + + const { result } = renderHook(() => useResumeImport()); + let returned: unknown = "unset"; + await act(async () => { + returned = await result.current.importPdf(_pdfFile()); + }); + + expect(returned).toBeNull(); + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toBe("テキストを含む PDF のみ対応しています。"); + expect(result.current.importing).toBe(false); + }); + + it("clearError で error をクリアできる", async () => { + mockImport.mockRejectedValueOnce(new ApiError({ code: "INTERNAL_ERROR", message: "失敗" })); + const { result } = renderHook(() => useResumeImport()); + await act(async () => { + await result.current.importPdf(_pdfFile()); + }); + expect(result.current.error).not.toBeNull(); + act(() => result.current.clearError()); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/web/src/hooks/useResumeImport.ts b/web/src/hooks/useResumeImport.ts new file mode 100644 index 00000000..42580d67 --- /dev/null +++ b/web/src/hooks/useResumeImport.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from "react"; + +import { importResumePdf } from "../api/agent"; +import { toAppError, type AppErrorState } from "../api"; +import type { ResumeImportResponse } from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; + +/** + * 手持ち PDF 経歴書のアップロード → 構造化抽出を管理するフック(ADR-0024 / #528)。 + * + * アップロード状態(importing)とエラー(AppErrorState)を持ち、成功時は抽出 payload を返す。 + * 抽出結果のフォーム適用(注入・上書き確認)は呼び出し側が担う(本フックは API 呼び出しと + * 状態管理のみ / DB 非更新 / ADR-0010)。失敗時は backend の日本語メッセージをそのまま表示する。 + */ +export function useResumeImport() { + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + + const importPdf = useCallback( + async (file: File): Promise => { + setImporting(true); + setError(null); + try { + return await importResumePdf(file); + } catch (e) { + // 422(非対応/破損 PDF・サイズ超過)/ 502(抽出失敗)/ 429 は backend の message を表示する + setError(toAppError(e, FALLBACK_MESSAGES.RESUME_IMPORT)); + return null; + } finally { + setImporting(false); + } + }, + [], + ); + + const clearError = useCallback(() => setError(null), []); + + return { importing, error, importPdf, clearError }; +} diff --git a/web/src/utils/resumeImport.test.ts b/web/src/utils/resumeImport.test.ts new file mode 100644 index 00000000..a7393498 --- /dev/null +++ b/web/src/utils/resumeImport.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { blankCareerExperience } from "../constants"; +import { createInitialCareerForm } from "../formMappers"; +import type { CareerFormState } from "../payloadBuilders"; +import type { ResumeImportResponse } from "../api/types"; +import { applyResumeImportToForm } from "./resumeImport"; + +/** 抽出 payload のひな型(テストで一部だけ差し替える)。 */ +function importPayload(overrides: Partial = {}): ResumeImportResponse { + return { + full_name: "", + career_summary: "", + self_pr: "", + experiences: [], + ...overrides, + }; +} + +describe("applyResumeImportToForm", () => { + it("抽出された非空の見出しフィールドを現フォームに上書きする", () => { + const current = createInitialCareerForm(); + const result = applyResumeImportToForm( + current, + importPayload({ + full_name: "山田 太郎", + career_summary: "バックエンドエンジニアとして 5 年。", + self_pr: "保守性を重視。", + }), + ); + expect(result.full_name).toBe("山田 太郎"); + expect(result.career_summary).toBe("バックエンドエンジニアとして 5 年。"); + expect(result.self_pr).toBe("保守性を重視。"); + }); + + it("抽出が空のフィールドは現フォームの既存値を保持する", () => { + const current: CareerFormState = { + ...createInitialCareerForm(), + full_name: "既存 名前", + career_summary: "既存の要約", + }; + const result = applyResumeImportToForm( + current, + importPayload({ full_name: "", career_summary: " ", self_pr: "新しい自己PR" }), + ); + // 空・空白のみは上書きしない + expect(result.full_name).toBe("既存 名前"); + expect(result.career_summary).toBe("既存の要約"); + // 非空は上書きする + expect(result.self_pr).toBe("新しい自己PR"); + }); + + it("抽出で得られない email / github_url / qualifications は現フォームを保持する", () => { + const current: CareerFormState = { + ...createInitialCareerForm(), + email: "keep@example.com", + github_url: "https://github.com/keep", + }; + const result = applyResumeImportToForm(current, importPayload({ full_name: "太郎" })); + expect(result.email).toBe("keep@example.com"); + expect(result.github_url).toBe("https://github.com/keep"); + expect(result.qualifications).toEqual(current.qualifications); + }); + + it("抽出職歴があれば experiences をフラット値から構築して置き換える", () => { + const current = createInitialCareerForm(); + const result = applyResumeImportToForm( + current, + importPayload({ + experiences: [ + { + company: "株式会社サンプル", + business_description: "受託開発", + start_date: "2020-04", + end_date: "2023-03", + description: "API 開発を担当。", + }, + { + company: "在籍中カンパニー", + business_description: "自社開発", + start_date: "2023-04", + end_date: "", + description: "設計を担当。", + }, + ], + }), + ); + expect(result.experiences).toHaveLength(2); + const [first, second] = result.experiences; + expect(first.company).toBe("株式会社サンプル"); + expect(first.business_description).toBe("受託開発"); + expect(first.start_date).toBe("2020-04"); + expect(first.end_date).toBe("2023-03"); + expect(first.description).toBe("API 開発を担当。"); + // end_date 空 = 在籍中フラグを立てる + expect(first.is_current).toBe(false); + expect(second.is_current).toBe(true); + // 深いネストは blank の既定(clients が 1 件用意される)を継承する + expect(first.clients.length).toBeGreaterThan(0); + }); + + it("抽出職歴が空なら現フォームの experiences を保持する", () => { + const current: CareerFormState = { + ...createInitialCareerForm(), + experiences: [{ ...blankCareerExperience, company: "既存カンパニー" }], + }; + const result = applyResumeImportToForm(current, importPayload({ full_name: "太郎" })); + expect(result.experiences).toHaveLength(1); + expect(result.experiences[0].company).toBe("既存カンパニー"); + }); +}); diff --git a/web/src/utils/resumeImport.ts b/web/src/utils/resumeImport.ts new file mode 100644 index 00000000..1ea8a6f9 --- /dev/null +++ b/web/src/utils/resumeImport.ts @@ -0,0 +1,58 @@ +/** + * PDF 経歴書の抽出結果({@link ResumeImportResponse})をキャリアフォーム state へ注入する + * 決定論変換(ADR-0024 / #524・#528)。 + * + * 方針(「欠落は既存値保持」/ #524): + * - 見出しフィールド(full_name / career_summary / self_pr)は**抽出値が非空のときだけ** + * 上書きし、空・空白のみなら現フォームの値を保持する。 + * - email / github_url / qualifications は抽出対象外(v1)なので常に現フォームを保持する。 + * - experiences は**抽出に 1 件でもあれば**フラット値から構築して置き換え、無ければ現状維持。 + * 深いネスト(clients / projects / periods / technology_stacks)は blank の既定を継承し、 + * ユーザーがフォームで追記する。 + * + * 破壊は呼び出し側(アップロード UI)が「入力途中なら確認ダイアログ」で防ぐ。本関数は純変換。 + */ +import type { ResumeImportResponse } from "../api/types"; +import { blankCareerExperience } from "../constants"; +import type { CareerExperienceForm, CareerFormState } from "../payloadBuilders"; + +type ImportExperience = NonNullable[number]; + +/** 抽出値が非空(空白のみでない)ならそれを、そうでなければ現行値を返す。 */ +function pick(extracted: string | undefined, current: string): string { + return (extracted ?? "").trim() ? (extracted as string) : current; +} + +/** フラットな抽出職歴 1 件を、深いネストは blank 既定のままにした experience へ写す。 */ +function toExperienceForm(exp: ImportExperience): CareerExperienceForm { + // 各 experience が blank のネスト構造を共有しないよう深いコピーを作る + const base = structuredClone(blankCareerExperience); + const endDate = exp.end_date ?? ""; + return { + ...base, + company: exp.company ?? "", + business_description: exp.business_description ?? "", + start_date: exp.start_date ?? "", + end_date: endDate, + // 終了日が空 = 在籍中とみなす + is_current: endDate.trim() === "", + description: exp.description ?? "", + }; +} + +export function applyResumeImportToForm( + current: CareerFormState, + payload: ResumeImportResponse, +): CareerFormState { + const extractedExperiences = payload.experiences ?? []; + return { + ...current, + full_name: pick(payload.full_name, current.full_name), + career_summary: pick(payload.career_summary, current.career_summary), + self_pr: pick(payload.self_pr, current.self_pr), + experiences: + extractedExperiences.length > 0 + ? extractedExperiences.map(toExperienceForm) + : current.experiences, + }; +} diff --git a/web/stryker.conf.json b/web/stryker.conf.json index 3dfd6f3a..93d28174 100644 --- a/web/stryker.conf.json +++ b/web/stryker.conf.json @@ -11,7 +11,6 @@ "src/payloadBuilders.ts", "src/hooks/**/*.ts", "src/store/*Slice.ts", - "src/constants/agentModels.ts", "!src/**/*.test.ts", "!src/**/*.test.tsx", "!src/**/*.d.ts", From d849d5ada08a4b23df99ecd85ff79cf6967816bd Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 25 Jul 2026 16:13:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?deps:=20brace-expansion=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20+=20react-router=20advisory=20=E3=82=92=20allowlist?= =?UTF-8?q?=EF=BC=88#528=20audit=20=E3=82=B2=E3=83=BC=E3=83=88=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main への PR で npm audit ゲートが新規 High advisory 2 件で落ちたため対処。 - brace-expansion(GHSA-mh99-v99m-4gvg / DoS→OOM): overrides で 5.0.8(patched)へ 統一。openapi-typescript→@redocly→minimatch 経由の dev 依存。非破壊更新 - react-router(GHSA-qwww-vcr4-c8h2 / RSC mode CSRF バイパス): allowlist で時限許容。 DevForge は SPA(BrowserRouter のみ)で RSC/framework モードを使わず到達不能。 react-router-dom 7.x に前進修正版が無く(最新 7.18.1 も脆弱範囲)、修正は v8 メジャー or ダウングレードのみで本 CSRF に無関係な破壊的変更のため。reviewBy 2026-10-31 Co-Authored-By: Claude Opus 4.8 --- web/package-lock.json | 25 ++++--------------------- web/package.json | 3 ++- web/scripts/audit-check.mjs | 9 +++++++++ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 0721f75f..04c49539 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2977,23 +2977,6 @@ "npm": ">=9.5.0" } }, - "node_modules/@redocly/openapi-core/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -4375,16 +4358,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { diff --git a/web/package.json b/web/package.json index dbfea8a0..7f1b0919 100644 --- a/web/package.json +++ b/web/package.json @@ -66,6 +66,7 @@ "ws": "^8.21.0", "undici": "^7.28.0", "fast-uri": "^3.1.4", - "sharp": "^0.35.3" + "sharp": "^0.35.3", + "brace-expansion": "^5.0.8" } } diff --git a/web/scripts/audit-check.mjs b/web/scripts/audit-check.mjs index 27d3f846..4e0a7612 100644 --- a/web/scripts/audit-check.mjs +++ b/web/scripts/audit-check.mjs @@ -48,6 +48,15 @@ const ALLOWLIST = { "dev サーバーは Vite(Linux)、esbuild serve は未使用のため到達不能。dev 依存。", reviewBy: "2026-09-30 (vite 8 / Rolldown 移行で esbuild 0.28.1 化を目指す)", }, + "GHSA-qwww-vcr4-c8h2": { + reason: + "React Router の RSC(framework)モードでの CSRF バイパス。DevForge は SPA 構成で " + + "main.tsx の BrowserRouter のみを使い、RSC / framework モード(@react-router/* の " + + "server 実行・action)を一切使わないため攻撃面に到達しない。react-router-dom 7.x に " + + "前進修正版が無く(最新 7.18.1 も脆弱範囲)、修正は v8 メジャー or 7.11.0 への " + + "ダウングレードのみで、いずれも本 CSRF に無関係な破壊的変更のため時限的に許容。", + reviewBy: "2026-10-31 (react-router v8 移行 or 7.x パッチ提供を評価する)", + }, }; const BLOCKING_SEVERITIES = new Set(["high", "critical"]); From 5ceccc2c46df7eabee3a3304bc8831cc4a712586 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Sat, 25 Jul 2026 16:47:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20PDF=20=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=9D=E3=83=BC=E3=83=88=E3=83=91=E3=83=8D=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E6=9D=A1=E4=BB=B6=E3=81=A8=E5=90=8C=E6=99=82?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=82=92=E5=A0=85=E7=89=A2=E5=8C=96=EF=BC=88?= =?UTF-8?q?#539=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 指摘(Functional Correctness)3 件。 - 空フォーム判定を全編集フィールド横断の単一述語 hasCareerFormContent に統一 (email/github_url/qualifications も見る)。パネル表示と上書き確認で共用(Minor) - パネル表示を !loading && !resumeId でゲート(Major)。認証ユーザは loadLatest 完了前に 空フォームが見えるため、既存経歴書との競合(インポートがロード結果で上書きされる/ 既存経歴書に適用される)を防ぐ - 抽出中の再アップロードを handleFile / openFileDialog で実ガード(Major)。aria-disabled は 視覚のみで、importing 中もクリック/選択で並行リクエストでき応答の順序逆転で誤反映し得た hasCareerFormContent の単体テスト(空/任意フィールド/配列中身)を追加。 Co-Authored-By: Claude Opus 4.8 --- web/src/components/forms/CareerResumeForm.tsx | 15 ++++++----- .../components/forms/ResumeImportPanel.tsx | 20 ++++++--------- web/src/utils/resumeImport.test.ts | 25 ++++++++++++++++++- web/src/utils/resumeImport.ts | 22 ++++++++++++++++ 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/web/src/components/forms/CareerResumeForm.tsx b/web/src/components/forms/CareerResumeForm.tsx index b32fceed..4be6b424 100644 --- a/web/src/components/forms/CareerResumeForm.tsx +++ b/web/src/components/forms/CareerResumeForm.tsx @@ -16,6 +16,7 @@ import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft"; +import { hasCareerFormContent } from "../../utils/resumeImport"; import { buildCareerPayload } from "../../payloadBuilders"; import { useCareerFormValidationFocus } from "../../hooks/career/useCareerFormValidationFocus"; import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData"; @@ -126,13 +127,11 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ const dirty = useCareerDirty(form, baseline); - // 空フォーム(新規ユーザ)のときだけ PDF 自動入力の導線を出す(ADR-0024 / #528)。 - // 入力・抽出後は内容が入るため自然に消える。既存経歴書のユーザには出さない。 - const isFormEmpty = - !form.full_name.trim() && - !form.career_summary.trim() && - !form.self_pr.trim() && - !form.experiences.some((e) => e.company.trim() || e.description.trim()); + // PDF 自動入力の導線は「保存済み経歴書が無く、ロード完了済みで、フォームが空」のときだけ出す + // (ADR-0024 / #528)。認証ユーザは loadLatest 完了前に空フォームが見えるため、!loading && + // !resumeId でガードして既存経歴書との競合(インポートがロード結果で上書きされる/既存に + // 適用される)を防ぐ。入力・抽出後は内容が入るため自然に消える。 + const showImportPanel = !loading && !resumeId && !hasCareerFormContent(form); /** Skeleton 表示・入力ロックの統合フラグ */ const formLocked = loading; @@ -245,7 +244,7 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean {validationError &&

{validationError}

} {/* 空フォームの新規ユーザ向け: 手持ち PDF から自動入力(ADR-0024 / #528) */} - {isFormEmpty && ( + {showImportPanel && ( void; }; -/** フォームに意味のある入力があるか(上書き確認の要否判定)。 */ -function hasMeaningfulInput(form: CareerFormState): boolean { - return Boolean( - form.full_name.trim() || - form.career_summary.trim() || - form.self_pr.trim() || - form.experiences.some((e) => e.company.trim() || e.description.trim()), - ); -} - /** * 手持ち PDF 経歴書のアップロード → 抽出 → フォーム反映の導線(ADR-0024 / #528)。 * @@ -50,6 +40,8 @@ export function ResumeImportPanel({ form, onApply, isAuthenticated, requestLogin }; const handleFile = async (file: File) => { + // 抽出中は新規アップロードを受け付けない(応答の順序逆転による誤反映を防止) + if (importing) return; clearError(); const isPdf = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); @@ -58,8 +50,8 @@ export function ResumeImportPanel({ form, onApply, isAuthenticated, requestLogin return; } const payload = await importPdf(file); - if (!payload) return; // 失敗時は useMessageToast がエラーを表示済み - if (hasMeaningfulInput(form)) { + if (!payload) return; // 失敗時は useAppErrorToast がエラーを表示済み + if (hasCareerFormContent(form)) { setPending(payload); } else { applyPayload(payload); @@ -67,6 +59,8 @@ export function ResumeImportPanel({ form, onApply, isAuthenticated, requestLogin }; const openFileDialog = () => { + // 抽出中の再選択を防ぐ(応答の順序逆転による誤反映を防止) + if (importing) return; if (!isAuthenticated) { requestLogin(); return; diff --git a/web/src/utils/resumeImport.test.ts b/web/src/utils/resumeImport.test.ts index a7393498..f2fdf509 100644 --- a/web/src/utils/resumeImport.test.ts +++ b/web/src/utils/resumeImport.test.ts @@ -4,7 +4,7 @@ import { blankCareerExperience } from "../constants"; import { createInitialCareerForm } from "../formMappers"; import type { CareerFormState } from "../payloadBuilders"; import type { ResumeImportResponse } from "../api/types"; -import { applyResumeImportToForm } from "./resumeImport"; +import { applyResumeImportToForm, hasCareerFormContent } from "./resumeImport"; /** 抽出 payload のひな型(テストで一部だけ差し替える)。 */ function importPayload(overrides: Partial = {}): ResumeImportResponse { @@ -17,6 +17,29 @@ function importPayload(overrides: Partial = {}): ResumeImp }; } +describe("hasCareerFormContent", () => { + it("初期(空)フォームは false", () => { + expect(hasCareerFormContent(createInitialCareerForm())).toBe(false); + }); + + it("email / github_url など任意の編集フィールドに入力があれば true", () => { + expect( + hasCareerFormContent({ ...createInitialCareerForm(), email: "a@example.com" }), + ).toBe(true); + expect( + hasCareerFormContent({ ...createInitialCareerForm(), github_url: "https://github.com/x" }), + ).toBe(true); + }); + + it("experiences / qualifications の中身があれば true(blank の配列長では判定しない)", () => { + const withExp: CareerFormState = { + ...createInitialCareerForm(), + experiences: [{ ...blankCareerExperience, company: "会社" }], + }; + expect(hasCareerFormContent(withExp)).toBe(true); + }); +}); + describe("applyResumeImportToForm", () => { it("抽出された非空の見出しフィールドを現フォームに上書きする", () => { const current = createInitialCareerForm(); diff --git a/web/src/utils/resumeImport.ts b/web/src/utils/resumeImport.ts index 1ea8a6f9..af64ac48 100644 --- a/web/src/utils/resumeImport.ts +++ b/web/src/utils/resumeImport.ts @@ -18,6 +18,28 @@ import type { CareerExperienceForm, CareerFormState } from "../payloadBuilders"; type ImportExperience = NonNullable[number]; +/** + * キャリアフォームにユーザーの意味のある入力があるかを判定する(全編集フィールド横断)。 + * + * PDF 自動入力パネルの表示可否(空フォームのみ)と、上書き確認の要否の両方で使う単一述語。 + * 空フォームは experiences / qualifications に blank 要素を 1 件持つため、配列長ではなく + * 各フィールドの中身(trim 後の非空)で判定する。 + */ +export function hasCareerFormContent(form: CareerFormState): boolean { + return Boolean( + form.full_name.trim() || + form.email.trim() || + form.github_url.trim() || + form.career_summary.trim() || + form.self_pr.trim() || + form.experiences.some( + (e) => + e.company.trim() || e.business_description.trim() || e.description.trim(), + ) || + form.qualifications.some((q) => q.name.trim() || q.acquired_date.trim()), + ); +} + /** 抽出値が非空(空白のみでない)ならそれを、そうでなければ現行値を返す。 */ function pick(extracted: string | undefined, current: string): string { return (extracted ?? "").trim() ? (extracted as string) : current;