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); } };