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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
32 changes: 32 additions & 0 deletions backend/app/prompts/agent_resume_import.md
Original file line number Diff line number Diff line change
@@ -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. 各フィールドへ原文を転記する。読み取れないものは空にする(推測で埋めない)
78 changes: 76 additions & 2 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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)
29 changes: 29 additions & 0 deletions backend/app/schemas/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions backend/app/services/agent/resume_import/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""手持ち PDF 経歴書のフォーム流し込み(ADR-0024)。

テキスト埋め込み PDF を pypdf で抽出し、Claude Haiku で Resume 互換 payload に
構造化する。DB 非更新でフォーム注入機構(#524)へ渡す前段まで。
"""
153 changes: 153 additions & 0 deletions backend/app/services/agent/resume_import/import_service.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading