diff --git a/backend/app/messages.json b/backend/app/messages.json index cc91bdc7..968754b8 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -71,7 +71,10 @@ "draft_pdf_failed": "経歴書ドラフトの PDF 生成に失敗しました。もう一度お試しください。", "draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。", "skill_display_no_skills": "表示名を提案できるスキルがありません。先に GitHub 連携を実行してください。", - "skill_display_invalid_identity": "確定対象に連携結果に存在しないスキルが含まれています。" + "skill_display_invalid_identity": "確定対象に連携結果に存在しないスキルが含まれています。", + "import_invalid_pdf": "PDF を読み取れませんでした。ファイルが壊れていないか確認して、もう一度お試しください。", + "import_scanned_pdf": "テキストを含む PDF のみ対応しています。スキャン画像の PDF は読み取れないため、お手数ですが手入力をお願いします。", + "import_too_large": "ファイルサイズが大きすぎます。10MB 以下の PDF をアップロードしてください。" } }, "notification": { diff --git a/backend/app/prompts/agent_resume_import.md b/backend/app/prompts/agent_resume_import.md new file mode 100644 index 00000000..bb5971a6 --- /dev/null +++ b/backend/app/prompts/agent_resume_import.md @@ -0,0 +1,32 @@ +あなたは日本語の職務経歴書 PDF から情報を読み取り、フォーム入力用に構造化するアシスタントです。 +「# 経歴書 PDF から抽出したテキスト」に与えられるテキスト(PDF から機械抽出したもの)を読み、氏名・職務要約・自己PR・在籍企業ごとの職歴を抽出してください。 +出力の構造・フィールド・文字数上限はスキーマで定義されているため、ここでは抽出の正確さに集中すること。 + +# 共通ルール(最優先) +- **書かれていることだけを抽出する(捏造禁止)**: 与えられたテキストに無い企業・数値・技術・成果を新たに作らない。読み取れないフィールドは空文字(配列なら空)にする +- **要約・創作をしない**: これは「作文」ではなく「転記」である。原文の表現を尊重し、フォーム項目に振り分けるだけにする。文体の大幅な書き換えや誇張をしない +- PDF 抽出テキストは改行やレイアウトが崩れていることがある。文の意味を読み取って適切なフィールドへ振り分けるが、内容は足さない +- 判断に迷う情報は、無理にどこかへ入れず空のままにする(ユーザーがフォームで補完する前提) + +# full_name(氏名) +- 経歴書の氏名を抽出する。ふりがな・英語表記が併記されている場合は主たる漢字/表記のみ +- 読み取れない場合は空文字 + +# career_summary(職務要約) +- 「職務要約」「概要」「サマリー」等の見出しに続く本文を抽出する +- 見出しが無い場合、冒頭のキャリア全体を要約した段落があればそれを充てる。無ければ空文字 +- 原文の表現を保ち、フォームに収まるよう整形する(内容は足さない) + +# self_pr(自己PR) +- 「自己PR」「アピール」「強み」等の見出しに続く本文を抽出する。無ければ空文字 + +# experiences(職歴・フラット) +- 在籍企業ごとに 1 件。各企業について company(企業名)/ business_description(事業内容・1 行程度)/ start_date(在籍開始)/ end_date(在籍終了・在籍中は空)/ description(その企業での職務内容)を抽出する +- **v1 ではプロジェクト・チーム構成・技術スタックの細目までは抽出しない**(企業単位の記述にまとめる。詳細はユーザーがフォームで追記する) +- 日付は原文の表記のまま抽出してよい(例: 「2020年4月」「2020-04」)。読み取れない日付は空文字 +- 企業が 1 社も読み取れない場合は空配列 + +# 思考ステップ(内部分析。出力には含めない) +1. テキスト全体を走査し、氏名・職務要約・自己PR・職歴セクションの位置を把握する +2. 職歴は在籍企業の区切りを見つけ、企業ごとに情報を集約する +3. 各フィールドへ原文を転記する。読み取れないものは空にする(推測で埋めない) diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index 73903650..5d13d14f 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -7,7 +7,7 @@ import logging -from fastapi import APIRouter, BackgroundTasks, Depends, Request +from fastapi import APIRouter, BackgroundTasks, Depends, File, Request, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -18,7 +18,12 @@ from ..db import get_db from ..models import User from ..repositories.resume_draft import ResumeDraftCacheRepository -from ..schemas.agent import AgentChatRequest, AgentChatResponse, ResumeDraftRequest +from ..schemas.agent import ( + AgentChatRequest, + AgentChatResponse, + ResumeDraftRequest, + ResumeImportResponse, +) from ..schemas.shared import TaskAcceptedResponse, TaskStatusResponse from ..services.agent import chat_service from ..services.agent.chat_service import ( @@ -33,6 +38,12 @@ ResumeDraftSourceUnavailableError, build_draft_source, ) +from ..services.agent.resume_import.import_service import run_resume_import +from ..services.agent.resume_import.text_extract import ( + PdfExtractionError, + ScannedPdfError, + extract_pdf_text, +) from ..services.pdf.generators.resume_generator import build_resume_pdf from ..services.tasks import AsyncTaskCacheService, TaskType from .download_utils import stream_pdf @@ -203,3 +214,66 @@ def download_resume_draft_pdf( ) pdf_bytes = build_resume_pdf(cache.result) return stream_pdf(pdf_bytes, "career-resume-draft.pdf") + + +# 手持ち PDF 経歴書のアップロード上限(ADR-0024 / #527)。経歴書 PDF は通常数 MB 以内。 +_MAX_PDF_UPLOAD_BYTES = 10 * 1024 * 1024 + + +@router.post("/resume-import/pdf", response_model=ResumeImportResponse) +@limiter.limit("5/minute") +async def import_resume_pdf( + request: Request, + file: UploadFile = File(...), + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> ResumeImportResponse: + """手持ちの PDF 経歴書を構造化抽出し、フォーム注入用 payload を返す(ADR-0024)。 + + テキスト埋め込み PDF のみ対応(スキャン PDF は 422 で案内)。抽出は Claude Haiku で + 行い、DB は更新しない(ADR-0010)。結果はフロントがフォーム state へ注入 → ユーザー + 確認 → 既存の保存 API を呼ぶ。abuse 防止は日次レート制限(#521 / ADR-0023)。 + """ + _enforce_agent_daily_limit(db, user.id) + + data = await file.read() + if len(data) > _MAX_PDF_UPLOAD_BYTES: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.import_too_large"), + ) + + # テキスト抽出。非 PDF / 破損 / スキャン(テキスト非埋め込み)は明示的に 422 で倒す + # (旧設計の空文字握りつぶしの再発防止 / ADR-0004→0008→0024) + try: + text = extract_pdf_text(data) + except ScannedPdfError: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.import_scanned_pdf"), + ) + except PdfExtractionError: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.import_invalid_pdf"), + ) + + # 構造化抽出(Claude Haiku 固定 / ADR-0023)。失敗契約はチャット・ドラフトと同一 + try: + result = await run_resume_import("haiku", text) + except LLMError: + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_LLM_ERROR, + message=get_error("agent.llm_failed"), + ) + except AgentResponseParseError: + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_PARSE_ERROR, + message=get_error("agent.parse_failed"), + ) + return ResumeImportResponse.model_validate(result.payload) diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index a048ed71..b347362e 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -168,3 +168,32 @@ class AgentChatResponse(BaseModel): message: str operations: list[AgentOperation] = Field(default_factory=list) suggestions: list[str] = Field(default_factory=list) + + +class ResumeImportExperience(BaseModel): + """PDF から抽出した職歴 1 件(フラット / ADR-0024 v1)。 + + フォーム注入用のため全フィールド任意(欠落は空文字)。深いネスト(clients / + projects / periods / technology_stacks)は v1 では抽出せず、ユーザーがフォームで追記する。 + """ + + company: str = "" + business_description: str = "" + start_date: str = "" + end_date: str = "" + description: str = "" + + +class ResumeImportResponse(BaseModel): + """手持ち PDF 経歴書の抽出結果(ADR-0024)。 + + Resume 互換のフォーム注入用 payload。保存契約(schemas/resume.py の strict な + バリデーション)とは分離し、抽出できた分だけを返す(全フィールド任意・欠落は空)。 + DB は更新せず、フロントがフォーム state へ注入 → ユーザー確認 → 既存の保存 API を呼ぶ。 + email 等の未抽出フィールドはフォームでユーザーが補完する。 + """ + + full_name: str = "" + career_summary: str = "" + self_pr: str = "" + experiences: list[ResumeImportExperience] = Field(default_factory=list) diff --git a/backend/app/services/agent/resume_import/__init__.py b/backend/app/services/agent/resume_import/__init__.py new file mode 100644 index 00000000..ab037657 --- /dev/null +++ b/backend/app/services/agent/resume_import/__init__.py @@ -0,0 +1,5 @@ +"""手持ち PDF 経歴書のフォーム流し込み(ADR-0024)。 + +テキスト埋め込み PDF を pypdf で抽出し、Claude Haiku で Resume 互換 payload に +構造化する。DB 非更新でフォーム注入機構(#524)へ渡す前段まで。 +""" diff --git a/backend/app/services/agent/resume_import/import_service.py b/backend/app/services/agent/resume_import/import_service.py new file mode 100644 index 00000000..4e090e82 --- /dev/null +++ b/backend/app/services/agent/resume_import/import_service.py @@ -0,0 +1,153 @@ +"""PDF 抽出テキスト → Resume 互換 payload の構造化抽出(ADR-0024 / #527)。 + +抽出済みテキスト(``text_extract`` の出力)を Claude Haiku に渡し、構造化出力で +Resume 互換の payload に落とす。DB には触れない。LLM 呼び出しの失敗契約 +(LLMError / AgentResponseParseError に usage〈観測用〉を載せる・リトライは 1 回のみ)は +チャット(chat_service)・ドラフト(draft_service)と同一(ADR-0010)。 + +payload は保存契約(schemas/resume.py の strict なバリデーション)ではなく、フォーム +注入用の緩い形(全フィールド任意・欠落は空)で返す。email 等の未抽出フィールドは +フォーム側でユーザが補完する(#524 / DB 非更新)。 +""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from pydantic import BaseModel, Field, ValidationError + +from ....schemas.agent import AgentModelAlias +from ..chat_service import AgentResponseParseError, AgentUsage +from ..llm.base import LLMError +from ..llm.factory import get_llm_client +from ..model_catalog import get_model_spec +from .output_schema import ( + MAX_BUSINESS_DESCRIPTION_LENGTH, + MAX_CAREER_SUMMARY_LENGTH, + MAX_COMPANY_LENGTH, + MAX_DATE_LENGTH, + MAX_EXPERIENCE_DESCRIPTION_LENGTH, + MAX_EXPERIENCES, + MAX_FULL_NAME_LENGTH, + MAX_SELF_PR_LENGTH, + build_import_output_schema, +) + +logger = logging.getLogger(__name__) + +# システムプロンプトの正本は app/prompts/(チャット・ドラフトと同じ分離)。静的に保つ。 +_PROMPTS_DIR = Path(__file__).resolve().parents[3] / "prompts" +_SYSTEM_PROMPT = (_PROMPTS_DIR / "agent_resume_import.md").read_text(encoding="utf-8") + +# リトライ時に LLM へフィードバックするエラー文の上限(chat_service と同じ趣旨) +_MAX_RETRY_ERROR_LENGTH = 500 + + +@dataclass(frozen=True) +class ResumeImportResult: + """run_resume_import の戻り値(フォーム注入用 payload + 観測用の使用量)。""" + + payload: dict + usage: AgentUsage + + +class _ImportExperience(BaseModel): + """抽出された職歴 1 件(フラット / v1)。""" + + company: str = Field(default="", max_length=MAX_COMPANY_LENGTH) + business_description: str = Field(default="", max_length=MAX_BUSINESS_DESCRIPTION_LENGTH) + start_date: str = Field(default="", max_length=MAX_DATE_LENGTH) + end_date: str = Field(default="", max_length=MAX_DATE_LENGTH) + description: str = Field(default="", max_length=MAX_EXPERIENCE_DESCRIPTION_LENGTH) + + +class _ImportOutput(BaseModel): + """LLM 出力全体の検証用モデル(構造の二重防衛)。""" + + full_name: str = Field(default="", max_length=MAX_FULL_NAME_LENGTH) + career_summary: str = Field(default="", max_length=MAX_CAREER_SUMMARY_LENGTH) + self_pr: str = Field(default="", max_length=MAX_SELF_PR_LENGTH) + experiences: list[_ImportExperience] = Field( + default_factory=list, max_length=MAX_EXPERIENCES + ) + + +def _parse_import(raw: str) -> _ImportOutput: + """LLM 応答をパースして検証する(上限超過・構造不正はパース失敗)。""" + text = raw.strip() + # Ollama など tool use ではないローカル実装のコードフェンス耐性(chat_service と同じ) + if text.startswith("```"): + text = text.strip("`") + text = text.removeprefix("json").strip() + try: + data = json.loads(text) + return _ImportOutput.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + logger.warning("PDF 抽出 LLM 応答のパースに失敗: %s", type(exc).__name__) + raise AgentResponseParseError(str(exc)) from exc + + +async def run_resume_import(model: AgentModelAlias, extracted_text: str) -> ResumeImportResult: + """抽出テキストから Resume 互換 payload を生成し、観測用の使用量とともに返す。 + + Raises: + AgentResponseParseError: LLM 応答が不正(リトライ後も失敗)。 + LLMError: LLM 呼び出しの失敗。 + """ + spec = get_model_spec(model) + client = get_llm_client(spec.provider) + output_schema = build_import_output_schema() + user_prompt = f"# 経歴書 PDF から抽出したテキスト\n{extracted_text}" + messages: list[dict[str, str]] = [{"role": "user", "content": user_prompt}] + + # 個人情報(抽出テキスト本文)はログに載せない(メタデータのみ) + logger.debug("PDF 抽出 LLM 入力: model=%s text_len=%d", model, len(extracted_text)) + + # リトライしても 1 回目の API 原価は発生するため、使用量は合算で記録する(観測用 / ADR-0023) + input_tokens = 0 + output_tokens = 0 + + def _usage() -> AgentUsage: + return AgentUsage(model=model, input_tokens=input_tokens, output_tokens=output_tokens) + + async def _generate_and_account(call_messages: list[dict[str, str]], *, label: str): + nonlocal input_tokens, output_tokens + call_result = await client.generate( + _SYSTEM_PROMPT, call_messages, output_schema, spec.model_id + ) + input_tokens += call_result.input_tokens + output_tokens += call_result.output_tokens + logger.debug("PDF 抽出 LLM %s応答(パース前): len=%d", label, len(call_result.text)) + return call_result + + result = await _generate_and_account(messages, label="生") + try: + output = _parse_import(result.text) + return ResumeImportResult(payload=output.model_dump(), usage=_usage()) + except AgentResponseParseError as exc: + # 出力契約違反は 1 回だけリトライ(違反内容をフィードバックして再生成 / ADR-0010) + logger.warning("PDF 抽出 LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__) + retry_messages = [ + *messages, + {"role": "assistant", "content": result.text}, + { + "role": "user", + "content": ( + "直前の応答は出力契約に違反しています。" + f"違反内容: {str(exc)[:_MAX_RETRY_ERROR_LENGTH]}\n" + "契約に従って同じ依頼への応答を再生成してください。" + ), + }, + ] + + try: + result = await _generate_and_account(retry_messages, label="リトライ") + except LLMError as retry_exc: + retry_exc.usage = _usage() + raise + try: + output = _parse_import(result.text) + except AgentResponseParseError as retry_exc: + raise AgentResponseParseError(str(retry_exc), usage=_usage()) from retry_exc + return ResumeImportResult(payload=output.model_dump(), usage=_usage()) diff --git a/backend/app/services/agent/resume_import/output_schema.py b/backend/app/services/agent/resume_import/output_schema.py new file mode 100644 index 00000000..82a64449 --- /dev/null +++ b/backend/app/services/agent/resume_import/output_schema.py @@ -0,0 +1,102 @@ +"""PDF 経歴書抽出の LLM 構造化出力スキーマ(機械制約の正本 / ADR-0024)。 + +チャット・ドラフトと同じ責務分離に従う: 機械検証可能な制約(フィールド構造・文字数 +上限)はここに置き、品質制約(捏造禁止・抽出方針)は ``prompts/agent_resume_import.md`` +に置く(ADR-0010 / P4)。 + +v1 の抽出スコープは「見出し 3 フィールド + フラット職歴」(ADR-0024 / #527)。experiences +の深いネスト(clients / projects / periods / technology_stacks)は v1 では抽出しない。 +文字数上限は保存スキーマ(``schemas/resume.py``)と揃える(フォーム注入後の保存契約)。 +""" + +from ..output_schema import SCOPE_FIELDS + +# 上限の正本: 見出しはチャットの SCOPE_FIELDS(= schemas/resume.py と drift 検証済み) +MAX_CAREER_SUMMARY_LENGTH = SCOPE_FIELDS["career_summary"]["career_summary"] +MAX_SELF_PR_LENGTH = SCOPE_FIELDS["self_pr"]["self_pr"] + +# experiences のフラットフィールド上限(schemas/resume.py の Experience と一致させる) +MAX_FULL_NAME_LENGTH = 120 +MAX_COMPANY_LENGTH = 120 +MAX_BUSINESS_DESCRIPTION_LENGTH = 200 +MAX_DATE_LENGTH = 30 +MAX_EXPERIENCE_DESCRIPTION_LENGTH = 4500 + +# 職歴の抽出件数上限(暴走・トークン浪費の防止) +MAX_EXPERIENCES = 30 + + +def build_import_output_schema() -> dict: + """PDF 抽出の出力 JSON Schema を構築する。 + + LLM クライアントの tool 定義(``build_tool_definition``)にそのまま渡せる形。 + maxLength は API では強制されないため、上限超過の扱いは import_service のパース側が + 担う(二重防衛 / ADR-0010)。全フィールドは抽出できなければ空文字/空配列を許容する + (部分抽出を許す。欠落はフォーム側で既存値保持 / #524)。 + """ + experience_item = { + "type": "object", + "properties": { + "company": { + "type": "string", + "maxLength": MAX_COMPANY_LENGTH, + "description": "在籍企業名。読み取れない場合は空文字", + }, + "business_description": { + "type": "string", + "maxLength": MAX_BUSINESS_DESCRIPTION_LENGTH, + "description": "企業の事業内容(1 行程度)。読み取れない場合は空文字", + }, + "start_date": { + "type": "string", + "maxLength": MAX_DATE_LENGTH, + "description": "在籍開始(例: 2020-04 / 2020年4月)。読み取れない場合は空文字", + }, + "end_date": { + "type": "string", + "maxLength": MAX_DATE_LENGTH, + "description": "在籍終了(在籍中は空文字)", + }, + "description": { + "type": "string", + "maxLength": MAX_EXPERIENCE_DESCRIPTION_LENGTH, + "description": "その企業での職務内容の記述。読み取れない場合は空文字", + }, + }, + "required": [ + "company", + "business_description", + "start_date", + "end_date", + "description", + ], + "additionalProperties": False, + } + return { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "maxLength": MAX_FULL_NAME_LENGTH, + "description": "氏名。読み取れない場合は空文字", + }, + "career_summary": { + "type": "string", + "maxLength": MAX_CAREER_SUMMARY_LENGTH, + "description": "職務要約。経歴書の該当箇所をそのまま整形した日本語", + }, + "self_pr": { + "type": "string", + "maxLength": MAX_SELF_PR_LENGTH, + "description": "自己PR。経歴書の該当箇所をそのまま整形した日本語", + }, + "experiences": { + "type": "array", + "maxItems": MAX_EXPERIENCES, + "items": experience_item, + "description": "在籍企業ごとの職歴(新しい順は問わない。読み取れた分のみ)", + }, + }, + "required": ["full_name", "career_summary", "self_pr", "experiences"], + "additionalProperties": False, + } diff --git a/backend/app/services/agent/resume_import/text_extract.py b/backend/app/services/agent/resume_import/text_extract.py new file mode 100644 index 00000000..3e0ef08f --- /dev/null +++ b/backend/app/services/agent/resume_import/text_extract.py @@ -0,0 +1,87 @@ +"""PDF 経歴書のテキスト抽出(決定論ロジック / ADR-0024)。 + +テキスト埋め込み PDF を pypdf で抽出し、スキャン PDF(テキスト非埋め込み)を判定して +明示的なエラーで倒す(旧設計の空文字握りつぶしの再発防止 / ADR-0004→0008→0024)。 +本モジュールは LLM を呼ばず DB にも触れない純粋な前処理。 +""" + +import logging +from io import BytesIO + +from pypdf import PdfReader +from pypdf.errors import PyPdfError + +logger = logging.getLogger(__name__) + +# PDF のマジックバイト(先頭)。multipart で送られた任意バイト列の一次弾き。 +_PDF_MAGIC = b"%PDF-" + +# 正規化後にこの文字数未満なら「テキスト非埋め込み(スキャン相当)」と判定する。 +# 氏名だけの表紙でも数十字は出るため、実質空を弾く緩い閾値。 +MIN_TEXT_LENGTH = 50 + + +class PdfExtractionError(Exception): + """PDF として読めない(非 PDF・破損)。router で 422 にマップする。""" + + +class ScannedPdfError(Exception): + """テキストが埋め込まれていない(スキャン画像 PDF)。router で 422 にマップする。""" + + +def looks_like_pdf(data: bytes) -> bool: + """先頭バイトが PDF マジック(``%PDF-``)かを判定する(一次バリデーション)。""" + return data[: len(_PDF_MAGIC)] == _PDF_MAGIC + + +def normalize_text(text: str) -> str: + """抽出テキストを正規化する。 + + - 各行の行末空白を除去する + - 連続する空行を 1 行に圧縮する(PDF 抽出で頻出する過剰な空行を畳む) + - 先頭・末尾の空白/空行を除去する + """ + normalized_lines: list[str] = [] + previous_blank = False + for raw_line in text.splitlines(): + line = raw_line.rstrip() + if line == "": + if not previous_blank: + normalized_lines.append("") + previous_blank = True + else: + normalized_lines.append(line) + previous_blank = False + return "\n".join(normalized_lines).strip() + + +def has_sufficient_text(text: str) -> bool: + """抽出テキストが構造化抽出に足る量かを判定する(スキャン PDF 判定の中核)。""" + return len(text.strip()) >= MIN_TEXT_LENGTH + + +def extract_pdf_text(data: bytes) -> str: + """PDF バイト列から本文テキストを抽出して正規化して返す。 + + Raises: + PdfExtractionError: 非 PDF・破損で読めない。 + ScannedPdfError: 読めたがテキストが埋め込まれていない(スキャン相当)。 + """ + if not looks_like_pdf(data): + raise PdfExtractionError("PDF ではないファイルです") + + try: + reader = PdfReader(BytesIO(data)) + pages_text = [page.extract_text() or "" for page in reader.pages] + except (PyPdfError, ValueError, OSError, KeyError, RecursionError) as exc: + # 破損 PDF・構造不正。pypdf は PdfReadError(PyPdfError 派生)だけでなく、 + # 必須キー欠落(KeyError: /Root・/Pages)や深いネスト(RecursionError)を + # 組み込み例外のまま送出することがあるため、それらも 422 へ倒す(500 漏れ防止)。 + # 個人情報は載せずに型のみログ。 + logger.warning("PDF の読み取りに失敗: %s", type(exc).__name__) + raise PdfExtractionError("PDF を読み取れませんでした") from exc + + text = normalize_text("\n".join(pages_text)) + if not has_sufficient_text(text): + raise ScannedPdfError("テキストが埋め込まれていない PDF です") + return text diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a5071bea..f818dd54 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "autopep8==2.3.2", "markdown==3.10.2", "weasyprint==69.0", + # PDF 経歴書のテキスト抽出(ADR-0024 / #527)。純 Python・native 依存なし。 + "pypdf==6.14.2", "pydyf==0.12.1", "redis==8.0.1", "isort==8.0.1", @@ -68,6 +70,7 @@ testpaths = ["tests"] source_paths = ["app"] only_mutate = [ "app/services/agent/rate_limit.py", # Agent 日次レート制限判定(#521) + "app/services/agent/resume_import/text_extract.py", # PDF テキスト抽出・スキャン判定(#527 / ADR-0024) "app/services/intelligence/skills/*", # スキル推論(aggregator/linguist/パーサ群) "app/services/intelligence/github/repo_analyzer.py", # リポジトリ解析 "app/services/intelligence/github/contributions.py", # コントリビューション集計 diff --git a/backend/tests/test_resume_import_api.py b/backend/tests/test_resume_import_api.py new file mode 100644 index 00000000..2715e13b --- /dev/null +++ b/backend/tests/test_resume_import_api.py @@ -0,0 +1,151 @@ +"""PDF 経歴書インポート API の統合テスト(ADR-0024 / #527)。 + +`POST /api/agent/resume-import/pdf` の正常系・非対応 PDF・LLM 失敗・パース失敗・ +レート制限・未認証を検証する。LLM はモックし実 API は呼ばない。実 PDF は WeasyPrint +(生成専用・テスト環境で利用可)で作る。DB はモックしない(実 SQLite セッション)。 +""" + +import json + +from app.core import env_keys +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult +from app.services.agent.resume_import import import_service +from fastapi.testclient import TestClient +from weasyprint import HTML + +from conftest import auth_header + + +class _FakeLLM(LLMClient): + """固定応答 or 例外を返すテスト用 LLM。""" + + def __init__(self, response: str | None = None, error: Exception | None = None): + self._response = response + self._error = error + + async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult: + if self._error: + raise self._error + assert self._response is not None + return LLMResult(text=self._response, input_tokens=10, output_tokens=20) + + +def _mock_llm(monkeypatch, *, response=None, error=None) -> _FakeLLM: + fake = _FakeLLM(response=response, error=error) + monkeypatch.setattr(import_service, "get_llm_client", lambda provider: fake) + return fake + + +def _import_json(**fields) -> str: + payload = { + "full_name": "", + "career_summary": "", + "self_pr": "", + "experiences": [], + } + payload.update(fields) + return json.dumps(payload, ensure_ascii=False) + + +def _render_pdf(body_html: str) -> bytes: + """テスト用 PDF を生成する(target 未指定の write_pdf は bytes を返す)。""" + pdf = HTML(string=f"{body_html}").write_pdf() + assert pdf is not None + return pdf + + +def _text_pdf() -> bytes: + """テキスト埋め込み PDF(ASCII 本文で抽出可能にする)。""" + return _render_pdf( + "

Career summary: backend engineer with five years of experience " + "building and operating web services.

" + "

Self PR: I value maintainability and thorough testing.

" + ) + + +def _upload(client: TestClient, data: bytes, *, filename="resume.pdf", content_type="application/pdf"): + return client.post( + "/api/agent/resume-import/pdf", + files={"file": (filename, data, content_type)}, + headers=auth_header(client), + ) + + +def test_import_pdf_success_returns_extracted_payload(client: TestClient, monkeypatch) -> None: + """テキスト埋め込み PDF → 抽出 payload を 200 で返す。""" + _mock_llm( + monkeypatch, + response=_import_json( + full_name="山田 太郎", + career_summary="バックエンドエンジニアとして 5 年。", + self_pr="保守性を重視。", + experiences=[ + { + "company": "株式会社サンプル", + "business_description": "受託開発", + "start_date": "2020-04", + "end_date": "", + "description": "API 開発を担当。", + } + ], + ), + ) + res = _upload(client, _text_pdf()) + assert res.status_code == 200 + body = res.json() + assert body["full_name"] == "山田 太郎" + assert body["career_summary"] == "バックエンドエンジニアとして 5 年。" + assert len(body["experiences"]) == 1 + assert body["experiences"][0]["company"] == "株式会社サンプル" + + +def test_import_pdf_rejects_non_pdf(client: TestClient, monkeypatch) -> None: + """PDF でないファイルは抽出段階で 422(import_invalid_pdf)。LLM には到達しない。""" + _mock_llm(monkeypatch, response=_import_json()) + res = _upload(client, b"this is plain text, not a pdf") + assert res.status_code == 422 + assert "読み取れませんでした" in res.json()["message"] + + +def test_import_pdf_rejects_scanned_pdf(client: TestClient, monkeypatch) -> None: + """テキスト非埋め込み(スキャン相当)PDF は 422(import_scanned_pdf)。""" + _mock_llm(monkeypatch, response=_import_json()) + res = _upload(client, _render_pdf("

 

")) + assert res.status_code == 422 + assert "テキストを含む PDF" in res.json()["message"] + + +def test_import_pdf_llm_failure_returns_502(client: TestClient, monkeypatch) -> None: + """LLM 呼び出し失敗は 502 AGENT_LLM_ERROR。""" + _mock_llm(monkeypatch, error=LLMError("boom")) + res = _upload(client, _text_pdf()) + assert res.status_code == 502 + assert res.json()["code"] == "AGENT_LLM_ERROR" + + +def test_import_pdf_parse_failure_returns_502(client: TestClient, monkeypatch) -> None: + """LLM 応答が不正 JSON(リトライ後も失敗)は 502 AGENT_PARSE_ERROR。""" + _mock_llm(monkeypatch, response="not a json at all") + res = _upload(client, _text_pdf()) + assert res.status_code == 502 + assert res.json()["code"] == "AGENT_PARSE_ERROR" + + +def test_import_pdf_daily_rate_limit_returns_429(client: TestClient, monkeypatch) -> None: + """日次上限到達で 429(LLM 呼び出し前に弾く)。""" + monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "1") + _mock_llm(monkeypatch, response=_import_json(full_name="太郎")) + first = _upload(client, _text_pdf()) + assert first.status_code == 200 + second = _upload(client, _text_pdf()) + assert second.status_code == 429 + assert second.json()["code"] == "AGENT_DAILY_LIMIT_EXCEEDED" + + +def test_import_pdf_requires_auth(client: TestClient) -> None: + """未認証は 401。""" + res = client.post( + "/api/agent/resume-import/pdf", + files={"file": ("resume.pdf", _text_pdf(), "application/pdf")}, + ) + assert res.status_code == 401 diff --git a/backend/tests/test_resume_import_text_extract.py b/backend/tests/test_resume_import_text_extract.py new file mode 100644 index 00000000..e8abb575 --- /dev/null +++ b/backend/tests/test_resume_import_text_extract.py @@ -0,0 +1,119 @@ +"""PDF テキスト抽出(決定論ロジック)の単体テスト(ADR-0024 / #527)。 + +TDD 対象(`app/services/agent/resume_import/text_extract.py`)。純関数 +(正規化・十分性判定・PDF 判定)と、pypdf を使うテキスト抽出の両方を検証する。 +実 PDF は WeasyPrint(生成専用・テスト環境で利用可)で作る。 +""" + +import pytest +from app.services.agent.resume_import.text_extract import ( + PdfExtractionError, + ScannedPdfError, + extract_pdf_text, + has_sufficient_text, + looks_like_pdf, + normalize_text, +) +from weasyprint import HTML + + +def _make_pdf(body_html: str) -> bytes: + """テキスト埋め込み PDF を生成する(抽出可能な本文を持つ)。""" + pdf = HTML(string=f"{body_html}").write_pdf() + assert pdf is not None # target 未指定の write_pdf は bytes を返す + return pdf + + +# ---- looks_like_pdf ---- + + +def test_looks_like_pdf_accepts_pdf_magic() -> None: + assert looks_like_pdf(b"%PDF-1.7\n...") is True + + +def test_looks_like_pdf_rejects_non_pdf() -> None: + assert looks_like_pdf(b"PK\x03\x04zip") is False + assert looks_like_pdf(b"") is False + assert looks_like_pdf(b"%PD") is False + + +# ---- normalize_text ---- + + +def test_normalize_text_strips_trailing_whitespace_per_line() -> None: + assert normalize_text("abc \n def ") == "abc\n def" + + +def test_normalize_text_collapses_consecutive_blank_lines() -> None: + assert normalize_text("a\n\n\n\nb") == "a\n\nb" + + +def test_normalize_text_trims_leading_and_trailing_blanks() -> None: + assert normalize_text("\n\n content \n\n") == "content" + + +# ---- has_sufficient_text ---- + + +def test_has_sufficient_text_true_for_long_text() -> None: + assert has_sufficient_text("あ" * 100) is True + + +def test_has_sufficient_text_false_for_empty_or_short() -> None: + assert has_sufficient_text("") is False + assert has_sufficient_text(" \n ") is False + assert has_sufficient_text("短い") is False + + +# ---- extract_pdf_text ---- + + +def test_extract_pdf_text_returns_embedded_text() -> None: + """テキスト埋め込み PDF から本文テキストを抽出する。 + + テスト環境のフォントに日本語グリフが無く(WeasyPrint が .notdef で描画し pypdf が + 抽出できない)ため、抽出機構自体の検証は ASCII 本文で行う(抽出できるか否かが論点)。 + """ + pdf = _make_pdf( + "

Career summary: backend engineer with five years of experience.

" + "

Self PR: focused on maintainable and well-tested system design.

" + ) + text = extract_pdf_text(pdf) + assert "Career summary" in text + assert "backend engineer" in text + + +def test_extract_pdf_text_rejects_non_pdf_bytes() -> None: + """PDF でないバイト列は PdfExtractionError。""" + with pytest.raises(PdfExtractionError): + extract_pdf_text(b"this is not a pdf") + + +def test_extract_pdf_text_rejects_corrupt_pdf() -> None: + """マジックバイトはあるが壊れた PDF は PdfExtractionError。""" + with pytest.raises(PdfExtractionError): + extract_pdf_text(b"%PDF-1.7\nbroken garbage not a real pdf structure") + + +def test_extract_pdf_text_raises_scanned_for_textless_pdf() -> None: + """テキストがほぼ無い PDF(スキャン相当)は ScannedPdfError。""" + pdf = _make_pdf("

 

") + with pytest.raises(ScannedPdfError): + extract_pdf_text(pdf) + + +@pytest.mark.parametrize("raised", [KeyError("/Root"), RecursionError()]) +def test_extract_pdf_text_wraps_builtin_parser_errors(monkeypatch, raised) -> None: + """pypdf が組み込み例外(KeyError / RecursionError)を漏らしても PdfExtractionError に倒す。 + + マジックバイトは有効だが構造が壊れた PDF で pypdf が PdfReadError 以外を送出しても、 + 500 に漏らさず 422(PdfExtractionError)へマップすることを保証する。 + """ + from app.services.agent.resume_import import text_extract + + def _raise(*_args, _exc=raised, **_kwargs): + raise _exc + + monkeypatch.setattr(text_extract, "PdfReader", _raise) + with pytest.raises(PdfExtractionError): + extract_pdf_text(b"%PDF-1.7\n" + b"x" * 100) diff --git a/backend/uv.lock b/backend/uv.lock index 00ee95d7..e7514950 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -322,6 +322,7 @@ dependencies = [ { name = "pydyf" }, { name = "pygithub" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "pypdf" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "python-dotenv" }, @@ -355,6 +356,7 @@ requires-dist = [ { name = "pydyf", specifier = "==0.12.1" }, { name = "pygithub", specifier = "==2.9.1" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, + { name = "pypdf", specifier = "==6.14.2" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, @@ -1165,6 +1167,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pypdf" +version = "6.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, +] + [[package]] name = "pyphen" version = "0.17.2" diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index feda5800..d141c446 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -101,6 +101,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/resume-import/pdf": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Import Resume Pdf + * @description 手持ちの PDF 経歴書を構造化抽出し、フォーム注入用 payload を返す(ADR-0024)。 + * + * テキスト埋め込み PDF のみ対応(スキャン PDF は 422 で案内)。抽出は Claude Haiku で + * 行い、DB は更新しない(ADR-0010)。結果はフロントがフォーム state へ注入 → ユーザー + * 確認 → 既存の保存 API を呼ぶ。abuse 防止は日次レート制限(#521 / ADR-0023)。 + */ + post: operations["import_resume_pdf_api_agent_resume_import_pdf_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/github-link/cache": { parameters: { query?: never; @@ -931,6 +955,11 @@ export interface components { */ pushed_at: string; }; + /** Body_import_resume_pdf_api_agent_resume_import_pdf_post */ + Body_import_resume_pdf_api_agent_resume_import_pdf_post: { + /** File */ + file: string; + }; /** * CachedGitHubLinkResponse * @description DB に保存された連携結果を返す。 @@ -1424,6 +1453,68 @@ export interface components { */ model: "haiku"; }; + /** + * ResumeImportExperience + * @description PDF から抽出した職歴 1 件(フラット / ADR-0024 v1)。 + * + * フォーム注入用のため全フィールド任意(欠落は空文字)。深いネスト(clients / + * projects / periods / technology_stacks)は v1 では抽出せず、ユーザーがフォームで追記する。 + */ + ResumeImportExperience: { + /** + * Business Description + * @default + */ + business_description: string; + /** + * Company + * @default + */ + company: string; + /** + * Description + * @default + */ + description: string; + /** + * End Date + * @default + */ + end_date: string; + /** + * Start Date + * @default + */ + start_date: string; + }; + /** + * ResumeImportResponse + * @description 手持ち PDF 経歴書の抽出結果(ADR-0024)。 + * + * Resume 互換のフォーム注入用 payload。保存契約(schemas/resume.py の strict な + * バリデーション)とは分離し、抽出できた分だけを返す(全フィールド任意・欠落は空)。 + * DB は更新せず、フロントがフォーム state へ注入 → ユーザー確認 → 既存の保存 API を呼ぶ。 + * email 等の未抽出フィールドはフォームでユーザーが補完する。 + */ + ResumeImportResponse: { + /** + * Career Summary + * @default + */ + career_summary: string; + /** Experiences */ + experiences?: components["schemas"]["ResumeImportExperience"][]; + /** + * Full Name + * @default + */ + full_name: string; + /** + * Self Pr + * @default + */ + self_pr: string; + }; /** ResumeQualificationItem */ ResumeQualificationItem: { /** Acquired Date */ @@ -1930,6 +2021,39 @@ export interface operations { }; }; }; + import_resume_pdf_api_agent_resume_import_pdf_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_import_resume_pdf_api_agent_resume_import_pdf_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResumeImportResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_cache_api_github_link_cache_get: { parameters: { query?: never;