diff --git a/.claude/rules/backend/agent.md b/.claude/rules/backend/agent.md
index 074c3d89..f23213e5 100644
--- a/.claude/rules/backend/agent.md
+++ b/.claude/rules/backend/agent.md
@@ -36,23 +36,27 @@ backend/
│ │ ├── openai_client.py # GPT(ADR-0013)
│ │ ├── ollama_client.py
│ │ └── factory.py # get_llm_client(provider) で分岐(ADR-0013)
-│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018。下記「resume_draft」節)
+│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018・0020。下記「resume_draft」節)
│ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource)
│ ├── mapper.py # ルールベース純関数(骨格 payload 構築)
│ ├── output_schema.py # ドラフト用構造化出力スキーマ(機械制約の正本)
-│ └── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ
+│ ├── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ(DB 非依存)
+│ └── run_task.py # 非同期タスク本体(ADR-0020: LLM→PDF検証→課金→結果保存。DB 書き込みはここ)
└── tests/
├── test_agent.py
├── test_agent_context_builder.py # Phase 2: context_builder の単体テスト
├── test_resume_draft_mapper.py # ADR-0018: ルールベースマッピングの単体テスト
- └── test_resume_draft_service.py # ADR-0018: draft_service(LLM モック)
+ ├── test_resume_draft_service.py # ADR-0018: draft_service(LLM モック)
+ ├── test_resume_draft_api.py # ADR-0020: enqueue/status/download の統合テスト
+ └── test_worker/test_resume_draft.py # ADR-0020: run_resume_draft_task(課金順序の不変条件)
```
-## resume_draft(経歴書ドラフト生成 / ADR-0018)
+## resume_draft(経歴書ドラフト生成 / ADR-0018・0020)
-GitHub 連携データから経歴書ドラフト payload を組み立て、PDF プレビューを返す単発生成機能。
-チャットとは別系統だが、**本ファイルの不変条件(制約の責務分離・リトライ 1 回・エラー契約・
-LLMError/usage の課金漏れ防止)を全て継承する**。
+GitHub 連携データから経歴書ドラフト payload を組み立てて PDF を生成する機能。**ADR-0020 で
+非同期タスク化**した(連携とは別の「ドラフト生成」ボタンで明示実行)。チャットとは別系統だが、
+**本ファイルの不変条件(制約の責務分離・リトライ 1 回・エラー契約・LLMError/usage の課金漏れ防止)を
+全て継承する**。
- **構造はルールベース、自然文だけ LLM**: repo→プロジェクト骨格・技術スタック・期間は
`mapper.py`(純関数)が決定論で写す。LLM が生成するのは career_summary / self_pr /
@@ -60,8 +64,20 @@ LLMError/usage の課金漏れ防止)を全て継承する**。
- **出力スキーマは動的**: `repo_full_name` を選定リポジトリの enum で縛る(捏造リポの構造排除)。
チャットの「プロンプトは静的・スキーマも静的」と異なりリクエストごとに構築するが、
プロンプト md(`agent_resume_draft.md`)自体は静的を維持する(動的情報は user メッセージへ)。
-- **何も永続化しない**: resumes テーブルへ書かない。生成物はレスポンスの PDF だけ
- (クレジット消費・使用ログは例外 / ADR-0012)。DB 読み取りは `context.py` の SELECT のみ。
+- **非同期タスク + 最小永続化(ADR-0020)**: `TaskType.RESUME_DRAFT` の独立タスク。生成 payload
+ だけを連携ドメインの `resume_draft_cache`(1 ユーザー 1 件・最新上書き)に保存し、
+ `GET /api/agent/resume-draft/pdf` で再レンダリングする。**`resumes` テーブルへは書かない**
+ (確定した Resume と混同させない)。DB 書き込み(課金・結果保存・状態遷移)は `run_task.py` と
+ repository に閉じ込め、`draft_service.py` / `mapper.py` / `context.py`(SELECT のみ)の DB 非依存は維持。
+- **課金はタスク側(ADR-0020)**: 残高の事前チェック(402)だけ enqueue で行い、実課金は
+ `run_task.py` が確定する。**PDF レンダリング成功後にのみ課金**(失敗=課金なし)、LLM/パース失敗時は
+ 消費済みトークンを必ず課金、課金記録の失敗は `NonRetryableError` で dead_letter 化(LLM 再実行=再課金を防ぐ)。
+- **二重課金を防ぐ原子性・冪等性(ADR-0020)**: 本課金と結果保存(`completed` + `result`)は
+ **同一セッションの単一トランザクション**で確定する(`record_chat_usage` の commit が staged な
+ cache 変更も一括 flush する)。「課金済みだが結果未保存」の窓を作らないことで、その状態からの
+ リトライ・再配信による再課金を構造的に防ぐ。加えてフェーズA に**冪等ガード**を置き、既に
+ `completed` かつ `result` があるタスク再配信(原子 commit 後・ack 前のクラッシュ)は再実行しない。
+ 手動再実行は router が status を `pending` へ戻すためガードに掛からず、意図どおり再生成する。
- **degrade 方針**: 個別プロジェクトの説明文が欠落・上限超過した場合のみ repo description の
定型文へフォールバック(切り詰めはしない)。career_summary / self_pr の欠落はパース失敗扱い。
diff --git a/backend/alembic_migrations/versions/0047_add_resume_draft_cache_table.py b/backend/alembic_migrations/versions/0047_add_resume_draft_cache_table.py
new file mode 100644
index 00000000..e468b0ea
--- /dev/null
+++ b/backend/alembic_migrations/versions/0047_add_resume_draft_cache_table.py
@@ -0,0 +1,60 @@
+"""経歴書ドラフト生成の非同期タスク用キャッシュテーブルを追加する(ADR-0018 / 非同期化)
+
+- resume_draft_cache: ユーザーごとに最新のドラフト生成 1 件(状態 + 生成 payload)を保持する
+
+新規テーブル作成のみ(op.create_table)で、既存テーブルの再作成は伴わない。
+FK は users を親に持つ。``resumes`` テーブルとは無関係(確定した職務経歴書とは別ドメイン)。
+
+Revision ID: 0047_add_resume_draft_cache_table
+Revises: 0046_add_manifest_path_to_github_skill_evidence
+Create Date: 2026-07-05 00:00:00.000000
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0047_add_resume_draft_cache_table"
+down_revision: Union[str, None] = "0046_add_manifest_path_to_github_skill_evidence"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "resume_draft_cache",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column(
+ "user_id",
+ sa.String(length=36),
+ sa.ForeignKey("users.id"),
+ nullable=False,
+ unique=True,
+ ),
+ sa.Column("result", sa.JSON(), nullable=True),
+ sa.Column(
+ "status", sa.String(length=20), nullable=False, server_default="completed"
+ ),
+ sa.Column("error_message", sa.Text(), nullable=True),
+ sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
+ sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("resume_draft_cache")
diff --git a/backend/app/messages.json b/backend/app/messages.json
index 9386bf27..cc1a3e74 100644
--- a/backend/app/messages.json
+++ b/backend/app/messages.json
@@ -74,7 +74,9 @@
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。",
"draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。",
- "draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。"
+ "draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。",
+ "draft_pdf_failed": "経歴書ドラフトの PDF 生成に失敗しました。もう一度お試しください。",
+ "draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。"
},
"billing": {
"insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
@@ -89,6 +91,10 @@
"github_link": {
"completed": "GitHub連携が完了しました",
"failed": "GitHub連携に失敗しました"
+ },
+ "resume_draft": {
+ "completed": "経歴書ドラフトの生成が完了しました",
+ "failed": "経歴書ドラフトの生成に失敗しました"
}
},
"success": {
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index a9112585..a47831a1 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -2,7 +2,7 @@
from .billing import AgentUsageLog, CreditTransaction
from .blog import BlogAccount, BlogArticle, BlogArticleTag
-from .cache import GitHubLinkCache
+from .cache import GitHubLinkCache, ResumeDraftCache
from .master_data import MQualification, MTechnologyStack
from .notification import Notification
from .resume import (
@@ -41,5 +41,6 @@
"ResumeProjectTeamMember",
"ResumeProjectTechnologyStack",
"ResumeQualification",
+ "ResumeDraftCache",
"User",
]
diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py
index 3a37a931..ae1ef564 100644
--- a/backend/app/models/cache.py
+++ b/backend/app/models/cache.py
@@ -40,3 +40,50 @@ class GitHubLinkCache(Base):
onupdate=func.now(),
nullable=False,
)
+
+
+class ResumeDraftCache(Base):
+ """経歴書ドラフト生成タスクの状態と生成結果のキャッシュ(ADR-0018 / 非同期化)。
+
+ ユーザーごとに最新のドラフト生成 1 件を保持する(``resumes`` テーブルとは無関係。
+ 確定した職務経歴書は ``Resume`` が正本で、本テーブルは連携ドメイン側の生成キャッシュ)。
+ ``result`` には LLM が生成したドラフト payload(``build_resume_pdf`` の入力 dict)を保存し、
+ ダウンロード時に PDF を再レンダリングする(DB にバイナリを持たない)。
+
+ ``status`` / ``error_message`` / ``retry_count`` / ``started_at`` / ``completed_at`` を持ち、
+ 非同期タスク基盤(``AsyncTaskCacheService`` の ``_AsyncTaskRecord`` Protocol)に適合する。
+ """
+
+ __tablename__ = "resume_draft_cache"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("users.id"), unique=True, nullable=False
+ )
+ # LLM 生成のドラフト payload(build_resume_pdf の入力 dict)。完了時のみ非 NULL。
+ result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ status: Mapped[str] = mapped_column(
+ String(20), nullable=False, default="completed", server_default="completed"
+ )
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+ retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+ max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3")
+ started_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
+ )
+ completed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, default=None
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=func.now(),
+ server_default=func.now(),
+ nullable=False,
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=func.now(),
+ server_default=func.now(),
+ onupdate=func.now(),
+ nullable=False,
+ )
diff --git a/backend/app/repositories/resume_draft.py b/backend/app/repositories/resume_draft.py
new file mode 100644
index 00000000..d8d876fa
--- /dev/null
+++ b/backend/app/repositories/resume_draft.py
@@ -0,0 +1,56 @@
+"""経歴書ドラフト生成キャッシュ(``ResumeDraftCache``)のデータアクセス。
+
+``ResumeDraftCache`` はユーザーあたり 1 件のレコードで、``user_id`` を一意境界とする。
+取得・作成クエリを本リポジトリへ集約し、router / handler / task_runner からの直クエリ散在を防ぐ。
+``user_id`` スコープは IDOR 防止の認可境界であり、1 箇所に閉じ込めることで条件追加時の漏れを防ぐ
+(``GitHubLinkCacheRepository`` と同形)。
+"""
+
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from ..models import ResumeDraftCache
+
+
+class ResumeDraftCacheRepository:
+ """ユーザーの経歴書ドラフト生成キャッシュの読み取り・作成。
+
+ セッションはコンストラクタで受け取る。ドラフト生成の実行経路では libSQL の
+ idle stream timeout 対策でフェーズごとにセッションを開閉するため、本リポジトリは
+ セッションを保持せず呼び出し側が渡したものをそのまま使う。
+ """
+
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_by_user(self, user_id: str) -> ResumeDraftCache | None:
+ """ユーザーのキャッシュを取得する。存在しなければ ``None``。"""
+ return self.db.scalar(
+ select(ResumeDraftCache).where(ResumeDraftCache.user_id == user_id)
+ )
+
+ def get_or_create(self, user_id: str) -> ResumeDraftCache:
+ """ユーザーのキャッシュを取得し、存在しなければ作成して flush する。
+
+ 並列リクエストが ``user_id`` の一意制約で衝突した場合は rollback して再取得する。
+ 再 SELECT が ``None`` を返したら ``RuntimeError`` を上げて non-Optional な戻り値契約を守る
+ (.claude/rules/backend/database.md「IntegrityError 後の再 SELECT は None を判定する」)。
+ """
+ cache = self.get_by_user(user_id)
+ if cache is not None:
+ return cache
+
+ cache = ResumeDraftCache(user_id=user_id)
+ self.db.add(cache)
+ try:
+ self.db.flush()
+ except IntegrityError:
+ self.db.rollback()
+ existing = self.get_by_user(user_id)
+ if existing is None:
+ raise RuntimeError(
+ f"ResumeDraftCache の作成と再取得に失敗しました (user_id={user_id})"
+ ) from None
+ return existing
+ return cache
diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py
index 6d51b4eb..5126fdbd 100644
--- a/backend/app/routers/agent.py
+++ b/backend/app/routers/agent.py
@@ -7,17 +7,19 @@
import logging
-from fastapi import APIRouter, Depends, Request
+from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
-from ..core.errors import ErrorCode, raise_app_error
+from ..core.errors import ErrorCode, raise_app_error, resolve_async_error_code
from ..core.messages import get_error
from ..core.security.auth import get_current_user, require_github_user
from ..core.security.dependencies import limiter
from ..db import get_db
from ..models import User
+from ..repositories.resume_draft import ResumeDraftCacheRepository
from ..schemas.agent import AgentChatRequest, AgentChatResponse, ResumeDraftRequest
+from ..schemas.shared import TaskAcceptedResponse, TaskStatusResponse
from ..services.agent import chat_service
from ..services.agent.chat_service import (
AgentResponseParseError,
@@ -31,10 +33,10 @@
ResumeDraftSourceUnavailableError,
build_draft_source,
)
-from ..services.agent.resume_draft.draft_service import run_resume_draft
from ..services.billing import credit_service
from ..services.billing.credit_service import InsufficientCreditsError
from ..services.pdf.generators.resume_generator import build_resume_pdf
+from ..services.tasks import AsyncTaskCacheService, TaskType
from .download_utils import stream_pdf
logger = logging.getLogger(__name__)
@@ -125,23 +127,24 @@ async def agent_chat(
return result.response
-@router.post("/resume-draft/pdf")
+@router.post("/resume-draft/run", response_model=TaskAcceptedResponse, status_code=202)
@limiter.limit("5/minute")
-async def generate_resume_draft_pdf(
+async def start_resume_draft(
request: Request,
body: ResumeDraftRequest,
+ background_tasks: BackgroundTasks,
user: User = Depends(require_github_user),
db: Session = Depends(get_db),
-) -> StreamingResponse:
- """GitHub 連携データから経歴書ドラフトを生成し、PDF で返す(ADR-0018)。
+) -> TaskAcceptedResponse:
+ """GitHub 連携データからの経歴書ドラフト生成をバックグラウンドで開始する(202 / ADR-0018)。
- 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、
- 自然文(職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。
- ドラフトは DB に保存しない(生成物はレスポンスの PDF のみ。
- クレジット消費・使用ログの記録は除く / ADR-0012)。
+ 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、自然文
+ (職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。生成物(payload)は
+ ``resume_draft_cache`` に保存され、``GET /resume-draft/pdf`` でダウンロードできる。
+ 確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。
+ 課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。
"""
- usage_description = f"経歴書ドラフト生成({body.model})"
- # 有料モデルは LLM を呼ぶ前に残高をチェックする(チャットと同一契約 / ADR-0012)
+ # 有料モデルは生成を開始する前に残高をチェックする(チャットと同一契約 / ADR-0012)
try:
credit_service.ensure_can_use_model(db, user.id, body.model)
except InsufficientCreditsError:
@@ -150,10 +153,10 @@ async def generate_resume_draft_pdf(
code=ErrorCode.INSUFFICIENT_CREDITS,
message=get_error("billing.insufficient_credits"),
)
- # 連携キャッシュ + スキル証跡の読み取り(SELECT のみ)。未連携・旧形式・0 件は 409。
+ # 連携キャッシュ + スキル証跡の読み取り(SELECT のみ)で事前検証し、409 を即時に返す。
# 0 件(NoRepositories)は再連携で回復しないため別導線を案内する(サブクラスを先に catch)
try:
- source = build_draft_source(db, user)
+ build_draft_source(db, user)
except ResumeDraftNoRepositoriesError as exc:
logger.info("経歴書ドラフト生成: 分析対象リポジトリなし: %s", exc)
raise_app_error(
@@ -170,36 +173,66 @@ async def generate_resume_draft_pdf(
message=get_error("agent.draft_link_required"),
action="サイドバーの「GitHub連携」から連携を実行してください",
)
+
+ cache = ResumeDraftCacheRepository(db).get_or_create(user.id)
+ service = AsyncTaskCacheService(db, cache)
+ # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら現ステータスを返す。
+ # dead_letter からの再実行も本メソッドが pending へ戻すため、生成ボタンが再試行を兼ねる。
+ if not service.try_reset_to_pending(reset_retry_count=True):
+ return TaskAcceptedResponse(status=cache.status)
+
try:
- result = await run_resume_draft(body.model, source)
- except LLMError as exc:
- # 失敗パスでも消費済みトークンの課金を確定させる(チャットと同一 / ADR-0012)
- if exc.usage is not None:
- try:
- _record_usage_after_llm(db, user.id, exc.usage, description=usage_description)
- except Exception:
- logger.error("LLM 失敗時のクレジット消費記録に失敗", exc_info=True)
+ await service.dispatch(
+ background_tasks,
+ TaskType.RESUME_DRAFT,
+ {"user_id": user.id, "model": body.model},
+ failure_message="タスクの開始に失敗しました",
+ logger=logger,
+ )
+ except Exception:
raise_app_error(
- status_code=502,
- code=ErrorCode.AGENT_LLM_ERROR,
- message=get_error("agent.llm_failed"),
+ status_code=500,
+ code=ErrorCode.INTERNAL_ERROR,
+ message=get_error("task.dispatch_failed"),
+ action="しばらく待ってから再試行してください",
)
- except AgentResponseParseError as exc:
- if exc.usage is not None:
- try:
- _record_usage_after_llm(db, user.id, exc.usage, description=usage_description)
- except Exception:
- logger.error("パース失敗時のクレジット消費記録に失敗", exc_info=True)
+
+ return TaskAcceptedResponse(status="pending")
+
+
+@router.get("/resume-draft/status", response_model=TaskStatusResponse)
+def get_resume_draft_status(
+ user: User = Depends(require_github_user),
+ db: Session = Depends(get_db),
+) -> TaskStatusResponse:
+ """経歴書ドラフト生成タスクのステータスを返す(軽量ポーリング用 / ADR-0018)。"""
+ cache = ResumeDraftCacheRepository(db).get_by_user(user.id)
+ if not cache:
+ return TaskStatusResponse(status="completed")
+ return TaskStatusResponse(
+ status=cache.status,
+ error_message=cache.error_message,
+ error_code=resolve_async_error_code(cache.error_message),
+ )
+
+
+@router.get("/resume-draft/pdf")
+def download_resume_draft_pdf(
+ user: User = Depends(require_github_user),
+ db: Session = Depends(get_db),
+) -> StreamingResponse:
+ """完了済みの経歴書ドラフトを PDF で返す(ADR-0018)。
+
+ 生成タスクが保存した payload から PDF を再レンダリングする(決定論的・DB 非依存)。
+ 生成未完了・結果なしは 409 を返す。
+ """
+ cache = ResumeDraftCacheRepository(db).get_by_user(user.id)
+ if not cache or cache.status != "completed" or not cache.result:
raise_app_error(
- status_code=502,
- code=ErrorCode.AGENT_PARSE_ERROR,
- message=get_error("agent.parse_failed"),
+ status_code=409,
+ code=ErrorCode.VALIDATION_ERROR,
+ message=get_error("agent.draft_not_ready"),
+ action="経歴書ドラフトの生成が完了してから再度お試しください",
)
- # 先に PDF を生成し、成功した場合のみ課金を確定する。build_resume_pdf は DB 非依存の
- # 同期処理なので _record_usage_after_llm(db.close を伴う)より前に実行してよい。
- # PDF 生成失敗(稀な実装/環境エラー)でユーザーに課金しないため、この順序にする。
- # LLM 呼び出し自体の失敗(上の except)はコストが発生済みなので従来どおり課金する(ADR-0012)
- pdf_bytes = build_resume_pdf(result.payload)
- # 実トークン量に基づくクレジット消費 + 使用ログ記録(記録失敗は 500 / ADR-0012)
- _record_usage_after_llm(db, user.id, result.usage, description=usage_description)
+ pdf_bytes = build_resume_pdf(cache.result)
return stream_pdf(pdf_bytes, "career-resume-draft.pdf")
diff --git a/backend/app/services/agent/resume_draft/run_task.py b/backend/app/services/agent/resume_draft/run_task.py
new file mode 100644
index 00000000..500f6faf
--- /dev/null
+++ b/backend/app/services/agent/resume_draft/run_task.py
@@ -0,0 +1,154 @@
+"""経歴書ドラフト生成タスクの実行サービス(非同期 / ADR-0018)。
+
+worker(``services/tasks``)から呼ばれ、LLM でドラフト payload を生成 → PDF レンダリング検証
+→ 課金確定 → ``ResumeDraftCache`` へ保存する。状態遷移・失敗通知は worker が担い、本モジュールは
+成功時に ``status="completed"`` + ``result`` を書き戻し、失敗時は ``NonRetryableError`` を raise する。
+
+DB 書き込み(課金・結果保存・状態遷移)は本モジュールと repository に閉じ込める。
+``draft_service`` / ``mapper`` / ``context`` の DB 非依存原則(context は SELECT のみ)は維持する。
+
+課金順序の不変条件(同期版 router から移設 / ADR-0012・0018):
+ - LLM/パース失敗時は消費済みトークンを **必ず課金** する(API 原価は発生済み)。
+ - PDF レンダリング成功後にのみ本課金を確定する(レンダリング失敗=課金しない)。
+ - 課金記録自体の失敗は ``NonRetryableError`` に包んで dead_letter にする
+ (リトライで LLM を再実行=再課金しないため。「課金漏れ・二重課金を黙って通さない」)。
+
+libSQL (Hrana over HTTP) の idle stream timeout を避けるため、LLM 呼び出しの前後で
+セッションを開閉する(run_github_link と同方針)。
+"""
+
+from datetime import datetime, timezone
+
+from ....core.logging_utils import get_logger
+from ....core.messages import get_error
+from ....models import User
+from ....repositories.resume_draft import ResumeDraftCacheRepository
+from ....services.billing import credit_service
+from ....services.pdf.generators.resume_generator import build_resume_pdf
+from ...tasks.exceptions import NonRetryableError
+from ...tasks.handlers.base import SessionFactory
+from ..chat_service import AgentResponseParseError
+from ..llm.base import LLMError
+from .context import ResumeDraftSourceUnavailableError, build_draft_source
+from .draft_service import run_resume_draft
+
+logger = get_logger(__name__)
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+async def run_resume_draft_task(session_factory: SessionFactory, payload: dict) -> None:
+ """経歴書ドラフトを生成し、結果をキャッシュに保存する。
+
+ フェーズ構成:
+ - A: payload 検証 + 入力取得(SELECT)+ processing マーク(短命セッション)
+ - B: LLM 生成 + PDF レンダリング検証(DB セッション無し)
+ - C: 課金確定 → 結果書き戻し(新セッション)
+
+ 失敗はすべて ``NonRetryableError`` で worker に dead_letter を委ねる
+ (LLM/PDF 失敗はリトライで回復せず再課金の恐れがあるため)。
+ """
+ user_id = payload.get("user_id")
+ model = payload.get("model")
+ if not user_id or not model:
+ message = "経歴書ドラフトタスクのペイロードが不正です"
+ logger.error(message, extra={"payload_keys": list(payload.keys())})
+ raise NonRetryableError(f"{message} (payload_keys={list(payload.keys())})")
+
+ usage_description = f"経歴書ドラフト生成({model})"
+
+ # ── フェーズA: 入力取得 + processing マーク ──────────────────────────────
+ with session_factory() as db:
+ cache = ResumeDraftCacheRepository(db).get_by_user(user_id)
+ if not cache:
+ message = "経歴書ドラフトキャッシュが見つかりません"
+ logger.error(message, extra={"user_id": user_id})
+ raise NonRetryableError(f"{message} (user_id={user_id})")
+ # 冪等ガード: 既に completed かつ result があるなら再実行しない。
+ # フェーズC の課金+結果保存を原子的に確定した後、worker が ack する前に
+ # プロセスが落ちると Cloud Tasks が同一メッセージを再配信しうる。ここで
+ # 短絡しないと LLM を再実行して二重課金する(手動再実行は router が status を
+ # pending へ戻すため本ガードには掛からない)。
+ if cache.status == "completed" and cache.result:
+ logger.info("経歴書ドラフトは完了済みのため再実行をスキップ", extra={"user_id": user_id})
+ return
+ user = db.get(User, user_id)
+ if not user:
+ message = "ユーザーが見つかりません"
+ logger.error(message, extra={"user_id": user_id})
+ raise NonRetryableError(f"{message} (user_id={user_id})")
+ # 連携データの取得(SELECT のみ)。enqueue 時に事前検証済みだが、実行時点で
+ # 連携が失われた/旧形式のケースを二重ガードする。回復には再連携が要るため NonRetryable。
+ try:
+ source = build_draft_source(db, user)
+ except ResumeDraftSourceUnavailableError as exc:
+ logger.info("経歴書ドラフト生成の入力が未整備: %s", exc)
+ raise NonRetryableError(get_error("agent.draft_link_required")) from exc
+
+ cache.status = "processing"
+ cache.started_at = _now()
+ cache.error_message = None
+ db.commit()
+
+ # ── フェーズB: LLM 生成 + PDF レンダリング検証(DB セッション無し)──────
+ try:
+ result = await run_resume_draft(model, source)
+ except LLMError as exc:
+ _charge_consumed_usage(session_factory, user_id, exc.usage, usage_description)
+ raise NonRetryableError(get_error("agent.llm_failed")) from exc
+ except AgentResponseParseError as exc:
+ _charge_consumed_usage(session_factory, user_id, exc.usage, usage_description)
+ raise NonRetryableError(get_error("agent.parse_failed")) from exc
+
+ # PDF レンダリングを検証する。失敗した場合は課金せず dead_letter にする
+ # (稀な実装/環境エラーでユーザーに課金しない不変条件 / 同期版 router と同一)。
+ try:
+ build_resume_pdf(result.payload)
+ except Exception as exc:
+ logger.error("経歴書ドラフト PDF のレンダリングに失敗", exc_info=True)
+ raise NonRetryableError(get_error("agent.draft_pdf_failed")) from exc
+
+ # ── フェーズC: 課金確定 + 結果書き戻し(単一トランザクションで原子的に)──────
+ # 本課金は PDF レンダリング成功後にのみ行う。課金と結果保存は**同一セッション**で
+ # staged し、``record_chat_usage`` 内の commit が両者を 1 トランザクションで確定する
+ # (SQLAlchemy の commit は pending 変更を一括 flush する)。これにより「課金済みだが
+ # 結果未保存」の窓が無くなり、その状態からのリトライによる二重課金が構造的に起きない。
+ # 課金記録の失敗は同一トランザクションを rollback(結果保存も巻き戻る)した上で
+ # NonRetryable に包み dead_letter 化する(リトライで LLM を再実行=再課金しないため)。
+ with session_factory() as db:
+ cache = ResumeDraftCacheRepository(db).get_by_user(user_id)
+ if not cache:
+ # 結果の保存先(ユーザーの ResumeDraftCache)が消えた(例: ユーザー削除の
+ # CASCADE)。保存先が無いだけで課金確定はせず終了する。
+ logger.warning("結果書き戻し時にキャッシュが見つかりません", extra={"user_id": user_id})
+ return
+ cache.result = result.payload
+ cache.status = "completed"
+ cache.error_message = None
+ cache.completed_at = _now()
+ try:
+ credit_service.record_chat_usage(
+ db, user_id, result.usage, description=usage_description
+ )
+ except Exception as exc:
+ logger.error("経歴書ドラフト生成のクレジット消費記録に失敗", exc_info=True)
+ raise NonRetryableError("課金記録に失敗しました") from exc
+
+
+def _charge_consumed_usage(
+ session_factory: SessionFactory, user_id: str, usage, usage_description: str
+) -> None:
+ """LLM/パース失敗時に、消費済みトークン(API 原価が発生済み)を課金する。
+
+ 課金記録自体の失敗はログに残して握りつぶす(本来の LLM/パース失敗を dead_letter として
+ 確定させることを優先する。課金の取りこぼしより二重の状態破壊を避ける / ADR-0012)。
+ """
+ if usage is None:
+ return
+ with session_factory() as db:
+ try:
+ credit_service.record_chat_usage(db, user_id, usage, description=usage_description)
+ except Exception:
+ logger.error("失敗パスでのクレジット消費記録に失敗", exc_info=True)
diff --git a/backend/app/services/tasks/base.py b/backend/app/services/tasks/base.py
index 4b1bcbb8..57f629eb 100644
--- a/backend/app/services/tasks/base.py
+++ b/backend/app/services/tasks/base.py
@@ -8,6 +8,7 @@ class TaskType(str, Enum):
"""バックグラウンドで実行可能なタスクの種別。"""
GITHUB_LINK = "github_link"
+ RESUME_DRAFT = "resume_draft"
# 手動再実行を許可するキャッシュレコードのステータス集合。
diff --git a/backend/app/services/tasks/handlers/__init__.py b/backend/app/services/tasks/handlers/__init__.py
index 93d6bac1..10217a6d 100644
--- a/backend/app/services/tasks/handlers/__init__.py
+++ b/backend/app/services/tasks/handlers/__init__.py
@@ -9,9 +9,11 @@
from ..base import TaskType
from .base import TaskHandler
from .github_link import GitHubLinkHandler
+from .resume_draft import ResumeDraftHandler
_HANDLERS: Dict[TaskType, TaskHandler] = {
TaskType.GITHUB_LINK: GitHubLinkHandler(),
+ TaskType.RESUME_DRAFT: ResumeDraftHandler(),
}
diff --git a/backend/app/services/tasks/handlers/resume_draft.py b/backend/app/services/tasks/handlers/resume_draft.py
new file mode 100644
index 00000000..c439f46b
--- /dev/null
+++ b/backend/app/services/tasks/handlers/resume_draft.py
@@ -0,0 +1,23 @@
+"""経歴書ドラフト生成タスクのハンドラ(ADR-0018 / 非同期化)。"""
+
+from sqlalchemy.orm import Session
+
+from ....models import ResumeDraftCache
+from ....repositories.resume_draft import ResumeDraftCacheRepository
+from .base import SessionFactory, TaskHandler
+
+
+class ResumeDraftHandler(TaskHandler):
+ """経歴書ドラフト生成タスク。連携データ → LLM → PDF レンダリング検証 → 保存。"""
+
+ def get_record(self, db: Session, payload: dict) -> ResumeDraftCache | None:
+ user_id = payload.get("user_id")
+ if not user_id:
+ return None
+ return ResumeDraftCacheRepository(db).get_by_user(user_id)
+
+ async def run(self, session_factory: SessionFactory, payload: dict) -> None:
+ # 循環インポート回避のため遅延 import する
+ from ...agent.resume_draft.run_task import run_resume_draft_task
+
+ await run_resume_draft_task(session_factory, payload)
diff --git a/backend/tests/test_resume_draft_api.py b/backend/tests/test_resume_draft_api.py
index 0519aabe..7797c85e 100644
--- a/backend/tests/test_resume_draft_api.py
+++ b/backend/tests/test_resume_draft_api.py
@@ -1,72 +1,29 @@
-"""経歴書ドラフト PDF エンドポイント(POST /api/agent/resume-draft/pdf)の統合テスト(ADR-0018)。
+"""経歴書ドラフト生成エンドポイント(非同期 / ADR-0018)の統合テスト。
-LLM のみモックし、認可ガード・課金配線・409/502 のエラーマッピング・PDF 生成は
-実コードを通す(DB は実 SQLite セッション)。
+``POST /api/agent/resume-draft/run``(enqueue)/ ``GET .../status``(ポーリング)/
+``GET .../pdf``(ダウンロード)を実コードで通す(DB は実 SQLite セッション)。
+
+``client`` fixture は ``execute_task`` を no-op に差し替えるため、本ファイルでは enqueue の
+受付・状態遷移・認可・バリデーションと、完了済みキャッシュからのダウンロードを検証する。
+生成タスク本体(LLM → PDF → 課金 → completed)の検証は
+``tests/test_worker/test_resume_draft.py`` で行う。
"""
-import json
+from datetime import date
-import pytest
-from app.models import GitHubLinkCache, User
-from app.models.billing import AgentUsageLog
-from app.models.skill import GitHubSkill, GitHubSkillEvidence
-from app.schemas.github_link import GitHubLinkResponse
-from app.services.agent.llm.base import LLMClient, LLMResult
-from app.services.agent.resume_draft import draft_service
+from app.models import GitHubLinkCache, ResumeDraftCache, User
+from app.schemas.github_link import AnalyzedRepoSummary, GitHubLinkResponse
+from app.services.agent.resume_draft.context import DraftSource, RepoTechnology
+from app.services.agent.resume_draft.mapper import build_skeleton, select_repos
from fastapi.testclient import TestClient
from conftest import auth_header
-def _flatten_exceptions(exc: BaseException) -> list[BaseException]:
- """例外を leaf まで平坦化する(ExceptionGroup を再帰展開)。
-
- TestClient から伝播する例外は環境により RuntimeError 単体だったり、Starlette の
- TaskGroup で ExceptionGroup にラップされたりする。中身の例外を型・メッセージで
- 検証できるよう、どちらの形でも leaf の列にそろえる。
- """
- if isinstance(exc, BaseExceptionGroup):
- leaves: list[BaseException] = []
- for sub in exc.exceptions:
- leaves.extend(_flatten_exceptions(sub))
- return leaves
- return [exc]
-
-
-class _FakeLLM(LLMClient):
- """固定応答を返すテスト用 LLM クライアント。"""
-
- def __init__(self, response: str, input_tokens: int = 100, output_tokens: int = 200):
- """固定応答とトークン実測値(課金記録の検証用)をセットする。"""
- self._response = response
- self._input_tokens = input_tokens
- self._output_tokens = output_tokens
-
- async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult:
- """固定応答を返す。"""
- return LLMResult(
- text=self._response,
- input_tokens=self._input_tokens,
- output_tokens=self._output_tokens,
- )
-
-
-def _draft_response() -> str:
- """契約に沿ったドラフト応答 JSON を返す。"""
- return json.dumps(
- {
- "career_summary": "生成された職務要約。",
- "self_pr": "生成された自己PR。",
- "project_descriptions": [
- {"repo_full_name": "octo/app", "description": "アプリの説明。"}
- ],
- },
- ensure_ascii=False,
- )
-
-
def _seed_link_data(db, username: str = "testuser", *, legacy: bool = False) -> None:
"""連携キャッシュ(+ スキル証跡)を投入する。legacy=True で repos 無しの旧形式。"""
+ from app.models.skill import GitHubSkill, GitHubSkillEvidence
+
user = db.query(User).filter_by(username=username).one()
result = {
"username": username,
@@ -98,58 +55,78 @@ def _seed_link_data(db, username: str = "testuser", *, legacy: bool = False) ->
db.commit()
-def test_resume_draft_pdf_success(client: TestClient, monkeypatch) -> None:
- """ハッピーパス: PDF が返り、使用ログ(無料モデルも対象)が記録される。"""
- headers = auth_header(client, github_id=1)
- _seed_link_data(client._db_session)
- monkeypatch.setattr(
- draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response())
+def _draft_payload() -> dict:
+ """build_resume_pdf が受け取れる有効なドラフト payload を決定論的に組み立てる。"""
+ source = DraftSource(
+ username="testuser",
+ email="testuser@example.com",
+ repos=[
+ AnalyzedRepoSummary(
+ full_name="octo/app",
+ description="タスク管理アプリ",
+ created_at="2024-01-01T00:00:00Z",
+ pushed_at="2026-06-01T00:00:00Z",
+ )
+ ],
+ repo_technologies={
+ "octo/app": [RepoTechnology(category="language", name="Python", confidence=0.9, language_bytes=1000)]
+ },
)
+ selected = select_repos(source)
+ payload = build_skeleton(source, selected, today=date(2026, 6, 15))
+ payload["career_summary"] = "生成された職務要約。"
+ payload["self_pr"] = "生成された自己PR。"
+ return payload
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
- assert res.status_code == 200
- assert res.headers["content-type"] == "application/pdf"
- assert res.content.startswith(b"%PDF")
+# ── enqueue(POST /run)──────────────────────────────────────────────────
+
+
+def test_resume_draft_run_enqueues(client: TestClient) -> None:
+ """連携済みなら 202 を返し、キャッシュが pending へ遷移する。"""
+ headers = auth_header(client, github_id=1)
+ _seed_link_data(client._db_session)
+
+ res = client.post("/api/agent/resume-draft/run", json={"model": "haiku"}, headers=headers)
- # 課金・使用ログの配線(ADR-0012): 無料モデルでもログのみ記録される
- (log,) = client._db_session.query(AgentUsageLog).all()
- assert log.model_alias == "haiku"
- assert log.input_tokens == 100
- assert log.output_tokens == 200
- assert log.credit_cost == 0
+ assert res.status_code == 202
+ assert res.json()["status"] == "pending"
+ cache = client._db_session.query(ResumeDraftCache).filter_by(
+ user_id=client._db_session.query(User).filter_by(username="testuser").one().id
+ ).one()
+ assert cache.status == "pending"
-def test_resume_draft_pdf_requires_github_login(client: TestClient) -> None:
+def test_resume_draft_run_requires_github_login(client: TestClient) -> None:
"""GitHub 未連携ユーザー(github_id 無し)は 403。"""
headers = auth_header(client)
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
+ res = client.post("/api/agent/resume-draft/run", json={"model": "haiku"}, headers=headers)
assert res.status_code == 403
-def test_resume_draft_pdf_requires_credits_for_paid_model(client: TestClient) -> None:
- """有料モデルは残高 0 だと LLM を呼ぶ前に 402。"""
+def test_resume_draft_run_requires_credits_for_paid_model(client: TestClient) -> None:
+ """有料モデルは残高 0 だと enqueue 前に 402。"""
headers = auth_header(client, github_id=1)
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "sonnet"}, headers=headers)
+ res = client.post("/api/agent/resume-draft/run", json={"model": "sonnet"}, headers=headers)
assert res.status_code == 402
-def test_resume_draft_pdf_conflict_without_link_cache(client: TestClient) -> None:
+def test_resume_draft_run_conflict_without_link_cache(client: TestClient) -> None:
"""連携未実行は 409(GitHub 連携の実行を促す)。"""
headers = auth_header(client, github_id=1)
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
+ res = client.post("/api/agent/resume-draft/run", json={"model": "haiku"}, headers=headers)
assert res.status_code == 409
-def test_resume_draft_pdf_conflict_with_legacy_cache(client: TestClient) -> None:
- """repos キーを持たない旧形式キャッシュ(ADR-0018 以前の連携結果)は 409(再連携を促す)。"""
+def test_resume_draft_run_conflict_with_legacy_cache(client: TestClient) -> None:
+ """repos キーを持たない旧形式キャッシュは 409(再連携を促す)。"""
headers = auth_header(client, github_id=1)
_seed_link_data(client._db_session, legacy=True)
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
+ res = client.post("/api/agent/resume-draft/run", json={"model": "haiku"}, headers=headers)
assert res.status_code == 409
-def test_resume_draft_pdf_conflict_with_zero_repositories(client: TestClient) -> None:
+def test_resume_draft_run_conflict_with_zero_repositories(client: TestClient) -> None:
"""新形式で分析対象リポジトリが 0 件(repos: [])は 409(旧形式とは別メッセージ)。"""
headers = auth_header(client, github_id=1)
user = client._db_session.query(User).filter_by(username="testuser").one()
@@ -169,68 +146,84 @@ def test_resume_draft_pdf_conflict_with_zero_repositories(client: TestClient) ->
)
client._db_session.commit()
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
+ res = client.post("/api/agent/resume-draft/run", json={"model": "haiku"}, headers=headers)
assert res.status_code == 409
- # 旧形式(draft_link_required)ではなく 0 件専用メッセージが返る
assert "公開リポジトリ" in res.json()["message"]
-def test_resume_draft_pdf_parse_failure_returns_502(client: TestClient, monkeypatch) -> None:
- """リトライ後も契約違反なら 502(消費済みトークンの使用ログは記録される)。"""
+def test_resume_draft_run_invalid_model_rejected(client: TestClient) -> None:
+ """未知のモデルエイリアスはスキーマ検証で 422。"""
headers = auth_header(client, github_id=1)
- _seed_link_data(client._db_session)
- monkeypatch.setattr(
- draft_service, "get_llm_client", lambda provider: _FakeLLM("JSON ではない応答")
+ res = client.post(
+ "/api/agent/resume-draft/run", json={"model": "gpt-999"}, headers=headers
)
+ assert res.status_code == 422
- res = client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
- assert res.status_code == 502
- # 失敗パスでも 2 回分の合算トークンが記録される(課金漏れ防止 / ADR-0012)
- (log,) = client._db_session.query(AgentUsageLog).all()
- assert log.input_tokens == 200
- assert log.output_tokens == 400
+# ── status(GET /status)────────────────────────────────────────────────
-def test_resume_draft_pdf_generation_failure_not_charged(
- client: TestClient, monkeypatch
-) -> None:
- """PDF 生成が失敗した場合はユーザーに課金しない(使用ログも残さない / CodeRabbit 指摘)。"""
- from app.routers import agent as agent_router
+def test_resume_draft_status_defaults_completed_without_cache(client: TestClient) -> None:
+ """キャッシュが無ければ completed(アイドル)を返す。"""
+ headers = auth_header(client, github_id=1)
+ res = client.get("/api/agent/resume-draft/status", headers=headers)
+ assert res.status_code == 200
+ assert res.json()["status"] == "completed"
+
+def test_resume_draft_status_reports_dead_letter(client: TestClient) -> None:
+ """dead_letter とエラーメッセージがステータスに反映される。"""
headers = auth_header(client, github_id=1)
- _seed_link_data(client._db_session)
- monkeypatch.setattr(
- draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response())
+ user = client._db_session.query(User).filter_by(username="testuser").one()
+ client._db_session.add(
+ ResumeDraftCache(
+ user_id=user.id, status="dead_letter", error_message="AI の応答取得に失敗しました。"
+ )
)
+ client._db_session.commit()
- def _fail_pdf(_payload):
- raise RuntimeError("PDF 生成失敗")
+ res = client.get("/api/agent/resume-draft/status", headers=headers)
+ assert res.status_code == 200
+ body = res.json()
+ assert body["status"] == "dead_letter"
+ assert body["error_message"] == "AI の応答取得に失敗しました。"
- monkeypatch.setattr(agent_router, "build_resume_pdf", _fail_pdf)
- # PDF 生成失敗は raise_app_error を通らない生の例外で、TestClient から伝播する。
- # ExceptionGroup を pytest.raises に直接渡すと pytest がグループ検査モードになり
- # 素直にマッチしないため、いったん BaseException で捕捉してから中身を検証する。
- with pytest.raises(BaseException) as exc_info: # noqa: B017, PT011
- client.post("/api/agent/resume-draft/pdf", json={"model": "haiku"}, headers=headers)
- # 環境により RuntimeError が ExceptionGroup にラップされるため平坦化し、
- # _fail_pdf が投げた PDF 生成失敗の例外であることまで確認する(無関係な例外での誤検知を防ぐ)
- leaves = _flatten_exceptions(exc_info.value)
- assert any(
- isinstance(e, RuntimeError) and "PDF 生成失敗" in str(e) for e in leaves
- ), f"想定した PDF 生成失敗の例外ではありません: {leaves}"
- # 課金は PDF 生成成功後にのみ行うため、使用ログは記録されない
- assert client._db_session.query(AgentUsageLog).count() == 0
+# ── download(GET /pdf)─────────────────────────────────────────────────
-def test_resume_draft_pdf_invalid_model_rejected(client: TestClient) -> None:
- """未知のモデルエイリアスはスキーマ検証で 422。"""
+def test_resume_draft_pdf_download_success(client: TestClient) -> None:
+ """完了済みキャッシュの payload から PDF が再レンダリングされて返る。"""
headers = auth_header(client, github_id=1)
- res = client.post(
- "/api/agent/resume-draft/pdf", json={"model": "gpt-999"}, headers=headers
+ user = client._db_session.query(User).filter_by(username="testuser").one()
+ client._db_session.add(
+ ResumeDraftCache(user_id=user.id, status="completed", result=_draft_payload())
)
- assert res.status_code == 422
+ client._db_session.commit()
+
+ res = client.get("/api/agent/resume-draft/pdf", headers=headers)
+
+ assert res.status_code == 200
+ assert res.headers["content-type"] == "application/pdf"
+ assert res.content.startswith(b"%PDF")
+
+
+def test_resume_draft_pdf_download_not_ready(client: TestClient) -> None:
+ """未生成(キャッシュ無し)のダウンロードは 409。"""
+ headers = auth_header(client, github_id=1)
+ res = client.get("/api/agent/resume-draft/pdf", headers=headers)
+ assert res.status_code == 409
+
+
+def test_resume_draft_pdf_download_conflict_while_processing(client: TestClient) -> None:
+ """生成中(processing・result 無し)のダウンロードは 409。"""
+ headers = auth_header(client, github_id=1)
+ user = client._db_session.query(User).filter_by(username="testuser").one()
+ client._db_session.add(ResumeDraftCache(user_id=user.id, status="processing"))
+ client._db_session.commit()
+
+ res = client.get("/api/agent/resume-draft/pdf", headers=headers)
+ assert res.status_code == 409
def test_github_link_response_backward_compat_without_repos() -> None:
diff --git a/backend/tests/test_worker/test_resume_draft.py b/backend/tests/test_worker/test_resume_draft.py
new file mode 100644
index 00000000..2dfd9b18
--- /dev/null
+++ b/backend/tests/test_worker/test_resume_draft.py
@@ -0,0 +1,190 @@
+"""run_resume_draft_task(経歴書ドラフト生成タスク本体 / ADR-0018 非同期化)の単体テスト。
+
+LLM のみモックし、連携データ読み取り・ルールベースマッピング・PDF レンダリング・課金配線は
+実コードを通す(DB は実 SQLite セッション)。課金順序の不変条件を固定する:
+ - 成功: completed + result 保存 + 使用ログ記録(無料モデルは credit_cost=0)。
+ - パース失敗: 消費済みトークンを課金してから NonRetryableError(worker が dead_letter 化)。
+ - PDF 生成失敗: 課金せず NonRetryableError(使用ログを残さない)。
+"""
+
+import json
+
+import pytest
+from app.models import GitHubLinkCache, ResumeDraftCache
+from app.models.billing import AgentUsageLog
+from app.models.skill import GitHubSkill, GitHubSkillEvidence
+from app.repositories import UserRepository
+from app.services.agent.llm.base import LLMClient, LLMResult
+from app.services.agent.resume_draft import draft_service, run_task
+from app.services.agent.resume_draft.run_task import run_resume_draft_task
+from app.services.tasks.exceptions import NonRetryableError
+from sqlalchemy.orm import Session
+
+from ._helpers import run_sync as _run
+
+
+class _FakeLLM(LLMClient):
+ """固定応答を返すテスト用 LLM クライアント。"""
+
+ def __init__(self, response: str, input_tokens: int = 100, output_tokens: int = 200):
+ self._response = response
+ self._input_tokens = input_tokens
+ self._output_tokens = output_tokens
+
+ async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult:
+ return LLMResult(
+ text=self._response,
+ input_tokens=self._input_tokens,
+ output_tokens=self._output_tokens,
+ )
+
+
+def _draft_response() -> str:
+ """契約に沿ったドラフト応答 JSON。"""
+ return json.dumps(
+ {
+ "career_summary": "生成された職務要約。",
+ "self_pr": "生成された自己PR。",
+ "project_descriptions": [
+ {"repo_full_name": "octo/app", "description": "アプリの説明。"}
+ ],
+ },
+ ensure_ascii=False,
+ )
+
+
+def _seed(db: Session, username: str = "draft-user", *, repos: bool = True):
+ """ユーザー + 連携キャッシュ(+ スキル証跡)+ ドラフトキャッシュ(pending) を投入する。"""
+ user = UserRepository(db).create(username, email=f"{username}@test.com")
+ result = {
+ "username": username,
+ "repos_analyzed": 1 if repos else 0,
+ "unique_skills": 1 if repos else 0,
+ "analyzed_at": "2026-06-01T00:00:00",
+ "languages": {"Python": 1000} if repos else {},
+ "repos": (
+ [
+ {
+ "full_name": "octo/app",
+ "description": "タスク管理アプリ",
+ "created_at": "2024-01-01T00:00:00Z",
+ "pushed_at": "2026-06-01T00:00:00Z",
+ }
+ ]
+ if repos
+ else []
+ ),
+ }
+ db.add(GitHubLinkCache(user_id=user.id, status="completed", result=result))
+ if repos:
+ skill = GitHubSkill(user_id=user.id, kind="language", canonical_name="Python")
+ skill.evidence = [
+ GitHubSkillEvidence(
+ repo_full_name="octo/app",
+ signal_source="language_bytes",
+ confidence=0.9,
+ language_bytes=1000,
+ )
+ ]
+ db.add(skill)
+ db.add(ResumeDraftCache(user_id=user.id, status="pending"))
+ db.commit()
+ return user
+
+
+def test_task_completes_and_records_usage(db_session, session_factory, monkeypatch):
+ """成功: status=completed + result 保存 + 使用ログ記録(無料モデルは credit_cost=0)。"""
+ user = _seed(db_session)
+ monkeypatch.setattr(draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response()))
+
+ _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"}))
+
+ db_session.expire_all()
+ draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one()
+ assert draft.status == "completed"
+ assert draft.result is not None
+ assert draft.result["career_summary"] == "生成された職務要約。"
+ assert draft.completed_at is not None
+
+ (log,) = db_session.query(AgentUsageLog).all()
+ assert log.model_alias == "haiku"
+ assert log.input_tokens == 100
+ assert log.output_tokens == 200
+ assert log.credit_cost == 0
+
+
+def test_task_parse_failure_charges_and_raises(db_session, session_factory, monkeypatch):
+ """パース失敗: 消費済みトークン(2 コール合算)を課金し NonRetryableError を送出する。"""
+ user = _seed(db_session)
+ monkeypatch.setattr(
+ draft_service, "get_llm_client", lambda provider: _FakeLLM("JSON ではない応答")
+ )
+
+ with pytest.raises(NonRetryableError):
+ _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"}))
+
+ db_session.expire_all()
+ (log,) = db_session.query(AgentUsageLog).all()
+ assert log.input_tokens == 200
+ assert log.output_tokens == 400
+
+
+def test_task_pdf_failure_not_charged(db_session, session_factory, monkeypatch):
+ """PDF 生成失敗: 課金せず NonRetryableError を送出する(使用ログを残さない)。"""
+ user = _seed(db_session)
+ monkeypatch.setattr(draft_service, "get_llm_client", lambda provider: _FakeLLM(_draft_response()))
+
+ def _fail_pdf(_payload):
+ raise RuntimeError("PDF 生成失敗")
+
+ monkeypatch.setattr(run_task, "build_resume_pdf", _fail_pdf)
+
+ with pytest.raises(NonRetryableError):
+ _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"}))
+
+ db_session.expire_all()
+ assert db_session.query(AgentUsageLog).count() == 0
+
+
+def test_task_source_unavailable_raises_non_retryable(db_session, session_factory):
+ """分析対象リポジトリ 0 件は回復しないため NonRetryableError。"""
+ user = _seed(db_session, "no-repos-user", repos=False)
+ with pytest.raises(NonRetryableError):
+ _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"}))
+
+
+def test_task_skips_when_already_completed(db_session, session_factory, monkeypatch):
+ """冪等ガード: 既に completed + result のタスク再配信は再実行せず、課金も LLM 呼び出しもしない。
+
+ 原子 commit 後・worker の ack 前にプロセスが落ちると Cloud Tasks が同一メッセージを
+ 再配信しうる。フェーズA の短絡が無いと LLM を再実行して二重課金するため、その回帰を固定する。
+ """
+ user = _seed(db_session)
+ draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one()
+ draft.status = "completed"
+ draft.result = {"career_summary": "既存の結果。", "self_pr": "既存。", "project_descriptions": []}
+ db_session.commit()
+
+ def _fail_llm(provider):
+ raise AssertionError("完了済みタスクで LLM を呼んではならない")
+
+ monkeypatch.setattr(draft_service, "get_llm_client", _fail_llm)
+
+ _run(run_resume_draft_task(session_factory, {"user_id": user.id, "model": "haiku"}))
+
+ db_session.expire_all()
+ # 課金(使用ログ)は発生しない。既存の completed 結果も上書きされない。
+ assert db_session.query(AgentUsageLog).count() == 0
+ draft = db_session.query(ResumeDraftCache).filter_by(user_id=user.id).one()
+ assert draft.status == "completed"
+ assert draft.result["career_summary"] == "既存の結果。"
+
+
+def test_task_missing_cache_raises_non_retryable(session_factory):
+ """ドラフトキャッシュが無い場合は NonRetryableError(worker が dead_letter 化)。"""
+ with pytest.raises(NonRetryableError):
+ _run(
+ run_resume_draft_task(
+ session_factory, {"user_id": "nonexistent-user-id", "model": "haiku"}
+ )
+ )
diff --git a/docs/adr/0020-async-resume-draft-generation.md b/docs/adr/0020-async-resume-draft-generation.md
new file mode 100644
index 00000000..8d022533
--- /dev/null
+++ b/docs/adr/0020-async-resume-draft-generation.md
@@ -0,0 +1,84 @@
+# ADR-0020: 経歴書ドラフト生成の非同期化と最小永続化
+
+## ステータス
+
+Accepted
+
+## 関連 ADR
+
+- 関連: ADR-0018(経歴書ドラフト生成の同期実装を非同期化・最小永続化で更新)、ADR-0010(Agent の不変条件を継承)、ADR-0012(課金配線をタスク側へ移設)、ADR-0016(連携ドメインのデータ供給源)
+
+## コンテキスト
+
+ADR-0018 で導入した「GitHub 連携データからの経歴書ドラフト生成」は同期エンドポイント
+(`POST /api/agent/resume-draft/pdf`)で、LLM を 1 回呼び(十数秒〜数十秒)→ WeasyPrint で
+PDF を生成 → **レスポンス本文でそのまま PDF を返す**設計だった。ADR-0018 は「何も永続化しない
+(レスポンスが唯一の成果物)」を明示の不変条件にしていた。
+
+この同期設計には次の課題がある:
+
+- 生成中はブラウザが待たされ、タブを閉じる・バックグラウンドにすると生成が無駄になる。
+- 既存の GitHub 連携(`GITHUB_LINK`)は非同期タスク基盤(Cloud Tasks / BackgroundTasks +
+ ステータスポーリング + 通知)を持つのに、ドラフト生成だけ同期で UX が不揃い。
+
+ユーザー要望は「連携ボタン」と「連携後のドラフト生成ボタン」を分け、ドラフト生成を
+バックグラウンドでも完了する非同期タスクにすること。非同期化すると、ワーカーはリクエスト応答後に
+完了するため、**成果物を後から取得できるよう永続化が避けられない**(= ADR-0018 の「何も永続化
+しない」を見直す必要がある)。
+
+## 決定内容
+
+ドラフト生成を、GitHub 連携とは独立した非同期タスク(`TaskType.RESUME_DRAFT`)にする。
+
+- **最小永続化**: LLM が生成したドラフト payload(`build_resume_pdf` の入力 dict)だけを、連携
+ ドメインの独立キャッシュ `resume_draft_cache`(1 ユーザー 1 件・最新上書き)の `result:JSON` に
+ 保存する。**PDF バイト列は保存しない**。ダウンロード(`GET /api/agent/resume-draft/pdf`)は
+ 保存済み payload から PDF を再レンダリングする(決定論的・DB 非依存)。`resumes` テーブル
+ (確定した職務経歴書 = `Resume`)には一切書き込まない(ドラフトと Resume を混同させない)。
+- **エンドポイント**: `POST .../run`(202 で受付)/ `GET .../status`(ポーリング)/
+ `GET .../pdf`(完了後のダウンロード)。旧同期 `POST .../pdf` は廃止。
+- **課金の移設**: 残高の事前チェック(402)だけ enqueue 側に残し、実課金はタスク本体
+ (`run_resume_draft_task`)で確定する。ADR-0018 の課金順序の不変条件を維持する:
+ - PDF レンダリング成功後にのみ本課金を確定する(レンダリング失敗=課金しない)。
+ - LLM/パース失敗時は消費済みトークンを必ず課金する(API 原価は発生済み / ADR-0012)。
+ - 課金記録の失敗は `NonRetryableError` に包んで dead_letter にし、リトライによる LLM 再実行
+ (=再課金)を防ぐ。
+- **既存基盤の再利用**: 状態遷移・リトライ・dead_letter・完了/失敗通知は既存 worker が担い、
+ フロントは既存 `useTaskPolling` / `AsyncTaskLoading` を再利用する。ADR-0010 の不変条件
+ (制約の責務分離・リトライ 1 回・エラー契約・`LLMError`/usage の課金漏れ防止)は継承する。
+
+## 代替案
+
+- **PDF バイト列を BLOB 保存**: ダウンロードは再レンダリング不要で課金=配信の整合が単純。ただし
+ Turso/libSQL にバイナリ(~100KB/件)を持つ。ドラフトは決定論的に再生成できるため payload 保存を採る。
+- **GCS 等の外部ストレージ**: 大容量・DB 非依存だが、現状バックエンドにストレージ層が無く、バケット・
+ IAM・env_keys 4 箇所同期のインフラ追加が必要でスコープ過大。
+- **連携タスクへ畳み込み**: 連携(`GITHUB_LINK`)完了時にドラフトも自動生成する案。連携のたびに
+ LLM 課金が走り、モデル選択やドラフト不要ケースを扱いにくい。ユーザー要望(連携とドラフトはボタン分離・
+ 明示実行)にも反するため独立タスクにする。
+
+## トレードオフ・既知のリスク
+
+- ADR-0018 の「何も永続化しない」原則を、連携ドメインへのドラフト payload 最小永続化へ緩める。
+ `resumes` には書かない原則は維持し、保存対象はユーザー自身の公開 GitHub 由来の生成データに限る。
+- 課金確定後・結果書き戻し前の稀な失敗では「課金済みだが未 completed」になりうる(再実行で二重課金の
+ 可能性)。窓は極小で、`NonRetryableError` により沈黙せず dead_letter として観測できる方を優先する。
+- ダウンロード時に PDF を再レンダリングする(生成タスク内の検証レンダリングと合わせ二重レンダリング)。
+ 決定論的かつ低コストのため許容する。
+
+## 将来の移行条件
+
+- ドラフトの履歴保持や大容量化が必要になったら、payload 保存を GCS ベースの成果物ストレージへ移す。
+- 生成レイテンシやリトライ要件が変われば、Cloud Tasks の `retry_config` とタスク分割を見直す。
+
+## 設計原則との関係
+
+- **P1(コスト最優先・規模を設計入力に)**: バイナリを DB に持たず payload 再レンダリングで済ませ、
+ 既存の単一インスタンス前提のタスク基盤を再利用する。
+- **P4(機械=構造 / 人間=自然文の責務分離)**: ADR-0018 のハイブリッド生成をそのまま継承する。
+- **P5(失敗を沈黙させない)**: 課金漏れ・二重課金を `NonRetryableError` で明示し、通知で可視化する。
+
+## 関連リンク
+
+- ADR-0018(経歴書ドラフト生成の同期実装)
+- `backend/app/services/agent/resume_draft/run_task.py` / `backend/app/routers/agent.py`
diff --git a/docs/adr/README.md b/docs/adr/README.md
index ef274643..6ea34c06 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -24,6 +24,7 @@
| [ADR-0017](./0017-mutation-testing-and-slack-notifications.md) | ミューテーションテスト週次実行と Slack 通知チャンネル分割 | 開発プロセス / 品質 | テストの検出力を週次ミューテーションで可視化(warn-only)、CI 通知を用途別 Slack へ分割 |
| [ADR-0018](./0018-github-resume-draft-generation.md) | GitHub 連携データからの経歴書ドラフト生成 | LLM / Agent | 構造はルールベース・自然文だけ LLM のハイブリッド。何も永続化せず PDF プレビューのみ返す |
| [ADR-0019](./0019-tdd-for-logic-layer.md) | 決定論的ロジック層への TDD(テスト駆動開発)導入 | 開発プロセス / 品質 | mutation 対象と同一スコープに red→green→refactor を必須化。テスト随伴を lint-tdd で機械検証 |
+| [ADR-0020](./0020-async-resume-draft-generation.md) | 経歴書ドラフト生成の非同期化と最小永続化 | LLM / Agent | ドラフト生成を独立の非同期タスク化。payload だけを連携ドメインに最小永続化し DL 時に再レンダリング |
## 全 ADR 一覧
@@ -49,8 +50,9 @@
| [ADR-0015](./0015-vertex-ai-for-gemini-anthropic.md) | Gemini / Anthropic を Vertex AI(SA→ADC)経由にする | Accepted | LLM / Agent | 0013 の認証部分を更新。関連: 0010、0012 | P2 |
| [ADR-0016](./0016-github-skill-inference.md) | GitHub 連携によるスキル推論基盤 | Accepted | LLM / Agent | 関連: 0010(責務分離の元思想)、0013、0015 | P4 |
| [ADR-0017](./0017-mutation-testing-and-slack-notifications.md) | ミューテーションテスト週次実行と Slack 通知チャンネル分割 | Accepted | 開発プロセス / 品質 | 関連: 0014 | P3 |
-| [ADR-0018](./0018-github-resume-draft-generation.md) | GitHub 連携データからの経歴書ドラフト生成 | Accepted | LLM / Agent | 関連: 0010(不変条件を継承し適用範囲を拡張)、0012(課金配線)、0013・0015(プロバイダ)、0016(データ供給源) | P4・P5 |
+| [ADR-0018](./0018-github-resume-draft-generation.md) | GitHub 連携データからの経歴書ドラフト生成 | Accepted | LLM / Agent | 関連: 0010(不変条件を継承し適用範囲を拡張)、0012(課金配線)、0013・0015(プロバイダ)、0016(データ供給源)、0020(非同期化・最小永続化で更新) | P4・P5 |
| [ADR-0019](./0019-tdd-for-logic-layer.md) | 決定論的ロジック層への TDD(テスト駆動開発)導入 | Accepted | 開発プロセス / 品質 | 関連: 0017(対象スコープの正本を共有)、0007(drift の機械検知パターン) | P3・P5 |
+| [ADR-0020](./0020-async-resume-draft-generation.md) | 経歴書ドラフト生成の非同期化と最小永続化 | Accepted | LLM / Agent | 関連: 0018(同期実装を更新)、0010(不変条件を継承)、0012(課金をタスク側へ移設)、0016(データ供給源) | P1・P4・P5 |
## テーマ別の決定系統
@@ -68,6 +70,7 @@ graph LR
A0010 -.-> A0016["0016
スキル推論 3 層"]
A0010 -.-> A0018["0018
経歴書ドラフト生成"]
A0016 -.-> A0018
+ A0018 -.-> A0020["0020
ドラフト生成の非同期化"]
```
このプロダクトで最も判断の往復が大きい系統。「LLM 抽象の先行実装(0004)→ 利用見込み薄と判断して全撤去(0008)→ 対話型として価値が明確になった時点で、0008 自身が規定した手続きに従い再導入(0010)→ 課金・マルチプロバイダ・データガバナンスへ段階拡張(0012/0013/0015)」という流れで、**撤退条件を先に書いておく運用が実際に機能した実例**になっている。0016 は 0010 の「機械検証可能な制約はコード、不能な制約はプロンプト」という責務分離を「機械=幅 / 人間=深さ」の 3 層モデルへ一般化した。0018 は 0010 の不変条件と 0016 の決定論データを前提に、経歴書ドラフト生成へ「構造=機械 / 自然文=LLM」の分離を適用した。
diff --git a/web/e2e/github-link.spec.ts b/web/e2e/github-link.spec.ts
index 4f593fdd..27bf2fba 100644
--- a/web/e2e/github-link.spec.ts
+++ b/web/e2e/github-link.spec.ts
@@ -153,6 +153,75 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ"
expect(runCalled).toBe(false);
});
+ test("連携後の「ドラフト生成」ボタンで非同期生成が走りプレビューが開く", async ({
+ page,
+ }) => {
+ // 連携済み(result あり)→ ダッシュボードにドラフト生成セクションが出る
+ await page.route("**/api/github-link/cache", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ status: "completed",
+ result: {
+ username: "e2e-test-user",
+ repos_analyzed: 1,
+ unique_skills: 1,
+ analyzed_at: "2026-04-24T00:00:00Z",
+ languages: { TypeScript: 100 },
+ repos: [
+ {
+ full_name: "e2e-test-user/app",
+ description: "アプリ",
+ created_at: "2024-01-01T00:00:00Z",
+ pushed_at: "2026-04-01T00:00:00Z",
+ },
+ ],
+ },
+ }),
+ }),
+ );
+ // マウント時・ポーリングとも完了を返す(enqueue → 即完了 → PDF 取得の順で流れる)
+ await page.route("**/api/agent/resume-draft/status", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ status: "completed" }),
+ }),
+ );
+ let runCalled = false;
+ await page.route("**/api/agent/resume-draft/run", (route) => {
+ runCalled = true;
+ return route.fulfill({
+ status: 202,
+ contentType: "application/json",
+ body: JSON.stringify({ status: "pending" }),
+ });
+ });
+ await page.route("**/api/agent/resume-draft/pdf", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/pdf",
+ body: "%PDF-1.4\n%mock\n",
+ }),
+ );
+
+ await page.goto("/github_link");
+ await waitForAuthenticatedLayout(page);
+
+ // 連携結果が表示され、ドラフト生成ボタンが出る
+ await expect(
+ page.getByRole("heading", { name: "e2e-test-user の連携結果" }),
+ ).toBeVisible();
+ await page
+ .getByRole("button", { name: "経歴書ドラフトPDFを生成" })
+ .click();
+
+ // enqueue → ポーリング完了 → PDF 取得でプレビューモーダルが開く
+ await expect(page.getByText("PDFプレビュー")).toBeVisible();
+ expect(runCalled).toBe(true);
+ });
+
test("サブパネルの「連携実行」ボタンで連携が実行されポーリング表示になる", async ({
page,
}) => {
diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts
index ee7d7bf7..f50da8f1 100644
--- a/web/src/api/agent.ts
+++ b/web/src/api/agent.ts
@@ -2,7 +2,13 @@ import { FALLBACK_MESSAGES } from "../constants/messages";
import { request } from "./client";
import { getBlobUrl } from "./download";
import { PATHS } from "./paths";
-import type { AgentChatRequest, AgentChatResponse, AgentModelAlias } from "./types";
+import type {
+ AgentChatRequest,
+ AgentChatResponse,
+ AgentModelAlias,
+ TaskAcceptedResponse,
+ TaskStatusResponse,
+} from "./types";
/**
* Agent チャット(ADR-0010)。選択スコープの内容とプロンプトを送り、
@@ -16,18 +22,32 @@ export function postAgentChat(payload: AgentChatRequest): Promise {
+export function startResumeDraft(model: AgentModelAlias): Promise {
+ return request(PATHS.agent.resumeDraftRun, {
+ method: "POST",
+ body: JSON.stringify({ model }),
+ });
+}
+
+/**
+ * 経歴書ドラフト生成タスクのステータスを取得する(ポーリング用 / ADR-0018)。
+ */
+export function getResumeDraftStatus(): Promise {
+ return request(PATHS.agent.resumeDraftStatus);
+}
+
+/**
+ * 完了済みの経歴書ドラフトを取得し、プレビュー用の Blob URL を返す(ADR-0018)。
+ * 未完了・結果なしの場合は ApiError(409)を送出する。
+ */
+export function fetchResumeDraftPdfBlobUrl(): Promise {
return getBlobUrl(
PATHS.agent.resumeDraftPdf,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model }),
- },
+ { method: "GET" },
FALLBACK_MESSAGES.RESUME_DRAFT,
);
}
diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts
index 03e5889c..4a40c813 100644
--- a/web/src/api/generated.ts
+++ b/web/src/api/generated.ts
@@ -33,6 +33,29 @@ export interface paths {
trace?: never;
};
"/api/agent/resume-draft/pdf": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Download Resume Draft Pdf
+ * @description 完了済みの経歴書ドラフトを PDF で返す(ADR-0018)。
+ *
+ * 生成タスクが保存した payload から PDF を再レンダリングする(決定論的・DB 非依存)。
+ * 生成未完了・結果なしは 409 を返す。
+ */
+ get: operations["download_resume_draft_pdf_api_agent_resume_draft_pdf_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/agent/resume-draft/run": {
parameters: {
query?: never;
header?: never;
@@ -42,15 +65,36 @@ export interface paths {
get?: never;
put?: never;
/**
- * Generate Resume Draft Pdf
- * @description GitHub 連携データから経歴書ドラフトを生成し、PDF で返す(ADR-0018)。
+ * Start Resume Draft
+ * @description GitHub 連携データからの経歴書ドラフト生成をバックグラウンドで開始する(202 / ADR-0018)。
*
- * 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、
- * 自然文(職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。
- * ドラフトは DB に保存しない(生成物はレスポンスの PDF のみ。
- * クレジット消費・使用ログの記録は除く / ADR-0012)。
+ * 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、自然文
+ * (職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。生成物(payload)は
+ * ``resume_draft_cache`` に保存され、``GET /resume-draft/pdf`` でダウンロードできる。
+ * 確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。
+ * 課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。
+ */
+ post: operations["start_resume_draft_api_agent_resume_draft_run_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/agent/resume-draft/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Resume Draft Status
+ * @description 経歴書ドラフト生成タスクのステータスを返す(軽量ポーリング用 / ADR-0018)。
*/
- post: operations["generate_resume_draft_pdf_api_agent_resume_draft_pdf_post"];
+ get: operations["get_resume_draft_status_api_agent_resume_draft_status_get"];
+ put?: never;
+ post?: never;
delete?: never;
options?: never;
head?: never;
@@ -2158,7 +2202,27 @@ export interface operations {
};
};
};
- generate_resume_draft_pdf_api_agent_resume_draft_pdf_post: {
+ download_resume_draft_pdf_api_agent_resume_draft_pdf_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ start_resume_draft_api_agent_resume_draft_run_post: {
parameters: {
query?: never;
header?: never;
@@ -2172,12 +2236,12 @@ export interface operations {
};
responses: {
/** @description Successful Response */
- 200: {
+ 202: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": components["schemas"]["TaskAcceptedResponse"];
};
};
/** @description Validation Error */
@@ -2191,6 +2255,26 @@ export interface operations {
};
};
};
+ get_resume_draft_status_api_agent_resume_draft_status_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TaskStatusResponse"];
+ };
+ };
+ };
+ };
admin_grant_credits_api_billing_admin_grant_post: {
parameters: {
query?: never;
diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts
index 1a9c681b..adf56355 100644
--- a/web/src/api/paths.ts
+++ b/web/src/api/paths.ts
@@ -29,6 +29,8 @@ export const PATHS = {
},
agent: {
chat: "/api/agent/chat",
+ resumeDraftRun: "/api/agent/resume-draft/run",
+ resumeDraftStatus: "/api/agent/resume-draft/status",
resumeDraftPdf: "/api/agent/resume-draft/pdf",
},
billing: {
diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts
index c2055f55..5a0b56db 100644
--- a/web/src/constants/messages.ts
+++ b/web/src/constants/messages.ts
@@ -203,9 +203,9 @@ export const RESUME_DRAFT_MESSAGES = {
/** 生成中のボタン/スピナーラベル。 */
GENERATING: "経歴書ドラフトを生成中...",
/** 機能説明(モデルはユーザーメニューで選択中のものを使う旨)。 */
- HINT: "連携したリポジトリの情報から、AI が経歴書のたたき台(PDF)を作成します。使用モデルはユーザーメニューで変更できます。",
- /** 生成物が保存されない旨の注意書き。 */
- NOT_SAVED_NOTE: "生成した PDF は保存されません。必要な内容は職務経歴書フォームへ転記してください。",
+ HINT: "連携したリポジトリの情報から、AI が経歴書のたたき台(PDF)を作成します。生成はバックグラウンドで実行され、完了すると通知でお知らせします。使用モデルはユーザーメニューで変更できます。",
+ /** 生成物が職務経歴書として保存されない旨の注意書き。 */
+ NOT_SAVED_NOTE: "生成した内容は職務経歴書として保存されません。必要な部分は職務経歴書フォームへ転記してください。",
} as const;
/** 年セレクトの選択肢表記「N年」。 */
diff --git a/web/src/hooks/useResumeDraftPdf.test.ts b/web/src/hooks/useResumeDraftPdf.test.ts
index cab65168..2e268e0d 100644
--- a/web/src/hooks/useResumeDraftPdf.test.ts
+++ b/web/src/hooks/useResumeDraftPdf.test.ts
@@ -1,62 +1,74 @@
-import { renderHook, act } from "@testing-library/react";
+import { renderHook, act, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ApiError } from "../utils/appError";
import { useResumeDraftPdf } from "./useResumeDraftPdf";
-// API モジュールをモックし、フックの状態遷移だけを検証する
+// API モジュールをモックし、フックの状態遷移(enqueue → ポーリング → 取得)だけを検証する
vi.mock("../api/agent", () => ({
- generateResumeDraftPdfBlobUrl: vi.fn(),
+ startResumeDraft: vi.fn(),
+ getResumeDraftStatus: vi.fn(),
+ fetchResumeDraftPdfBlobUrl: vi.fn(),
}));
-import { generateResumeDraftPdfBlobUrl } from "../api/agent";
+import {
+ startResumeDraft,
+ getResumeDraftStatus,
+ fetchResumeDraftPdfBlobUrl,
+} from "../api/agent";
-const mockGenerate = vi.mocked(generateResumeDraftPdfBlobUrl);
+const mockStart = vi.mocked(startResumeDraft);
+const mockStatus = vi.mocked(getResumeDraftStatus);
+const mockFetch = vi.mocked(fetchResumeDraftPdfBlobUrl);
describe("useResumeDraftPdf", () => {
beforeEach(() => {
vi.clearAllMocks();
+ // 既定: マウント時・ポーリングとも「完了」を返す(マウント復帰は発火しない)
+ mockStatus.mockResolvedValue({ status: "completed" });
+ mockStart.mockResolvedValue({ status: "pending" });
});
- /** 成功時: previewUrl がセットされ、選択モデルで API が呼ばれること */
+ /** 成功時: enqueue → ポーリング完了 → PDF 取得で previewUrl がセットされ、選択モデルで enqueue される */
it("generate 成功で previewUrl がセットされる", async () => {
- mockGenerate.mockResolvedValueOnce("blob:http://localhost/draft-pdf");
+ mockFetch.mockResolvedValueOnce("blob:http://localhost/draft-pdf");
const { result } = renderHook(() => useResumeDraftPdf("haiku"));
await act(async () => {
await result.current.generate();
});
- expect(mockGenerate).toHaveBeenCalledWith("haiku");
- expect(result.current.previewUrl).toBe("blob:http://localhost/draft-pdf");
+ await waitFor(() =>
+ expect(result.current.previewUrl).toBe("blob:http://localhost/draft-pdf"),
+ );
+ expect(mockStart).toHaveBeenCalledWith("haiku");
expect(result.current.error).toBeNull();
expect(result.current.generating).toBe(false);
});
- /** 生成中: generating が true になり、完了で false に戻ること */
+ /** 生成中: generating が true になり、PDF 取得完了で false に戻ること */
it("generate 中は generating が true になる", async () => {
let resolveFetch: (url: string) => void = () => {};
- mockGenerate.mockImplementationOnce(
+ mockFetch.mockImplementationOnce(
() => new Promise((resolve) => (resolveFetch = resolve)),
);
const { result } = renderHook(() => useResumeDraftPdf("haiku"));
- let pending: Promise;
- act(() => {
- pending = result.current.generate();
+ await act(async () => {
+ await result.current.generate();
});
- expect(result.current.generating).toBe(true);
+ // enqueue → ポーリング完了 → PDF 取得が pending の間は generating が true
+ await waitFor(() => expect(result.current.generating).toBe(true));
await act(async () => {
resolveFetch("blob:http://localhost/x");
- await pending;
});
- expect(result.current.generating).toBe(false);
+ await waitFor(() => expect(result.current.generating).toBe(false));
});
- /** 失敗時: ApiError の message / action が AppErrorState に保持されること */
- it("generate 失敗で backend のエラー内容が error にセットされる", async () => {
- mockGenerate.mockRejectedValueOnce(
+ /** enqueue 失敗(409 連携データ不足など): backend の message / action が error に保持される */
+ it("enqueue 失敗で backend のエラー内容が error にセットされる", async () => {
+ mockStart.mockRejectedValueOnce(
new ApiError({
code: "VALIDATION_ERROR",
message: "連携データがありません",
@@ -72,55 +84,74 @@ describe("useResumeDraftPdf", () => {
expect(result.current.previewUrl).toBeNull();
expect(result.current.error?.message).toBe("連携データがありません");
expect(result.current.error?.action).toBe("GitHub 連携を実行してください");
+ expect(result.current.generating).toBe(false);
});
- /** closePreview: Blob URL が解放され previewUrl が null に戻ること */
- it("closePreview で URL.revokeObjectURL が呼ばれ previewUrl が null になる", async () => {
- const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
- mockGenerate.mockResolvedValueOnce("blob:http://localhost/to-revoke");
+ /** タスク失敗(dead_letter): ポーリングが失敗を検知して error にセットされる */
+ it("生成タスクが dead_letter になると error にセットされる", async () => {
+ mockStatus.mockResolvedValueOnce({ status: "completed" }); // マウント復帰は発火させない
+ mockStatus.mockResolvedValue({
+ status: "dead_letter",
+ error_message: "AI の応答取得に失敗しました。",
+ error_code: "AGENT_LLM_ERROR",
+ });
const { result } = renderHook(() => useResumeDraftPdf("haiku"));
await act(async () => {
await result.current.generate();
});
- act(() => {
- result.current.closePreview();
- });
- expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/to-revoke");
+ await waitFor(() =>
+ expect(result.current.error?.message).toBe("AI の応答取得に失敗しました。"),
+ );
expect(result.current.previewUrl).toBeNull();
- revokeSpy.mockRestore();
+ expect(result.current.generating).toBe(false);
+ });
+
+ /** マウント復帰: 進行中タスクを検知するとポーリングを再開し、完了で previewUrl がセットされる */
+ it("マウント時に進行中タスクがあればポーリングを再開して復帰する", async () => {
+ mockStatus.mockResolvedValueOnce({ status: "processing" }); // マウント時: 進行中
+ mockStatus.mockResolvedValue({ status: "completed" }); // 以降のポーリング
+ mockFetch.mockResolvedValueOnce("blob:http://localhost/resumed");
+
+ const { result } = renderHook(() => useResumeDraftPdf("haiku"));
+
+ await waitFor(() =>
+ expect(result.current.previewUrl).toBe("blob:http://localhost/resumed"),
+ );
+ // マウント復帰では enqueue は行わない
+ expect(mockStart).not.toHaveBeenCalled();
});
- /** 再生成: 旧 previewUrl が revoke されてから新 URL に差し替わること(Blob リーク防止) */
- it("generate を再実行すると旧 Blob URL が revoke される", async () => {
+ /** closePreview: Blob URL が解放され previewUrl が null に戻ること */
+ it("closePreview で URL.revokeObjectURL が呼ばれ previewUrl が null になる", async () => {
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
- mockGenerate
- .mockResolvedValueOnce("blob:http://localhost/first")
- .mockResolvedValueOnce("blob:http://localhost/second");
+ mockFetch.mockResolvedValueOnce("blob:http://localhost/to-revoke");
const { result } = renderHook(() => useResumeDraftPdf("haiku"));
await act(async () => {
await result.current.generate();
});
- await act(async () => {
- await result.current.generate();
+ await waitFor(() => expect(result.current.previewUrl).not.toBeNull());
+ act(() => {
+ result.current.closePreview();
});
- expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/first");
- expect(result.current.previewUrl).toBe("blob:http://localhost/second");
+ expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/to-revoke");
+ expect(result.current.previewUrl).toBeNull();
revokeSpy.mockRestore();
});
/** アンマウント: プレビュー表示中に画面離脱しても Blob URL が解放されること */
it("アンマウント時に残っている Blob URL が revoke される", async () => {
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
- mockGenerate.mockResolvedValueOnce("blob:http://localhost/on-unmount");
+ mockFetch.mockResolvedValueOnce("blob:http://localhost/on-unmount");
const { result, unmount } = renderHook(() => useResumeDraftPdf("haiku"));
await act(async () => {
await result.current.generate();
});
+ await waitFor(() => expect(result.current.previewUrl).not.toBeNull());
unmount();
expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/on-unmount");
diff --git a/web/src/hooks/useResumeDraftPdf.ts b/web/src/hooks/useResumeDraftPdf.ts
index 31ef3ca3..86e041f5 100644
--- a/web/src/hooks/useResumeDraftPdf.ts
+++ b/web/src/hooks/useResumeDraftPdf.ts
@@ -1,16 +1,26 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
-import { generateResumeDraftPdfBlobUrl } from "../api/agent";
+import {
+ fetchResumeDraftPdfBlobUrl,
+ getResumeDraftStatus,
+ startResumeDraft,
+} from "../api/agent";
import { toAppError, type AppErrorState } from "../api";
import type { AgentModelAlias } from "../api/types";
import { FALLBACK_MESSAGES } from "../constants/messages";
+import { isInProgressStatus } from "../utils/taskStatus";
+import { useTaskPolling } from "./useTaskPolling";
/**
- * 経歴書ドラフト PDF の生成とプレビュー状態を管理するフック(ADR-0018)。
+ * 経歴書ドラフト PDF の生成とプレビュー状態を管理するフック(ADR-0018 / 非同期化)。
*
- * 生成はサーバー側で LLM を 1 回呼ぶ同期処理(十数秒〜数十秒)のため、
- * generating 中はボタンを無効化して二重実行を防ぐ。生成物は DB に保存されず、
- * previewUrl(Blob URL)のみがこのフックのライフサイクルで管理される。
+ * 生成はサーバー側のバックグラウンドタスク(LLM 1 コール → PDF 生成)で、enqueue(202)→
+ * ステータスポーリング → 完了後に PDF を取得してプレビュー、という流れ。画面を離れても
+ * バックエンドの生成は継続し、完了は通知ベルで知らされる。マウント時に進行中タスクを
+ * 検知した場合はポーリングを再開する(別画面から戻ったケースの復帰)。
+ *
+ * 生成物(payload)はサーバーに保存されるが、previewUrl(Blob URL)は本フックの
+ * ライフサイクルで管理する(リーク防止のため再生成・アンマウントで revoke する)。
*
* @param model 使用モデル(ユーザーメニューで選択中のグローバル設定を渡す)
*/
@@ -22,13 +32,13 @@ export function useResumeDraftPdf(model: AgentModelAlias) {
const previewUrlRef = useRef(null);
/** プレビュー URL を差し替える。既存の Blob URL があれば解放してから新しい値をセットする。 */
- const updatePreviewUrl = (url: string | null) => {
+ const updatePreviewUrl = useCallback((url: string | null) => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
}
previewUrlRef.current = url;
setPreviewUrl(url);
- };
+ }, []);
// アンマウント時に残っている Blob URL を解放する(プレビュー表示中の画面離脱でリークしない)
useEffect(() => {
@@ -39,17 +49,57 @@ export function useResumeDraftPdf(model: AgentModelAlias) {
};
}, []);
- /** ドラフト PDF を生成してプレビュー URL をセットする。 */
+ /** 完了後に PDF を取得してプレビュー URL をセットする。 */
+ const loadPreview = useCallback(async () => {
+ try {
+ updatePreviewUrl(await fetchResumeDraftPdfBlobUrl());
+ } catch (e) {
+ setError(toAppError(e, FALLBACK_MESSAGES.RESUME_DRAFT));
+ } finally {
+ setGenerating(false);
+ }
+ }, [updatePreviewUrl]);
+
+ const { startPolling } = useTaskPolling({
+ checkStatus: getResumeDraftStatus,
+ onCompleted: () => {
+ void loadPreview();
+ },
+ onFailed: (e) => {
+ setError(e);
+ setGenerating(false);
+ },
+ });
+
+ // マウント時に進行中タスクがあればポーリングを再開する(別画面から戻った場合の復帰)
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const { status } = await getResumeDraftStatus();
+ if (cancelled || !isInProgressStatus(status)) return;
+ setGenerating(true);
+ startPolling();
+ } catch {
+ // ステータス取得失敗は復帰を諦めるだけ(生成ボタンから開始できる)
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [startPolling]);
+
+ /** ドラフト生成を開始する(enqueue → ポーリング → 完了で自動プレビュー)。 */
const generate = async () => {
if (generating) return;
setGenerating(true);
setError(null);
try {
- updatePreviewUrl(await generateResumeDraftPdfBlobUrl(model));
+ await startResumeDraft(model);
+ startPolling();
} catch (e) {
- // 409(連携データ不足)等は backend の message / action をそのまま表示する
+ // 409(連携データ不足)/ 402(残高不足)等は backend の message / action をそのまま表示する
setError(toAppError(e, FALLBACK_MESSAGES.RESUME_DRAFT));
- } finally {
setGenerating(false);
}
};