diff --git a/.claude/rules/backend/agent.md b/.claude/rules/backend/agent.md index 151f3f2b..074c3d89 100644 --- a/.claude/rules/backend/agent.md +++ b/.claude/rules/backend/agent.md @@ -29,18 +29,42 @@ backend/ │ ├── chat_service.py # コンテキスト組み立て → LLM → 検証(DB に触れない) │ ├── context_builder.py # Phase 2: GitHub/ブログ参照コンテキスト取得(DB 読み取り専用) │ ├── output_schema.py # tool use スキーマ(機械制約の正本) -│ └── llm/ -│ ├── base.py # LLMClient 抽象・LLMError -│ ├── anthropic_client.py -│ ├── google_client.py # Gemini(ADR-0013) -│ ├── openai_client.py # GPT(ADR-0013) -│ ├── ollama_client.py -│ └── factory.py # get_llm_client(provider) で分岐(ADR-0013) +│ ├── llm/ +│ │ ├── base.py # LLMClient 抽象・LLMError +│ │ ├── anthropic_client.py +│ │ ├── google_client.py # Gemini(ADR-0013) +│ │ ├── openai_client.py # GPT(ADR-0013) +│ │ ├── ollama_client.py +│ │ └── factory.py # get_llm_client(provider) で分岐(ADR-0013) +│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018。下記「resume_draft」節) +│ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource) +│ ├── mapper.py # ルールベース純関数(骨格 payload 構築) +│ ├── output_schema.py # ドラフト用構造化出力スキーマ(機械制約の正本) +│ └── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ └── tests/ ├── test_agent.py - └── test_agent_context_builder.py # Phase 2: context_builder の単体テスト + ├── 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 モック) ``` +## resume_draft(経歴書ドラフト生成 / ADR-0018) + +GitHub 連携データから経歴書ドラフト payload を組み立て、PDF プレビューを返す単発生成機能。 +チャットとは別系統だが、**本ファイルの不変条件(制約の責務分離・リトライ 1 回・エラー契約・ +LLMError/usage の課金漏れ防止)を全て継承する**。 + +- **構造はルールベース、自然文だけ LLM**: repo→プロジェクト骨格・技術スタック・期間は + `mapper.py`(純関数)が決定論で写す。LLM が生成するのは career_summary / self_pr / + 各プロジェクト description のみ。 +- **出力スキーマは動的**: `repo_full_name` を選定リポジトリの enum で縛る(捏造リポの構造排除)。 + チャットの「プロンプトは静的・スキーマも静的」と異なりリクエストごとに構築するが、 + プロンプト md(`agent_resume_draft.md`)自体は静的を維持する(動的情報は user メッセージへ)。 +- **何も永続化しない**: resumes テーブルへ書かない。生成物はレスポンスの PDF だけ + (クレジット消費・使用ログは例外 / ADR-0012)。DB 読み取りは `context.py` の SELECT のみ。 +- **degrade 方針**: 個別プロジェクトの説明文が欠落・上限超過した場合のみ repo description の + 定型文へフォールバック(切り詰めはしない)。career_summary / self_pr の欠落はパース失敗扱い。 + ## プロバイダ抽象(ADR-0013) - プロバイダ選択は **モデルエイリアスの属性**(`model_catalog.ModelSpec.provider`)に紐づき、 diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 7be6c796..6ce3324d 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -57,11 +57,16 @@ backend/app/ │ │ ├── context_builder.py # GitHub/ブログ参照コンテキスト取得(DB 読み取り専用) │ │ ├── model_catalog.py # エイリアス→provider/実モデル ID/課金レート(SSoT / ADR-0012・0013) │ │ ├── output_schema.py # 構造化出力スキーマ(機械制約の正本) -│ │ └── llm/ # LLM プロバイダ抽象(失敗は raise / ADR-0013) -│ │ ├── base.py # LLMClient 抽象・LLMError・共通ヘルパ -│ │ ├── factory.py # get_llm_client(provider) で分岐 -│ │ ├── anthropic_client.py / openai_client.py / google_client.py -│ │ └── ollama_client.py # ローカル開発用(LLM_LOCAL_OLLAMA) +│ │ ├── llm/ # LLM プロバイダ抽象(失敗は raise / ADR-0013) +│ │ │ ├── base.py # LLMClient 抽象・LLMError・共通ヘルパ +│ │ │ ├── factory.py # get_llm_client(provider) で分岐 +│ │ │ ├── anthropic_client.py / openai_client.py / google_client.py +│ │ │ └── ollama_client.py # ローカル開発用(LLM_LOCAL_OLLAMA) +│ │ └── resume_draft/ # GitHub 連携データ → 経歴書ドラフト生成(ADR-0018) +│ │ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource) +│ │ ├── mapper.py # ルールベース純関数(骨格 payload 構築) +│ │ ├── output_schema.py # ドラフト用 LLM 構造化出力スキーマ +│ │ └── draft_service.py # LLM 1 コール → パース → 骨格へ自然文マージ │ ├── blog/ # ブログ収集・技術記事判定・スコア算出 │ │ ├── account_service.py │ │ ├── collector.py diff --git a/backend/app/core/security/auth.py b/backend/app/core/security/auth.py index 696d03c6..5932c198 100644 --- a/backend/app/core/security/auth.py +++ b/backend/app/core/security/auth.py @@ -144,3 +144,18 @@ def get_current_user( message_key="auth.user_not_found", ) return user + + +def require_github_user(user: User = Depends(get_current_user)) -> User: + """GitHub 連携データを使う機能には GitHub ログイン(``github_id`` 保持)が必須。未連携なら 403。 + + github-link の run / retry と経歴書ドラフト生成(ADR-0018)で共通の認可ガード。 + """ + if user.github_id is None: + raise_app_error( + status_code=403, + code=ErrorCode.AUTH_REQUIRED, + message=get_error("github_link.github_login_required"), + action="GitHub アカウントでログインし直してください", + ) + return user diff --git a/backend/app/messages.json b/backend/app/messages.json index 53668321..9386bf27 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -72,7 +72,9 @@ "llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。", "parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。", "target_required": "このスコープでは対象の指定が必要です。", - "target_not_found": "指定された対象が見つかりません。" + "target_not_found": "指定された対象が見つかりません。", + "draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。", + "draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。" }, "billing": { "insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", diff --git a/backend/app/prompts/agent_resume_draft.md b/backend/app/prompts/agent_resume_draft.md new file mode 100644 index 00000000..8a3c7923 --- /dev/null +++ b/backend/app/prompts/agent_resume_draft.md @@ -0,0 +1,30 @@ +あなたは日本語の職務経歴書のドラフト作成を支援するアシスタントです。 +GitHub のリポジトリ情報から、経歴書の「職務要約」「自己PR」「各プロジェクトの業務内容説明」を書いてください。 +出力の構造・対象リポジトリの集合・文字数上限はスキーマで定義されているため、ここでは内容の品質に集中すること。 + +# 共通ルール(最優先) +- 「# リポジトリ情報」に書かれていない技術・数値・成果・所属企業・チーム規模を新たに作らない(事実の捏造禁止) +- **プレースホルダー・穴埋めの禁止**: 「〇〇%」「XX件」のような仮の数値や、「(具体的な数値で示す)」のような編集指示文を入れない。書かれていない数値はその文ごと書かない +- これは個人開発の実績にもとづくドラフト(たたき台)である。業務経験のように偽装しない(「クライアント向けに」「チームを率いて」等を書かない) +- 各文章は経歴書にそのまま掲載できる完成した日本語にする(箇条書き記号の乱用や「です・ます」と「である」の混在を避ける) + +# career_summary(職務要約)の品質基準 +- 推奨 200〜400字(品質基準としての目安。上限とは別物) +- リポジトリ群から読み取れる技術領域の広がり・主要言語・継続的な開発活動を一読で伝える +- 直近の活動と技術領域に重心を置く + +# self_pr(自己PR)の品質基準 +- 推奨 200〜400字(品質基準としての目安) +- リポジトリ情報から根拠づけられる強みだけを書く(例: 複数言語での実装経験、IaC を含む一気通貫の構築、継続的なアウトプット) +- 根拠(どのリポジトリ・どの技術か)に軽く触れ、説得力を持たせる + +# project_descriptions(プロジェクト説明)の品質基準 +- 1 件あたり推奨 150〜300字(品質基準としての目安) +- そのリポジトリの description と技術スタックだけを根拠に、「何を作ったか」「どんな技術で実現したか」を書く +- description が空のリポジトリは技術スタックから読み取れる範囲だけを簡潔に書く(用途を推測で断定しない) +- 与えられた全リポジトリについて 1 件ずつ返す + +# 思考ステップ(内部分析。出力には含めない) +1. リポジトリ一覧から主要言語・技術領域・活動期間の全体像を把握する +2. 各リポジトリの description と技術スタックから「何を・どう作ったか」を整理する +3. 全体像を職務要約に、強みの根拠を自己PRに、個別の整理を各プロジェクト説明に落とし込む diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index a7b553b0..6d51b4eb 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -8,15 +8,16 @@ import logging from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from ..core.errors import ErrorCode, raise_app_error from ..core.messages import get_error -from ..core.security.auth import get_current_user +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 ..schemas.agent import AgentChatRequest, AgentChatResponse +from ..schemas.agent import AgentChatRequest, AgentChatResponse, ResumeDraftRequest from ..services.agent import chat_service from ..services.agent.chat_service import ( AgentResponseParseError, @@ -25,15 +26,25 @@ ) from ..services.agent.context_builder import build_reference_context from ..services.agent.llm.base import LLMError +from ..services.agent.resume_draft.context import ( + ResumeDraftNoRepositoriesError, + 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 .download_utils import stream_pdf logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/agent", tags=["agent"]) -def _record_usage_after_llm(db: Session, user_id: str, usage: AgentUsage) -> None: +def _record_usage_after_llm( + db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None +) -> None: """LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。 LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL @@ -43,7 +54,7 @@ def _record_usage_after_llm(db: Session, user_id: str, usage: AgentUsage) -> Non 新しいコネクション(=新規 Hrana ストリーム)を取得して正常に確定できる。 """ db.close() - credit_service.record_chat_usage(db, user_id, usage) + credit_service.record_chat_usage(db, user_id, usage, description=description) @router.post("/chat", response_model=AgentChatResponse) @@ -112,3 +123,83 @@ async def agent_chat( # 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012) _record_usage_after_llm(db, user.id, result.usage) return result.response + + +@router.post("/resume-draft/pdf") +@limiter.limit("5/minute") +async def generate_resume_draft_pdf( + request: Request, + body: ResumeDraftRequest, + user: User = Depends(require_github_user), + db: Session = Depends(get_db), +) -> StreamingResponse: + """GitHub 連携データから経歴書ドラフトを生成し、PDF で返す(ADR-0018)。 + + 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、 + 自然文(職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。 + ドラフトは DB に保存しない(生成物はレスポンスの PDF のみ。 + クレジット消費・使用ログの記録は除く / ADR-0012)。 + """ + usage_description = f"経歴書ドラフト生成({body.model})" + # 有料モデルは LLM を呼ぶ前に残高をチェックする(チャットと同一契約 / ADR-0012) + try: + credit_service.ensure_can_use_model(db, user.id, body.model) + except InsufficientCreditsError: + raise_app_error( + status_code=402, + code=ErrorCode.INSUFFICIENT_CREDITS, + message=get_error("billing.insufficient_credits"), + ) + # 連携キャッシュ + スキル証跡の読み取り(SELECT のみ)。未連携・旧形式・0 件は 409。 + # 0 件(NoRepositories)は再連携で回復しないため別導線を案内する(サブクラスを先に catch) + try: + source = build_draft_source(db, user) + except ResumeDraftNoRepositoriesError as exc: + logger.info("経歴書ドラフト生成: 分析対象リポジトリなし: %s", exc) + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.draft_no_repositories"), + action="公開リポジトリを追加してから GitHub 連携を再実行してください", + ) + except ResumeDraftSourceUnavailableError as exc: + logger.info("経歴書ドラフト生成の入力が未整備: %s", exc) + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.draft_link_required"), + action="サイドバーの「GitHub連携」から連携を実行してください", + ) + 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) + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_LLM_ERROR, + message=get_error("agent.llm_failed"), + ) + 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) + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_PARSE_ERROR, + message=get_error("agent.parse_failed"), + ) + # 先に 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) + return stream_pdf(pdf_bytes, "career-resume-draft.pdf") diff --git a/backend/app/routers/github_link/endpoints.py b/backend/app/routers/github_link/endpoints.py index 8a0c04a2..376bd74c 100644 --- a/backend/app/routers/github_link/endpoints.py +++ b/backend/app/routers/github_link/endpoints.py @@ -13,7 +13,7 @@ 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 +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 @@ -46,21 +46,6 @@ def _raise_dispatch_failed() -> None: ) -def require_github_user(user: User = Depends(get_current_user)) -> User: - """GitHub 連携には GitHub ログイン(``github_id`` 保持)が必須。未連携なら 403。 - - ``start`` / ``retry`` の両エンドポイントで共通の認可ガード。 - """ - if user.github_id is None: - raise_app_error( - status_code=403, - code=ErrorCode.AUTH_REQUIRED, - message=get_error("github_link.github_login_required"), - action="GitHub アカウントでログインし直してください", - ) - return user - - @router.get("/cache", response_model=CachedGitHubLinkResponse) def get_cache( user: User = Depends(get_current_user), diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index e15a4699..5f2461f7 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -137,6 +137,17 @@ def validate_target(self) -> "AgentChatRequest": return self +class ResumeDraftRequest(BaseModel): + """経歴書ドラフト生成(ADR-0018)のリクエスト。 + + 生成対象(リポジトリ集合)はサーバー側が連携キャッシュから決めるため、 + クライアントが指定するのは使用モデルのみ。 + """ + + # 使用モデル。既定は無料枠の haiku(ADR-0012 の課金契約はチャットと共通) + model: AgentModelAlias = "haiku" + + class AgentOperation(BaseModel): """resume state へ適用する差分(テキストフィールドの置換)。 diff --git a/backend/app/schemas/github_link.py b/backend/app/schemas/github_link.py index 66b6d6f2..5ae66868 100644 --- a/backend/app/schemas/github_link.py +++ b/backend/app/schemas/github_link.py @@ -34,6 +34,19 @@ class ContributionCalendar(BaseModel): ) +class AnalyzedRepoSummary(BaseModel): + """連携で分析したリポジトリ 1 件分のサマリ(ADR-0018)。 + + 経歴書ドラフト生成のルールベースマッピングが入力にする決定論データ。 + スキル証跡(github_skill_evidence)と同一連携実行時点のスナップショットになる。 + """ + + full_name: str = Field(description="owner/name 形式のリポジトリ名") + description: str = Field(default="", description="GitHub のリポジトリ説明(無ければ空文字)") + created_at: str = Field(default="", description="ISO 8601 形式の作成日時") + pushed_at: str = Field(default="", description="ISO 8601 形式の最終 push 日時") + + class GitHubLinkResponse(BaseModel): username: str repos_analyzed: int @@ -47,6 +60,12 @@ class GitHubLinkResponse(BaseModel): default_factory=list, description="年ごとのコントリビューションカレンダー(新しい年順。取得失敗時は空配列)", ) + # ADR-0018 以前に保存された旧形式 JSON には無いフィールド。default_factory で + # 後方互換を保ち、旧形式かどうかは「空リスト」で判定する(ドラフト生成側で 409) + repos: List[AnalyzedRepoSummary] = Field( + default_factory=list, + description="分析対象リポジトリのサマリ一覧(経歴書ドラフト生成の入力 / ADR-0018)", + ) class CachedGitHubLinkResponse(BaseModel): diff --git a/backend/app/services/agent/resume_draft/__init__.py b/backend/app/services/agent/resume_draft/__init__.py new file mode 100644 index 00000000..bdabadf0 --- /dev/null +++ b/backend/app/services/agent/resume_draft/__init__.py @@ -0,0 +1,6 @@ +"""GitHub 連携データからの経歴書ドラフト生成(ADR-0018)。 + +構造(プロジェクト骨格・技術スタック・期間)はルールベース(mapper)で決定論的に写し、 +自然文(職務要約・自己PR・プロジェクト説明文)だけを LLM(draft_service)で生成する。 +Agent の不変条件(制約の責務分離・リトライ 1 回・エラー契約・DB 非更新)を継承する。 +""" diff --git a/backend/app/services/agent/resume_draft/context.py b/backend/app/services/agent/resume_draft/context.py new file mode 100644 index 00000000..8e86c17b --- /dev/null +++ b/backend/app/services/agent/resume_draft/context.py @@ -0,0 +1,159 @@ +"""経歴書ドラフト生成の入力データ取得(DB 読み取り専用 / ADR-0018)。 + +DB アクセスは本モジュールに閉じ込める(mapper / draft_service は DB に触れない。 +チャットの context_builder と同じ責務分担)。SELECT のみで commit / flush / add は行わない。 + +連携キャッシュの ``result.repos``(連携実行時のリポジトリサマリ)と、スキル証跡 +``github_skill_evidence``(ADR-0016 / スキル → リポの N:N)を「リポジトリ → 技術」に +反転した辞書を ``DraftSource`` に束ねて返す。両者は同一連携実行時点のスナップショット +なのでリポジトリ集合が一致する前提を置ける。 +""" + +import logging +from dataclasses import dataclass, field + +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from ....models import User +from ....repositories.github_link import GitHubLinkCacheRepository +from ....repositories.skill import GitHubSkillRepository +from ....schemas.github_link import AnalyzedRepoSummary, GitHubLinkResponse +from ...intelligence.skills.types import ( + SKILL_KIND_INFRA, + SKILL_KIND_LANGUAGE, + SKILL_KIND_PACKAGE, +) + +logger = logging.getLogger(__name__) + +# スキル種別 → 経歴書の技術スタックカテゴリ(schemas/resume.py の Literal に収まる値のみ) +_KIND_TO_CATEGORY: dict[str, str] = { + SKILL_KIND_LANGUAGE: "language", + SKILL_KIND_PACKAGE: "framework", + SKILL_KIND_INFRA: "iac", +} + +# package スキルを技術スタックに採用する根拠の下限。manifest の間接依存 +# (dependency_kind が direct 以外かつ実 import 未確認)は「使った技術」とは +# 言えないため経歴書には載せない +_PACKAGE_DEPENDENCY_KIND_DIRECT = "direct" +_PACKAGE_SIGNAL_ACTUAL_IMPORT = "actual_import" + + +class ResumeDraftSourceUnavailableError(Exception): + """ドラフト生成に必要な連携データが無い(未連携・旧形式キャッシュ・進行中)。 + + router で 409 にマッピングし、GitHub 連携の(再)実行を促す。 + """ + + +class ResumeDraftNoRepositoriesError(ResumeDraftSourceUnavailableError): + """連携は完了しているが分析対象リポジトリが 0 件(再連携では回復しない)。 + + 旧形式キャッシュ(再連携で回復する)とは区別し、router で「公開リポジトリを追加して + 再連携」という別の導線を案内する。``ResumeDraftSourceUnavailableError`` のサブクラス + なので、router では本クラスを先に catch すること。 + """ + + +@dataclass(frozen=True) +class RepoTechnology: + """リポジトリ 1 件に紐づく技術 1 件(スキル証跡の反転結果)。""" + + category: str + name: str + confidence: float + # 言語スキルのみ持つ、このリポでのバイト数(リポ選定のタイブレークに使う) + language_bytes: int = 0 + + +@dataclass(frozen=True) +class DraftSource: + """ドラフト生成のルールベースマッピング(mapper)への入力一式。""" + + username: str + email: str + repos: list[AnalyzedRepoSummary] = field(default_factory=list) + # repo_full_name → 技術リスト(証跡の反転。順序は保証しない。並べ替えは mapper が担う) + repo_technologies: dict[str, list[RepoTechnology]] = field(default_factory=dict) + + +def build_draft_source(db: Session, user: User) -> DraftSource: + """連携キャッシュとスキル証跡からドラフト生成の入力を組み立てる。 + + Raises: + ResumeDraftSourceUnavailableError: 連携が未完了、またはキャッシュが + リポジトリサマリを持たない旧形式(ADR-0018 以前の連携結果)。 + """ + cache = GitHubLinkCacheRepository(db).get_by_user(user.id) + if not cache or cache.status != "completed" or not cache.result: + raise ResumeDraftSourceUnavailableError( + f"GitHub 連携が完了していません (status={cache.status if cache else None})" + ) + # ADR-0018 より前の旧形式は result に "repos" キー自体が無い(再連携で回復する)。 + # 一方 ADR-0018 以降は分析対象が 0 件でも "repos": [] が保存されるため、生 JSON の + # キー有無で両者を区別する(Pydantic 検証後は default_factory=[] のため区別できない)。 + if "repos" not in cache.result: + raise ResumeDraftSourceUnavailableError( + "連携キャッシュにリポジトリサマリがありません(旧形式)" + ) + try: + result = GitHubLinkResponse.model_validate(cache.result) + except ValidationError: + # キャッシュ JSON がスキーマに合わない場合も再連携で回復できるため 409 側に倒す + logger.warning("連携キャッシュの検証に失敗(再連携が必要)", exc_info=True) + raise ResumeDraftSourceUnavailableError("連携キャッシュを解釈できません") from None + if not result.repos: + # 新形式だが分析対象リポジトリが 0 件。再連携では回復しないため別導線を案内する + raise ResumeDraftNoRepositoriesError("分析対象のリポジトリがありません") + + return DraftSource( + username=user.username, + email=user.email or "", + repos=list(result.repos), + repo_technologies=_invert_skill_evidence(db, user.id), + ) + + +def _invert_skill_evidence(db: Session, user_id: str) -> dict[str, list[RepoTechnology]]: + """スキル証跡(スキル → リポ)を「リポ → 技術」に反転する。 + + - language: 常に採用(language_bytes を保持) + - package: direct 宣言または実 import 確認済みのみ採用 + - infra: 常に採用 + 同一(リポ・カテゴリ・技術名)が複数証跡(manifest_declared と actual_import 等)で + 現れた場合は confidence / language_bytes の大きい方に畳む。 + """ + merged: dict[str, dict[tuple[str, str], RepoTechnology]] = {} + for skill in GitHubSkillRepository(db, user_id).list_for_user(): + category = _KIND_TO_CATEGORY.get(skill.kind) + if category is None: + logger.warning("未知のスキル種別を技術スタックから除外: kind=%s", skill.kind) + continue + name = skill.display_name or skill.canonical_name + for evidence in skill.evidence: + if skill.kind == SKILL_KIND_PACKAGE and not ( + evidence.dependency_kind == _PACKAGE_DEPENDENCY_KIND_DIRECT + or evidence.signal_source == _PACKAGE_SIGNAL_ACTUAL_IMPORT + ): + continue + per_repo = merged.setdefault(evidence.repo_full_name, {}) + key = (category, name) + candidate = RepoTechnology( + category=category, + name=name, + confidence=evidence.confidence or 0.0, + language_bytes=evidence.language_bytes or 0, + ) + existing = per_repo.get(key) + if existing is None: + per_repo[key] = candidate + else: + per_repo[key] = RepoTechnology( + category=category, + name=name, + confidence=max(existing.confidence, candidate.confidence), + language_bytes=max(existing.language_bytes, candidate.language_bytes), + ) + return {repo: list(entries.values()) for repo, entries in merged.items()} diff --git a/backend/app/services/agent/resume_draft/draft_service.py b/backend/app/services/agent/resume_draft/draft_service.py new file mode 100644 index 00000000..3d4464d2 --- /dev/null +++ b/backend/app/services/agent/resume_draft/draft_service.py @@ -0,0 +1,235 @@ +"""経歴書ドラフト生成の中核ロジック(骨格構築 → LLM → 検証 → マージ / ADR-0018)。 + +本モジュールは DB に触れない(DB 読み取りは router → context.py 経由のみ)。 +LLM 呼び出しの失敗契約(LLMError / AgentResponseParseError に usage を載せて課金漏れを +防ぐ・リトライは 1 回のみ)はチャット(chat_service)と同一。呼び出しの流れも同じ形だが、 +入出力(骨格 payload / ドラフト出力スキーマ)が異なるため現時点では共通化しない +(Rule of Three / .claude/rules/common/duplication.md)。 +""" + +import json +import logging +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +from pydantic import BaseModel, Field, ValidationError + +from ....schemas.agent import AgentModelAlias +from ..chat_service import AgentResponseParseError, AgentUsage +from ..llm.base import LLMError +from ..llm.factory import get_llm_client +from ..model_catalog import get_model_spec +from .context import DraftSource +from .mapper import build_skeleton, select_repos +from .output_schema import ( + MAX_CAREER_SUMMARY_LENGTH, + MAX_PROJECT_DESCRIPTION_LENGTH, + MAX_SELF_PR_LENGTH, + build_draft_output_schema, +) + +logger = logging.getLogger(__name__) + +# システムプロンプトの正本は app/prompts/(チャットと同じ分離)。動的プレースホルダは +# 使わず静的に保つ(プロバイダキャッシュを効かせる)。動的情報(リポ情報・許可集合)は +# user メッセージの JSON とスキーマの enum に載せる +_PROMPTS_DIR = Path(__file__).resolve().parents[3] / "prompts" +_SYSTEM_PROMPT = (_PROMPTS_DIR / "agent_resume_draft.md").read_text(encoding="utf-8") + +# リトライ時に LLM へフィードバックするエラー文の上限(chat_service と同じ趣旨) +_MAX_RETRY_ERROR_LENGTH = 500 + + +@dataclass(frozen=True) +class ResumeDraftResult: + """run_resume_draft の戻り値(PDF 生成用 payload + 課金用の使用量)。""" + + payload: dict + usage: AgentUsage + + +class _ProjectDescription(BaseModel): + """LLM 出力のプロジェクト説明 1 件分。""" + + repo_full_name: str + description: str + + +class _DraftOutput(BaseModel): + """LLM 出力全体の検証用モデル(構造の二重防衛)。""" + + career_summary: str = Field(min_length=1, max_length=MAX_CAREER_SUMMARY_LENGTH) + self_pr: str = Field(min_length=1, max_length=MAX_SELF_PR_LENGTH) + project_descriptions: list[_ProjectDescription] = Field(default_factory=list) + + +def _build_repo_context(source: DraftSource, selected: list) -> str: + """LLM に渡すリポジトリ情報の JSON コンテキストを組み立てる。 + + 捏造禁止の判定根拠になる「与えた情報」の全量。技術名は骨格に載せる集合と同じ + ものを渡す(スタック上限で絞る前の反転結果ではなく、mapper の選定結果に依存 + させないよう名前だけを列挙する)。 + """ + repos = [ + { + "repo_full_name": repo.full_name, + "description": repo.description, + "created_at": repo.created_at, + "pushed_at": repo.pushed_at, + "technologies": sorted( + {tech.name for tech in source.repo_technologies.get(repo.full_name, [])} + ), + } + for repo in selected + ] + return json.dumps({"github_username": source.username, "repos": repos}, ensure_ascii=False) + + +def _parse_draft(raw: str, allowed_names: set[str]) -> _DraftOutput: + """LLM 応答をパースし、リポジトリ名の検証と重複・許可外の破棄を行って返す。 + + career_summary / self_pr の欠落・上限超過は契約違反としてパース失敗にする + (切り詰めない / ADR-0010 踏襲)。プロジェクト説明のみ個別に degrade する + (許可外・重複・上限超過の 1 件を破棄しても、骨格側の repo description + フォールバックで経歴書として成立するため)。 + """ + text = raw.strip() + # Ollama など tool use ではないローカル実装のコードフェンス耐性(chat_service と同じ) + if text.startswith("```"): + text = text.strip("`") + text = text.removeprefix("json").strip() + try: + data = json.loads(text) + parsed = _DraftOutput.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + logger.warning("ドラフト LLM 応答のパースに失敗: %s", type(exc).__name__) + raise AgentResponseParseError(str(exc)) from exc + + descriptions: list[_ProjectDescription] = [] + seen: set[str] = set() + for item in parsed.project_descriptions: + if item.repo_full_name not in allowed_names: + logger.warning("許可外リポジトリの説明を破棄: %s", item.repo_full_name) + continue + if item.repo_full_name in seen: + logger.warning("重複したリポジトリ説明を破棄: %s", item.repo_full_name) + continue + if len(item.description) > MAX_PROJECT_DESCRIPTION_LENGTH: + logger.warning( + "文字数上限超過のプロジェクト説明を破棄: repo=%s len=%d", + item.repo_full_name, + len(item.description), + ) + continue + seen.add(item.repo_full_name) + descriptions.append(item) + return _DraftOutput( + career_summary=parsed.career_summary, + self_pr=parsed.self_pr, + project_descriptions=descriptions, + ) + + +def _merge_output(skeleton: dict, selected: list, output: _DraftOutput) -> dict: + """骨格 payload に LLM の自然文をマージする。 + + 説明が返らなかったプロジェクトは骨格の repo description フォールバックのまま残す。 + """ + skeleton["career_summary"] = output.career_summary + skeleton["self_pr"] = output.self_pr + + by_name = {item.repo_full_name: item.description for item in output.project_descriptions} + projects = skeleton["experiences"][0]["clients"][0]["projects"] + for repo, project in zip(selected, projects): + description = by_name.get(repo.full_name) + if description is None: + logger.warning("プロジェクト説明が欠落(フォールバック使用): %s", repo.full_name) + continue + project["description"] = description + return skeleton + + +async def run_resume_draft( + model: AgentModelAlias, source: DraftSource, *, today: date | None = None +) -> ResumeDraftResult: + """経歴書ドラフト payload を生成し、課金用の実トークン使用量とともに返す。 + + Args: + model: モデルエイリアス(AgentModelAlias。router のスキーマで検証済み)。 + source: context.build_draft_source が組み立てた連携データ。 + today: 「参画中」判定の基準日(テスト注入用。省略時は当日)。 + + Raises: + AgentResponseParseError: LLM 応答が不正(リトライ後も失敗)。 + LLMError: LLM 呼び出しの失敗。 + """ + selected = select_repos(source) + skeleton = build_skeleton(source, selected, today=today) + allowed_names = [repo.full_name for repo in selected] + + spec = get_model_spec(model) + client = get_llm_client(spec.provider) + output_schema = build_draft_output_schema(allowed_names) + user_prompt = f"# リポジトリ情報\n{_build_repo_context(source, selected)}" + messages: list[dict[str, str]] = [{"role": "user", "content": user_prompt}] + + # 個人情報・リポジトリ本文はログに載せない(メタデータのみ) + logger.debug( + "ドラフト LLM 入力: model=%s repos=%d prompt_len=%d", + model, + len(selected), + len(user_prompt), + ) + + # リトライしても 1 回目の API 原価は発生しているため、使用量は合算で課金する(ADR-0012) + input_tokens = 0 + output_tokens = 0 + + def _usage() -> AgentUsage: + return AgentUsage(model=model, input_tokens=input_tokens, output_tokens=output_tokens) + + async def _generate_and_account(call_messages: list[dict[str, str]], *, label: str): + nonlocal input_tokens, output_tokens + call_result = await client.generate( + _SYSTEM_PROMPT, call_messages, output_schema, spec.model_id + ) + input_tokens += call_result.input_tokens + output_tokens += call_result.output_tokens + logger.debug("ドラフト LLM %s応答(パース前): len=%d", label, len(call_result.text)) + return call_result + + result = await _generate_and_account(messages, label="生") + try: + output = _parse_draft(result.text, set(allowed_names)) + return ResumeDraftResult( + payload=_merge_output(skeleton, selected, output), usage=_usage() + ) + except AgentResponseParseError as exc: + # 出力契約違反は 1 回だけリトライ(違反内容をフィードバックして再生成 / ADR-0010) + logger.warning("ドラフト LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__) + retry_messages = [ + *messages, + {"role": "assistant", "content": result.text}, + { + "role": "user", + "content": ( + "直前の応答は出力契約に違反しています。" + f"違反内容: {str(exc)[:_MAX_RETRY_ERROR_LENGTH]}\n" + "契約に従って同じ依頼への応答を再生成してください。" + ), + }, + ] + + try: + result = await _generate_and_account(retry_messages, label="リトライ") + except LLMError as retry_exc: + # 1 回目の API 原価は発生済み。使用量を載せて router 側で課金を確定させる(ADR-0012) + retry_exc.usage = _usage() + raise + try: + output = _parse_draft(result.text, set(allowed_names)) + except AgentResponseParseError as retry_exc: + # 2 回目も失敗。合算使用量を載せて伝播する(課金漏れ防止 / ADR-0012) + raise AgentResponseParseError(str(retry_exc), usage=_usage()) from retry_exc + return ResumeDraftResult(payload=_merge_output(skeleton, selected, output), usage=_usage()) diff --git a/backend/app/services/agent/resume_draft/mapper.py b/backend/app/services/agent/resume_draft/mapper.py new file mode 100644 index 00000000..ce8b3683 --- /dev/null +++ b/backend/app/services/agent/resume_draft/mapper.py @@ -0,0 +1,149 @@ +"""ドラフト骨格のルールベースマッピング(決定論・純関数 / ADR-0018)。 + +GitHub 連携データ(DraftSource)から、``build_resume_pdf(dict)`` が受け取れる +経歴書 payload の骨格を組み立てる。自然文(career_summary / self_pr / +プロジェクト description)は空または repo description のままにし、 +draft_service が LLM 出力をマージする。 + +DB・LLM・時刻に依存しない(``today`` は引数で受ける)。捏造につながる値 +(担当工程・チーム規模の水増し等)はここでは生成しない。 +""" + +from datetime import date + +from .context import DraftSource, RepoTechnology + +# ドラフトに載せるプロジェクト(リポジトリ)数の上限。LLM 出力の有界化と +# 同期エンドポイントのレイテンシ上限(ADR-0018)のための定数 +PROJECT_LIMIT = 5 +# 1 プロジェクトに載せる技術スタック数の上限(経歴書としての可読性) +STACK_LIMIT_PER_PROJECT = 8 +# 最終 push がこの日数以内なら「参画中」とみなす +_CURRENT_THRESHOLD_DAYS = 90 + +# 個人開発プレースホルダ(GitHub からは職歴が得られないため / ADR-0018) +PLACEHOLDER_COMPANY = "個人開発" +PLACEHOLDER_BUSINESS_DESCRIPTION = "GitHub 上での個人開発活動" +PLACEHOLDER_ROLE = "開発(個人開発)" + +# 技術スタックの表示順(言語 → フレームワーク → IaC) +_CATEGORY_ORDER: dict[str, int] = {"language": 0, "framework": 1, "iac": 2} + + +def select_repos(source: DraftSource, limit: int = PROJECT_LIMIT) -> list: + """ドラフトに載せるリポジトリを決定論的に選定する。 + + 第 1 キー: 最終 push 日時の降順(直近の活動を優先) + 第 2 キー: 言語バイト合計の降順(実装量の多いリポを優先) + タイブレーク: full_name の辞書順(決定論の担保) + """ + + def language_bytes_total(full_name: str) -> int: + return sum( + tech.language_bytes for tech in source.repo_technologies.get(full_name, []) + ) + + # 安定ソートを重ねて「タイブレーク昇順 → 主キー群降順」を実現する + repos = sorted(source.repos, key=lambda r: r.full_name) + repos = sorted( + repos, + key=lambda r: (r.pushed_at, language_bytes_total(r.full_name)), + reverse=True, + ) + return repos[:limit] + + +def build_skeleton( + source: DraftSource, selected: list, *, today: date | None = None +) -> dict: + """選定リポジトリから経歴書 payload の骨格を組み立てる。 + + ``today`` は「参画中」判定の基準日。テストからの注入用で、省略時は当日。 + """ + reference_date = today or date.today() + + projects = [ + _build_project(repo, source.repo_technologies.get(repo.full_name, []), reference_date) + for repo in selected + ] + + start_months = [ + _year_month(repo.created_at) for repo in selected if _year_month(repo.created_at) + ] + experience = { + "company": PLACEHOLDER_COMPANY, + "business_description": PLACEHOLDER_BUSINESS_DESCRIPTION, + "is_it_company": True, + "start_date": min(start_months) if start_months else "", + "end_date": "", + "is_current": True, + "clients": [{"name": "", "is_vacation": False, "projects": projects}], + } + + return { + "full_name": source.username, + "email": source.email, + "github_url": f"https://github.com/{source.username}", + "career_summary": "", + "self_pr": "", + "experiences": [experience], + "qualifications": [], + } + + +def _build_project(repo, technologies: list[RepoTechnology], reference_date: date) -> dict: + """リポジトリ 1 件をプロジェクト 1 件に写す。 + + description は LLM 出力で置換される前提のフォールバックとして repo description を + 先に入れておく(LLM 側の欠落 degrade / ADR-0018)。 + """ + is_current = _is_recently_pushed(repo.pushed_at, reference_date) + periods = [] + start = _year_month(repo.created_at) + if start: + periods.append( + { + "start_date": start, + # 参画中は end を "" で渡す契約(schemas/resume.py と同じ) + "end_date": "" if is_current else _year_month(repo.pushed_at), + "is_current": is_current, + } + ) + return { + "name": repo.full_name.split("/", 1)[-1], + "role": PLACEHOLDER_ROLE, + "description": repo.description, + "periods": periods, + "team": {"total": "1", "members": [{"role": "開発", "count": 1}]}, + "phases": [], + "technology_stacks": [ + {"category": tech.category, "name": tech.name} + for tech in _select_stacks(technologies) + ], + } + + +def _select_stacks(technologies: list[RepoTechnology]) -> list[RepoTechnology]: + """技術スタックを表示順に並べ、上限件数に絞る。 + + カテゴリ順(言語 → FW → IaC)→ 量的シグナル(バイト数・confidence)降順 → + 名前昇順の決定論ソート。 + """ + ordered = sorted(technologies, key=lambda t: t.name) + ordered = sorted(ordered, key=lambda t: (t.language_bytes, t.confidence), reverse=True) + ordered = sorted(ordered, key=lambda t: _CATEGORY_ORDER.get(t.category, len(_CATEGORY_ORDER))) + return ordered[:STACK_LIMIT_PER_PROJECT] + + +def _year_month(iso_datetime: str) -> str: + """ISO 8601 日時文字列から YYYY-MM を取り出す(不正・空は空文字)。""" + return iso_datetime[:7] if len(iso_datetime) >= 7 else "" + + +def _is_recently_pushed(pushed_at: str, reference_date: date) -> bool: + """最終 push が基準日から閾値日数以内かどうか。""" + try: + pushed = date.fromisoformat(pushed_at[:10]) + except ValueError: + return False + return (reference_date - pushed).days <= _CURRENT_THRESHOLD_DAYS diff --git a/backend/app/services/agent/resume_draft/output_schema.py b/backend/app/services/agent/resume_draft/output_schema.py new file mode 100644 index 00000000..443c29e4 --- /dev/null +++ b/backend/app/services/agent/resume_draft/output_schema.py @@ -0,0 +1,67 @@ +"""経歴書ドラフトの LLM 構造化出力スキーマ(機械制約の正本 / ADR-0018)。 + +チャットのスキーマ(``../output_schema.py``)と同じ責務分離に従う: +機械検証可能な制約(フィールド構造・文字数上限・リポ名の許可集合)はここに置き、 +品質制約(文体・捏造禁止・目安字数)は ``prompts/agent_resume_draft.md`` に置く。 + +チャットと異なり ``repo_full_name`` を選定リポジトリの enum で縛るため、スキーマは +リクエストごとに動的構築する(存在しないリポジトリへの言及を構造的に排除する)。 +プロンプト md は静的を維持し、動的情報は user メッセージの JSON コンテキストに載せる。 + +文字数上限は保存契約(schemas/resume.py)と揃えるため、チャット側の +``SCOPE_FIELDS``(``test_scope_limits_match_resume_schema`` で drift 検証済み)を参照する。 +""" + +from ..output_schema import SCOPE_FIELDS + +# 上限の正本はチャット側 SCOPE_FIELDS(= schemas/resume.py と drift テスト済み) +MAX_CAREER_SUMMARY_LENGTH = SCOPE_FIELDS["career_summary"]["career_summary"] +MAX_SELF_PR_LENGTH = SCOPE_FIELDS["self_pr"]["self_pr"] +MAX_PROJECT_DESCRIPTION_LENGTH = SCOPE_FIELDS["project"]["description"] + + +def build_draft_output_schema(repo_full_names: list[str]) -> dict: + """選定リポジトリの許可集合から、ドラフト生成の出力 JSON Schema を構築する。 + + LLM クライアント側の tool 定義(``build_tool_definition`` / tool 名は + ``propose_revision`` 固定)にそのまま渡せる形。maxLength は API では強制されない + ため、上限超過の扱いは draft_service のパース側が担う(二重防衛)。 + """ + return { + "type": "object", + "properties": { + "career_summary": { + "type": "string", + "maxLength": MAX_CAREER_SUMMARY_LENGTH, + "description": "職務要約。経歴書にそのまま掲載できる完成した日本語の文章", + }, + "self_pr": { + "type": "string", + "maxLength": MAX_SELF_PR_LENGTH, + "description": "自己PR。経歴書にそのまま掲載できる完成した日本語の文章", + }, + "project_descriptions": { + "type": "array", + "description": "各リポジトリの業務内容説明。与えられたリポジトリのみ対象", + "maxItems": len(repo_full_names), + "items": { + "type": "object", + "properties": { + "repo_full_name": { + "type": "string", + "enum": repo_full_names, + }, + "description": { + "type": "string", + "maxLength": MAX_PROJECT_DESCRIPTION_LENGTH, + "description": "経歴書にそのまま掲載できる完成した日本語の本文", + }, + }, + "required": ["repo_full_name", "description"], + "additionalProperties": False, + }, + }, + }, + "required": ["career_summary", "self_pr", "project_descriptions"], + "additionalProperties": False, + } diff --git a/backend/app/services/billing/credit_service.py b/backend/app/services/billing/credit_service.py index 5fa36a77..d1dcc1e1 100644 --- a/backend/app/services/billing/credit_service.py +++ b/backend/app/services/billing/credit_service.py @@ -46,19 +46,24 @@ def ensure_can_use_model(db: Session, user_id: str, model_alias: str) -> None: ) -def record_chat_usage(db: Session, user_id: str, usage: AgentUsage) -> int | None: - """チャット 1 回分の使用量を記録し、有料モデルならクレジットを消費する。 +def record_chat_usage( + db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None +) -> int | None: + """LLM 利用 1 回分の使用量を記録し、有料モデルならクレジットを消費する。 無料モデルは使用ログのみ記録して None を返す。有料モデルは残高減算・台帳追記・ 使用ログ記録を単一トランザクションで原子的に確定し、適用後残高を返す。記録の 失敗(途中での例外)は全体を rollback して呼び出し元へ伝播させる (課金漏れ・課金とログの不整合を黙って通さない方針 / ADR-0012)。 + + description は台帳の用途表示。省略時は既定のチャット文言(経歴書ドラフト生成 + など別用途の呼び出し元が上書きする / ADR-0018)。 """ cost = calculate_credit_cost(usage.model, usage.input_tokens, usage.output_tokens) balance_after = BillingRepository(db, user_id).record_chat_consumption( amount=-cost, transaction_type=TRANSACTION_TYPE_CONSUMPTION, - description=f"Agent チャット({usage.model})", + description=description or f"Agent チャット({usage.model})", model_alias=usage.model, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index 036c3e86..fe6ccb68 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -144,7 +144,8 @@ async def _on_repo_fetched(done: int, total: int) -> None: # dashboard 表示用サマリ(件数・言語バイト数)を検出スキルから組む。 result = aggregate_intelligence(payload["github_username"], repos, detected_skills) - response = map_pipeline_result(result) + # リポジトリ単位のサマリも保存対象に含める(経歴書ドラフト生成の入力 / ADR-0018) + response = map_pipeline_result(result, repos) response.contribution_calendars = calendars result_dict = response.model_dump() diff --git a/backend/app/services/intelligence/response_mapper.py b/backend/app/services/intelligence/response_mapper.py index 806d027a..45fc17d1 100644 --- a/backend/app/services/intelligence/response_mapper.py +++ b/backend/app/services/intelligence/response_mapper.py @@ -1,15 +1,33 @@ -from ...schemas.github_link import GitHubLinkResponse +from typing import List + +from ...schemas.github_link import AnalyzedRepoSummary, GitHubLinkResponse +from .github_collector import RepoData from .pipeline import IntelligenceResult -def map_pipeline_result(result: IntelligenceResult) -> GitHubLinkResponse: +def map_pipeline_result( + result: IntelligenceResult, repos: List[RepoData] | None = None +) -> GitHubLinkResponse: """ パイプラインの実行結果を API レスポンス形式に変換します。 + + repos を渡すと、経歴書ドラフト生成(ADR-0018)の入力になるリポジトリ単位の + サマリを ``repos`` フィールドに詰めて永続化対象へ含める。 """ + repo_summaries = [ + AnalyzedRepoSummary( + full_name=f"{repo.owner}/{repo.name}", + description=repo.description, + created_at=repo.created_at, + pushed_at=repo.pushed_at, + ) + for repo in (repos or []) + ] return GitHubLinkResponse( username=result.username, repos_analyzed=result.repos_analyzed, unique_skills=result.unique_skills, analyzed_at=result.analyzed_at, languages=result.languages, + repos=repo_summaries, ) diff --git a/backend/tests/test_resume_draft_api.py b/backend/tests/test_resume_draft_api.py new file mode 100644 index 00000000..0519aabe --- /dev/null +++ b/backend/tests/test_resume_draft_api.py @@ -0,0 +1,246 @@ +"""経歴書ドラフト PDF エンドポイント(POST /api/agent/resume-draft/pdf)の統合テスト(ADR-0018)。 + +LLM のみモックし、認可ガード・課金配線・409/502 のエラーマッピング・PDF 生成は +実コードを通す(DB は実 SQLite セッション)。 +""" + +import json + +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 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 無しの旧形式。""" + user = db.query(User).filter_by(username=username).one() + result = { + "username": username, + "repos_analyzed": 1, + "unique_skills": 1, + "analyzed_at": "2026-06-01T00:00:00", + "languages": {"Python": 1000}, + } + if not legacy: + result["repos"] = [ + { + "full_name": "octo/app", + "description": "タスク管理アプリ", + "created_at": "2024-01-01T00:00:00Z", + "pushed_at": "2026-06-01T00:00:00Z", + } + ] + 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(GitHubLinkCache(user_id=user.id, status="completed", result=result)) + 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()) + ) + + 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") + + # 課金・使用ログの配線(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 + + +def test_resume_draft_pdf_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) + assert res.status_code == 403 + + +def test_resume_draft_pdf_requires_credits_for_paid_model(client: TestClient) -> None: + """有料モデルは残高 0 だと LLM を呼ぶ前に 402。""" + headers = auth_header(client, github_id=1) + res = client.post("/api/agent/resume-draft/pdf", json={"model": "sonnet"}, headers=headers) + assert res.status_code == 402 + + +def test_resume_draft_pdf_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) + assert res.status_code == 409 + + +def test_resume_draft_pdf_conflict_with_legacy_cache(client: TestClient) -> None: + """repos キーを持たない旧形式キャッシュ(ADR-0018 以前の連携結果)は 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) + assert res.status_code == 409 + + +def test_resume_draft_pdf_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() + client._db_session.add( + GitHubLinkCache( + user_id=user.id, + status="completed", + result={ + "username": "testuser", + "repos_analyzed": 0, + "unique_skills": 0, + "analyzed_at": "2026-06-01T00:00:00", + "languages": {}, + "repos": [], + }, + ) + ) + client._db_session.commit() + + res = client.post("/api/agent/resume-draft/pdf", 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(消費済みトークンの使用ログは記録される)。""" + 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/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 + + +def test_resume_draft_pdf_generation_failure_not_charged( + client: TestClient, monkeypatch +) -> None: + """PDF 生成が失敗した場合はユーザーに課金しない(使用ログも残さない / CodeRabbit 指摘)。""" + from app.routers import agent as agent_router + + 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 _fail_pdf(_payload): + raise RuntimeError("PDF 生成失敗") + + 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 + + +def test_resume_draft_pdf_invalid_model_rejected(client: TestClient) -> None: + """未知のモデルエイリアスはスキーマ検証で 422。""" + headers = auth_header(client, github_id=1) + res = client.post( + "/api/agent/resume-draft/pdf", json={"model": "gpt-999"}, headers=headers + ) + assert res.status_code == 422 + + +def test_github_link_response_backward_compat_without_repos() -> None: + """旧形式キャッシュ JSON(repos 無し)が GitHubLinkResponse として検証できる。""" + legacy = { + "username": "octo", + "repos_analyzed": 3, + "unique_skills": 2, + "analyzed_at": "2025-01-01T00:00:00", + "languages": {"Python": 100}, + } + parsed = GitHubLinkResponse.model_validate(legacy) + assert parsed.repos == [] diff --git a/backend/tests/test_resume_draft_mapper.py b/backend/tests/test_resume_draft_mapper.py new file mode 100644 index 00000000..a98a5581 --- /dev/null +++ b/backend/tests/test_resume_draft_mapper.py @@ -0,0 +1,319 @@ +"""経歴書ドラフトのルールベースマッピング(mapper / context)の単体テスト(ADR-0018)。 + +mapper は純関数として DraftSource を直接組み立てて検証する。 +context(スキル証跡の反転・連携キャッシュの検証)は実 SQLite セッションで検証する +(DB モック禁止 / .claude/rules/backend/test.md)。 +""" + +from datetime import date + +import pytest +from app.models import GitHubLinkCache, User +from app.models.skill import GitHubSkill, GitHubSkillEvidence +from app.schemas.github_link import AnalyzedRepoSummary +from app.schemas.resume import TechnologyStackItem +from app.services.agent.resume_draft.context import ( + DraftSource, + RepoTechnology, + ResumeDraftNoRepositoriesError, + ResumeDraftSourceUnavailableError, + build_draft_source, +) +from app.services.agent.resume_draft.mapper import ( + PLACEHOLDER_COMPANY, + PROJECT_LIMIT, + STACK_LIMIT_PER_PROJECT, + build_skeleton, + select_repos, +) + +_TODAY = date(2026, 7, 1) + + +def _repo(full_name: str, *, description: str = "", created: str = "2023-01-15T00:00:00Z", + pushed: str = "2026-06-01T00:00:00Z") -> AnalyzedRepoSummary: + """テスト用のリポジトリサマリを生成する。""" + return AnalyzedRepoSummary( + full_name=full_name, description=description, created_at=created, pushed_at=pushed + ) + + +def _source(repos: list, technologies: dict | None = None) -> DraftSource: + """テスト用の DraftSource を生成する。""" + return DraftSource( + username="octocat", + email="octo@example.com", + repos=repos, + repo_technologies=technologies or {}, + ) + + +# --------------------------------------------------------------------------- +# select_repos: リポジトリ選定の決定論 +# --------------------------------------------------------------------------- + + +def test_select_repos_orders_by_pushed_at_desc() -> None: + """第 1 キーは最終 push 日時の降順。""" + source = _source( + [ + _repo("o/old", pushed="2024-01-01T00:00:00Z"), + _repo("o/new", pushed="2026-06-01T00:00:00Z"), + _repo("o/mid", pushed="2025-06-01T00:00:00Z"), + ] + ) + assert [r.full_name for r in select_repos(source)] == ["o/new", "o/mid", "o/old"] + + +def test_select_repos_tiebreaks_by_language_bytes_then_name() -> None: + """push 日時が同じなら言語バイト合計の降順、それも同じなら名前の辞書順。""" + pushed = "2026-06-01T00:00:00Z" + source = _source( + [_repo("o/small", pushed=pushed), _repo("o/big", pushed=pushed), + _repo("o/b-zero", pushed=pushed), _repo("o/a-zero", pushed=pushed)], + technologies={ + "o/small": [RepoTechnology("language", "Python", 0.9, language_bytes=100)], + "o/big": [RepoTechnology("language", "Python", 0.9, language_bytes=9000)], + }, + ) + assert [r.full_name for r in select_repos(source)] == [ + "o/big", "o/small", "o/a-zero", "o/b-zero", + ] + + +def test_select_repos_caps_at_project_limit() -> None: + """上限件数(PROJECT_LIMIT)で打ち切る。""" + repos = [_repo(f"o/repo-{i}", pushed=f"2026-01-{i + 1:02d}T00:00:00Z") for i in range(8)] + selected = select_repos(_source(repos)) + assert len(selected) == PROJECT_LIMIT + # 直近 push 順に上位が選ばれている + assert selected[0].full_name == "o/repo-7" + + +# --------------------------------------------------------------------------- +# build_skeleton: 骨格 payload の構築 +# --------------------------------------------------------------------------- + + +def test_build_skeleton_top_level_and_placeholder_experience() -> None: + """トップレベルとプレースホルダ職歴(個人開発)が契約どおり組み立てられる。""" + source = _source( + [ + _repo("o/first", created="2022-03-10T00:00:00Z"), + _repo("o/second", created="2021-11-05T00:00:00Z"), + ] + ) + selected = select_repos(source) + payload = build_skeleton(source, selected, today=_TODAY) + + assert payload["full_name"] == "octocat" + assert payload["email"] == "octo@example.com" + assert payload["github_url"] == "https://github.com/octocat" + assert payload["career_summary"] == "" + assert payload["self_pr"] == "" + assert payload["qualifications"] == [] + + (experience,) = payload["experiences"] + assert experience["company"] == PLACEHOLDER_COMPANY + assert experience["is_it_company"] is True + assert experience["is_current"] is True + assert experience["end_date"] == "" + # 選定リポの最古 created_at(YYYY-MM)を職歴の開始にする + assert experience["start_date"] == "2021-11" + (client,) = experience["clients"] + assert len(client["projects"]) == 2 + + +def test_build_skeleton_project_period_current_boundary() -> None: + """最終 push が 90 日以内なら参画中(end は空文字契約)、超えたら期間を閉じる。""" + source = _source( + [ + _repo("o/active", created="2024-01-01T00:00:00Z", pushed="2026-04-05T00:00:00Z"), + _repo("o/stale", created="2024-01-01T00:00:00Z", pushed="2026-03-01T00:00:00Z"), + ] + ) + payload = build_skeleton(source, select_repos(source), today=_TODAY) + projects = {p["name"]: p for p in payload["experiences"][0]["clients"][0]["projects"]} + + active_period = projects["active"]["periods"][0] + assert active_period == {"start_date": "2024-01", "end_date": "", "is_current": True} + + stale_period = projects["stale"]["periods"][0] + assert stale_period == {"start_date": "2024-01", "end_date": "2026-03", "is_current": False} + + +def test_build_skeleton_skips_period_without_created_at() -> None: + """created_at が空のリポは期間を出さない(不正な期間を捏造しない)。""" + source = _source([_repo("o/no-date", created="", pushed="")]) + payload = build_skeleton(source, select_repos(source), today=_TODAY) + (project,) = payload["experiences"][0]["clients"][0]["projects"] + assert project["periods"] == [] + # 職歴の開始も空になる + assert payload["experiences"][0]["start_date"] == "" + + +def test_build_skeleton_description_falls_back_to_repo_description() -> None: + """プロジェクト description は LLM マージ前のフォールバックとして repo description を持つ。""" + source = _source([_repo("o/app", description="タスク管理アプリ")]) + payload = build_skeleton(source, select_repos(source), today=_TODAY) + (project,) = payload["experiences"][0]["clients"][0]["projects"] + assert project["description"] == "タスク管理アプリ" + assert project["team"] == {"total": "1", "members": [{"role": "開発", "count": 1}]} + assert project["phases"] == [] + + +def test_build_skeleton_stacks_ordered_and_capped() -> None: + """技術スタックはカテゴリ順(言語→FW→IaC)・量的シグナル降順で上限件数に絞る。""" + technologies = [ + RepoTechnology("iac", "terraform-provider-aws", 0.9), + RepoTechnology("framework", "fastapi", 0.9), + RepoTechnology("language", "Python", 0.9, language_bytes=5000), + RepoTechnology("language", "TypeScript", 0.9, language_bytes=9000), + ] + [RepoTechnology("framework", f"lib-{i}", 0.5 - i * 0.01) for i in range(6)] + source = _source([_repo("o/app")], technologies={"o/app": technologies}) + payload = build_skeleton(source, select_repos(source), today=_TODAY) + (project,) = payload["experiences"][0]["clients"][0]["projects"] + + stacks = project["technology_stacks"] + assert len(stacks) == STACK_LIMIT_PER_PROJECT + # 言語がバイト数降順で先頭に並ぶ + assert stacks[0] == {"category": "language", "name": "TypeScript"} + assert stacks[1] == {"category": "language", "name": "Python"} + # framework は confidence 降順(fastapi 0.9 が先頭) + assert stacks[2] == {"category": "framework", "name": "fastapi"} + # 各カテゴリが保存契約(schemas/resume.py の Literal)に収まる + for stack in stacks: + TechnologyStackItem.model_validate(stack) + + +# --------------------------------------------------------------------------- +# context: 連携キャッシュの検証とスキル証跡の反転(実 SQLite) +# --------------------------------------------------------------------------- + + +def _create_user(db_session) -> User: + """テスト用ユーザーを作成して返す。""" + user = User(username="octocat", email="octo@example.com") + db_session.add(user) + db_session.commit() + return user + + +def _cache_result(repos: list[dict] | None) -> dict: + """連携キャッシュの result JSON を生成する(repos=None で旧形式を再現)。""" + result: dict = { + "username": "octocat", + "repos_analyzed": 1, + "unique_skills": 1, + "analyzed_at": "2026-06-01T00:00:00", + "languages": {"Python": 1000}, + } + if repos is not None: + result["repos"] = repos + return result + + +def _add_skill(db_session, user_id: str, *, kind: str, name: str, evidence: list[dict]) -> None: + """スキル + 証跡を直接投入する。""" + skill = GitHubSkill(user_id=user_id, kind=kind, canonical_name=name) + skill.evidence = [GitHubSkillEvidence(**ev) for ev in evidence] + db_session.add(skill) + db_session.commit() + + +def test_build_draft_source_requires_completed_cache(db_session) -> None: + """キャッシュ無し・未完了はドラフト入力を組み立てられない。""" + user = _create_user(db_session) + with pytest.raises(ResumeDraftSourceUnavailableError): + build_draft_source(db_session, user) + + db_session.add(GitHubLinkCache(user_id=user.id, status="processing", result=None)) + db_session.commit() + with pytest.raises(ResumeDraftSourceUnavailableError): + build_draft_source(db_session, user) + + +def test_build_draft_source_rejects_legacy_cache_without_repos(db_session) -> None: + """ADR-0018 以前の旧形式キャッシュ(repos キー無し)は再連携が必要。""" + user = _create_user(db_session) + db_session.add( + GitHubLinkCache(user_id=user.id, status="completed", result=_cache_result(repos=None)) + ) + db_session.commit() + with pytest.raises(ResumeDraftSourceUnavailableError): + build_draft_source(db_session, user) + + +def test_build_draft_source_rejects_empty_repos_as_no_repositories(db_session) -> None: + """新形式で repos が空リスト(分析対象 0 件)は旧形式と区別され、専用例外になる。""" + user = _create_user(db_session) + db_session.add( + GitHubLinkCache(user_id=user.id, status="completed", result=_cache_result(repos=[])) + ) + db_session.commit() + # 0 件は旧形式(再連携で回復)ではなく NoRepositories(リポジトリ追加が必要) + with pytest.raises(ResumeDraftNoRepositoriesError): + build_draft_source(db_session, user) + + +def test_build_draft_source_inverts_skill_evidence(db_session) -> None: + """スキル証跡が「リポ → 技術」に反転され、採用基準どおりフィルタされる。""" + user = _create_user(db_session) + repo_summary = { + "full_name": "octocat/app", + "description": "アプリ", + "created_at": "2024-01-01T00:00:00Z", + "pushed_at": "2026-06-01T00:00:00Z", + } + db_session.add( + GitHubLinkCache( + user_id=user.id, status="completed", result=_cache_result(repos=[repo_summary]) + ) + ) + db_session.commit() + + _add_skill( + db_session, user.id, kind="language", name="Python", + evidence=[{ + "repo_full_name": "octocat/app", "signal_source": "language_bytes", + "confidence": 0.9, "language_bytes": 1234, + }], + ) + # direct 宣言 + 実 import の二重証跡 → confidence の大きい方に畳まれる + _add_skill( + db_session, user.id, kind="package", name="fastapi", + evidence=[ + {"repo_full_name": "octocat/app", "signal_source": "manifest_declared", + "confidence": 0.6, "dependency_kind": "direct"}, + {"repo_full_name": "octocat/app", "signal_source": "actual_import", + "confidence": 0.8, "dependency_kind": "direct"}, + ], + ) + # 間接依存(direct でも actual_import でもない)→ 採用しない + _add_skill( + db_session, user.id, kind="package", name="urllib3", + evidence=[{ + "repo_full_name": "octocat/app", "signal_source": "manifest_declared", + "confidence": 0.3, "dependency_kind": "indirect", + }], + ) + _add_skill( + db_session, user.id, kind="infra", name="aws", + evidence=[{ + "repo_full_name": "octocat/app", "signal_source": "infra_declared", + "confidence": 0.7, + }], + ) + + source = build_draft_source(db_session, user) + assert source.username == "octocat" + assert [r.full_name for r in source.repos] == ["octocat/app"] + + technologies = { + (t.category, t.name): t for t in source.repo_technologies["octocat/app"] + } + assert set(technologies) == { + ("language", "Python"), ("framework", "fastapi"), ("iac", "aws"), + } + assert technologies[("language", "Python")].language_bytes == 1234 + assert technologies[("framework", "fastapi")].confidence == 0.8 diff --git a/backend/tests/test_resume_draft_service.py b/backend/tests/test_resume_draft_service.py new file mode 100644 index 00000000..880c650f --- /dev/null +++ b/backend/tests/test_resume_draft_service.py @@ -0,0 +1,194 @@ +"""経歴書ドラフト生成サービス(draft_service)の単体テスト(ADR-0018)。 + +LLM のみモックし、パース・degrade・リトライ・課金用 usage の合算は実コードを通す。 +async 実行はグローバル event loop を触らない分離パターンで行う +(mutmut の clean test 対策 / .claude/rules/backend/test.md)。 +""" + +import asyncio +import json + +import pytest +from app.schemas.github_link import AnalyzedRepoSummary +from app.services.agent.chat_service import AgentResponseParseError +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult +from app.services.agent.resume_draft import draft_service +from app.services.agent.resume_draft.context import DraftSource, RepoTechnology +from app.services.agent.resume_draft.draft_service import run_resume_draft +from app.services.agent.resume_draft.output_schema import ( + MAX_PROJECT_DESCRIPTION_LENGTH, + build_draft_output_schema, +) + + +class _SequentialFakeLLM(LLMClient): + """呼び出しごとに応答(または例外)を順に返す LLM クライアント。""" + + def __init__(self, responses: list, input_tokens: int = 10, output_tokens: int = 20): + """順に返す応答(str)または送出する例外(Exception)のリストを受け取る。""" + self._responses = list(responses) + self._input_tokens = input_tokens + self._output_tokens = output_tokens + self.calls: list[list[dict[str, str]]] = [] + self.received_output_schema: dict | None = None + + async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult: + """受信 messages を記録し、呼び出し順に対応した応答を返すか例外を送出する。""" + self.calls.append(messages) + self.received_output_schema = output_schema + item = self._responses[len(self.calls) - 1] + if isinstance(item, Exception): + raise item + return LLMResult( + text=item, input_tokens=self._input_tokens, output_tokens=self._output_tokens + ) + + +def _mock_llm(monkeypatch, responses: list) -> _SequentialFakeLLM: + """draft_service の LLM クライアントを差し替え、そのインスタンスを返す。""" + fake = _SequentialFakeLLM(responses) + monkeypatch.setattr(draft_service, "get_llm_client", lambda provider: fake) + return fake + + +def _run(coro): + """グローバル event loop を汚さずにコルーチンを実行する。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _source() -> DraftSource: + """リポ 2 件のテスト用 DraftSource を返す。""" + return DraftSource( + username="octocat", + email="octo@example.com", + repos=[ + AnalyzedRepoSummary( + full_name="octocat/newer", description="新しい方", + created_at="2024-01-01T00:00:00Z", pushed_at="2026-06-01T00:00:00Z", + ), + AnalyzedRepoSummary( + full_name="octocat/older", description="古い方", + created_at="2022-01-01T00:00:00Z", pushed_at="2024-06-01T00:00:00Z", + ), + ], + repo_technologies={ + "octocat/newer": [RepoTechnology("language", "Python", 0.9, language_bytes=100)], + }, + ) + + +def _draft_json(descriptions: dict[str, str] | None = None, **overrides) -> str: + """契約に沿ったドラフト応答 JSON を生成する。""" + if descriptions is None: + descriptions = {"octocat/newer": "新しい方の説明。", "octocat/older": "古い方の説明。"} + data = { + "career_summary": "生成された職務要約。", + "self_pr": "生成された自己PR。", + "project_descriptions": [ + {"repo_full_name": name, "description": text} + for name, text in descriptions.items() + ], + } + data.update(overrides) + return json.dumps(data, ensure_ascii=False) + + +def test_draft_success_merges_llm_output(monkeypatch) -> None: + """LLM の自然文が骨格(選定順)へマージされ、usage が実測値で返る。""" + fake = _mock_llm(monkeypatch, [_draft_json()]) + result = _run(run_resume_draft("haiku", _source())) + + assert result.payload["career_summary"] == "生成された職務要約。" + assert result.payload["self_pr"] == "生成された自己PR。" + projects = result.payload["experiences"][0]["clients"][0]["projects"] + # 選定順(pushed_at 降順): newer → older + assert [p["name"] for p in projects] == ["newer", "older"] + assert projects[0]["description"] == "新しい方の説明。" + assert projects[1]["description"] == "古い方の説明。" + + assert result.usage.model == "haiku" + assert result.usage.input_tokens == 10 + assert result.usage.output_tokens == 20 + # 出力スキーマの enum が選定リポで縛られている(捏造リポの構造排除) + expected_schema = build_draft_output_schema(["octocat/newer", "octocat/older"]) + assert fake.received_output_schema == expected_schema + + +def test_draft_missing_description_falls_back_to_repo_description(monkeypatch) -> None: + """説明が返らなかったプロジェクトは repo description のまま残す(degrade)。""" + _mock_llm(monkeypatch, [_draft_json({"octocat/newer": "新しい方の説明。"})]) + result = _run(run_resume_draft("haiku", _source())) + projects = result.payload["experiences"][0]["clients"][0]["projects"] + assert projects[0]["description"] == "新しい方の説明。" + assert projects[1]["description"] == "古い方" + + +def test_draft_drops_unknown_repo_and_over_limit_description(monkeypatch) -> None: + """許可外リポの説明・上限超過の説明は破棄され、フォールバックが残る(切り詰めない)。""" + response = _draft_json( + { + "octocat/evil": "存在しないリポの説明。", + "octocat/older": "あ" * (MAX_PROJECT_DESCRIPTION_LENGTH + 1), + } + ) + _mock_llm(monkeypatch, [response]) + result = _run(run_resume_draft("haiku", _source())) + projects = result.payload["experiences"][0]["clients"][0]["projects"] + assert projects[0]["description"] == "新しい方" + assert projects[1]["description"] == "古い方" + + +def test_draft_retries_once_with_error_feedback(monkeypatch) -> None: + """1 回目が契約違反なら違反内容をフィードバックして 1 回だけリトライする。""" + fake = _mock_llm(monkeypatch, ["JSON ではない応答", _draft_json()]) + result = _run(run_resume_draft("haiku", _source())) + + assert result.payload["career_summary"] == "生成された職務要約。" + assert len(fake.calls) == 2 + # リトライには前回応答(assistant)と違反フィードバック(user)が含まれる + retry_messages = fake.calls[1] + assert retry_messages[-2]["role"] == "assistant" + assert "出力契約に違反" in retry_messages[-1]["content"] + # 使用量はリトライ分も合算される(課金漏れ防止 / ADR-0012) + assert result.usage.input_tokens == 20 + assert result.usage.output_tokens == 40 + + +def test_draft_empty_career_summary_is_contract_violation(monkeypatch) -> None: + """career_summary の欠落は degrade せずリトライ対象の契約違反として扱う。""" + fake = _mock_llm(monkeypatch, [_draft_json(career_summary=""), _draft_json()]) + result = _run(run_resume_draft("haiku", _source())) + assert len(fake.calls) == 2 + assert result.payload["career_summary"] == "生成された職務要約。" + + +def test_draft_retry_failure_raises_with_accumulated_usage(monkeypatch) -> None: + """2 回とも契約違反なら合算 usage を載せて AgentResponseParseError を送出する。""" + _mock_llm(monkeypatch, ["不正応答 1 回目", "不正応答 2 回目"]) + with pytest.raises(AgentResponseParseError) as exc_info: + _run(run_resume_draft("haiku", _source())) + assert exc_info.value.usage is not None + assert exc_info.value.usage.input_tokens == 20 + assert exc_info.value.usage.output_tokens == 40 + + +def test_draft_llm_error_on_retry_carries_usage(monkeypatch) -> None: + """リトライ呼び出し自体の失敗は 1 回目分の usage を載せて LLMError を伝播する。""" + _mock_llm(monkeypatch, ["不正応答", LLMError("timeout")]) + with pytest.raises(LLMError) as exc_info: + _run(run_resume_draft("haiku", _source())) + assert exc_info.value.usage is not None + assert exc_info.value.usage.input_tokens == 10 + assert exc_info.value.usage.output_tokens == 20 + + +def test_draft_llm_error_on_first_call_propagates(monkeypatch) -> None: + """1 回目の呼び出し失敗はトークン未消費のため usage 無しで伝播する。""" + _mock_llm(monkeypatch, [LLMError("connection refused")]) + with pytest.raises(LLMError) as exc_info: + _run(run_resume_draft("haiku", _source())) + assert exc_info.value.usage is None diff --git a/docs/adr/0018-github-resume-draft-generation.md b/docs/adr/0018-github-resume-draft-generation.md new file mode 100644 index 00000000..be5b0bf2 --- /dev/null +++ b/docs/adr/0018-github-resume-draft-generation.md @@ -0,0 +1,79 @@ +# ADR-0018: GitHub 連携データからの経歴書ドラフト生成(ルールベース骨格 + LLM 自然文のハイブリッド) + +## ステータス + +Accepted + +## 関連 ADR + +- 関連: [ADR-0010](./0010-devforge-agent.md)(「LLM は `services/agent/` のみ」「制約の責務分離」「DB 非更新」「エラー契約」を全継承し、適用範囲を対話型チャットから単発生成へ拡張する) +- 関連: [ADR-0012](./0012-agent-model-switching-and-prepaid-billing.md)(モデルエイリアス・残高チェック・使用量記録の課金配線をそのまま使う) +- 関連: [ADR-0013](./0013-multi-provider-llm-selection.md) / [ADR-0015](./0015-vertex-ai-for-gemini-anthropic.md)(プロバイダ抽象・Vertex 認証を共有) +- 関連: [ADR-0016](./0016-github-skill-inference.md)(連携パイプラインの決定論データ=スキル証跡がドラフトの供給源) + +## コンテキスト + +GitHub 連携(ADR-0016)で得たリポジトリ・スキル推論結果は dashboard 表示にしか使われておらず、プロダクトの中心である職務経歴書(Resume)へ還流していない。GitHub 連携済みユーザーが「連携データから経歴書のたたき台を PDF で得る」機能を作りたい。 + +論点は 3 つあった。 + +1. **生成方式**: ルールベースか LLM か。GitHub データから経歴書の**構造**(プロジェクト・技術スタック・期間)は決定論的に写せるが、**自然文**(職務要約・自己PR・プロジェクト説明文)はルールベースだと定型文にしかならず、たたき台としての価値がない。逆に構造まで LLM に作らせると、存在しないリポジトリ・期間・技術の捏造を検証するコストが発生する。 +2. **データ供給**: 連携キャッシュ(`github_link_cache.result`)にはリポジトリ単位の情報(description・作成/最終 push 日時)が保存されておらず、集計値しか残らない。ドラフト生成時に GitHub API を再度叩くか、連携パイプラインの保存内容を拡張するか。 +3. **永続化**: `resumes` テーブルは 1 ユーザー 1 件の一意制約があり、ドラフトを保存すると既存の経歴書と衝突する。 + +## 決定内容 + +### 1. ハイブリッド方式(構造 = ルールベース / 自然文 = LLM) + +| 生成対象 | 方式 | +|---|---| +| プロジェクト骨格(repo → project)・技術スタック(スキル証跡 → technology_stacks)・期間 | ルールベース(決定論・純関数) | +| 職務要約・自己PR・各プロジェクト説明文 | LLM(1 コール・構造化出力) | + +ADR-0016 の「機械=幅 / 人間=深さ」の責務分離を「機械=構造 / LLM=文章 / 人間=最終確認・編集」へ延長する。LLM の出力スキーマでは `repo_full_name` を選定リポジトリの enum で縛り、捏造リポジトリへの言及を構造的に排除する。 + +### 2. 実装は `services/agent/resume_draft/` に置く + +ADR-0010 の「LLM は `services/agent/` のみ」という機械 grep 可能な不変条件を**変更せず**、サブパッケージとして配置する。LLM 抽象(`llm/`)・`model_catalog`・課金(ADR-0012)・プロンプト分離パターンをディレクトリ内で共有する。Agent の不変条件(機械制約はスキーマ / 品質制約はプロンプト、リトライ 1 回、`LLMError` / パース失敗の 502 契約、usage を載せた課金漏れ防止)を全て継承する。 + +### 3. 何も永続化しない(PDF プレビューのみ) + +ドラフト payload は `resumes` テーブルに書かず、既存の `build_resume_pdf(dict)` に直接渡して PDF ストリームで返す。ADR-0010 の「DB 非更新」(フロント state のみ更新)より更に踏み込み、**生成物はレスポンスの PDF だけ**とする。気に入らなければ再生成、採用するなら既存フォームへ手で転記する運用(フォーム流し込みは将来フェーズ)。例外は ADR-0012 のクレジット消費・使用ログ記録のみ。 + +### 4. 連携パイプラインの保存内容を拡張する(API 再取得はしない) + +`GitHubLinkResponse` に `repos: list[AnalyzedRepoSummary]`(full_name / description / created_at / pushed_at)を追加し、連携実行時に `RepoData` から詰めて `github_link_cache.result`(JSON カラム)へ保存する。マイグレーション不要・旧 JSON との後方互換(Optional)。ドラフト生成エンドポイントの失敗モードが「LLM だけ」に閉じ、スキル証跡と同一連携時点のスナップショットでリポジトリ集合が必ず一致する。旧形式キャッシュ(保存 JSON に `repos` キー自体が無い)のユーザーには 409 を返し、再連携を促す。旧形式判定は保存 JSON のキー有無で行う(ADR-0018 以降は分析対象が 0 件でも `"repos": []` が保存されるため、Pydantic 検証後の空リストでは旧形式と 0 件を区別できない)。分析対象リポジトリが 0 件のユーザーには、再連携では回復しないため別メッセージ(公開リポジトリの追加を促す)で 409 を返す。README・コミット履歴の追加取得はしない(トークンコストと連携時間の増加に見合わない。repo description で足りない分は空のまま人間が埋める)。 + +### 5. 同期エンドポイント(LLM 1 コール) + +`POST /api/agent/resume-draft/pdf`。職務要約 + 自己PR + 最大 5 プロジェクトの説明文を単一の構造化出力で返させる(見積: 出力 3,000 字前後)。`agent/chat` と同じ同期・60s timeout の運用実績に載せる。非同期タスク化(TaskType 追加・専用キャッシュ・ポーリング)はコード量が数倍になるため見送る(P1)。 + +## 代替案 + +- **純ルールベース**: LLM なしで骨格 PDF を出す。ADR-0010 を触らず最速だが、自然文が「Python と React を使用した開発経験があります」式の定型文になり、既存フォームの手入力と価値が変わらない。不採用。 +- **フル LLM(構造も LLM が生成)**: 柔軟だが、リポジトリ名・期間・技術の捏造検証が必要になり、検証コスト・実装量・トークンコストが最大。決定論で写せるものを LLM に任せる理由がない(P5)。不採用。 +- **ドラフト生成時に GitHub API を再取得**: 常に最新だが、ドラフトエンドポイントに GitHub API の失敗モード(token 失効・レート制限)と遅延が混入し、スキル証跡のリポジトリ集合との突合も必要になる。不採用。 +- **ドラフトを DB 保存**: 1 ユーザー 1 件制約下で既存経歴書との上書き/マージ仕様が必要になり、フェーズ 1 には重い。不採用(将来フェーズの選択肢としては残す)。 + +## トレードオフ・既知のリスク + +- **既存ユーザーは再連携が 1 回必要**: 旧形式キャッシュには repo サマリが無いため、409 + 再連携導線で吸収する。 +- **GitHub からは職歴・資格が得られない**: Experience は「個人開発」プレースホルダ 1 件になり、氏名も `username` プレースホルダになる。ドラフトの守備範囲は「プロジェクト・技術スタック・要約系」と明示する。 +- **同期エンドポイントのレイテンシ**: sonnet 等の大型モデルでは 30〜50 秒かかりうる。rate limit 5/minute で高コスト呼び出しを抑制する。 +- **PDF のみ返す UX**: 生成結果を編集するには手転記が要る。フェーズ 1 の割り切り(下記移行条件参照)。 + +## 将来の移行条件 + +- p95 レイテンシが 50 秒を超えたら、既存の非同期タスク基盤(`services/tasks/`)へ載せ替える(TaskType 追加 + キャッシュテーブル + ポーリング)。 +- ドラフトの編集ニーズが確認できたら、「payload JSON を返す + フォーム流し込み」へ分割する(PDF 化は既存の保存済み経歴書エンドポイントを使う)。 +- README 由来の説明文品質が必要になったら、連携パイプライン側で README 取得を追加する(本 ADR の供給源拡張と同じ手順で `AnalyzedRepoSummary` に載せる)。 + +## 設計原則との関係 + +- **P4(責務を層で分離する)**: 中心的な判断軸。構造 = 機械(決定論)、自然文 = LLM、最終確認 = 人間。ADR-0016 の 3 層モデルの延長。 +- **P5(デフォルトは決定論、LLM は対話型に限定する)**: 「対話型」ではない単発生成だが、ユーザー起動・結果はプレビューのみ・何も永続化しないという「人間が確認して適用する」性質は維持しており、推論パイプライン(ADR-0016)の決定論も崩さない。P5 の趣旨(LLM 出力を無確認で正とすることの禁止)の範囲内と判断する。 +- **P1(コスト最適化)**: 同期 1 コール・非同期基盤の増設なし・既定モデルは無料枠(haiku)。 + +## 関連リンク + +- 実装 PR: (本 ADR と同時) diff --git a/docs/adr/README.md b/docs/adr/README.md index cce4d0f3..a000f949 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ | [ADR-0015](./0015-vertex-ai-for-gemini-anthropic.md) | Gemini / Anthropic を Vertex AI(SA→ADC)経由にする | LLM / Agent | API キー注入を廃止し ADC 認証へ。データ所在地(アジア圏)と学習除外を担保 | | [ADR-0016](./0016-github-skill-inference.md) | GitHub 連携によるスキル推論基盤 | LLM / Agent | スキルを 3 層に分離(機械=幅 / 人間=深さ)。推論パイプラインは決定論を維持 | | [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 一覧 @@ -47,6 +48,7 @@ | [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 | ## テーマ別の決定系統 @@ -62,9 +64,11 @@ graph LR A0012 -.-> A0013["0013
マルチプロバイダ"] A0013 -.-> A0015["0015
Vertex AI (ADC)"] A0010 -.-> A0016["0016
スキル推論 3 層"] + A0010 -.-> A0018["0018
経歴書ドラフト生成"] + A0016 -.-> A0018 ``` -このプロダクトで最も判断の往復が大きい系統。「LLM 抽象の先行実装(0004)→ 利用見込み薄と判断して全撤去(0008)→ 対話型として価値が明確になった時点で、0008 自身が規定した手続きに従い再導入(0010)→ 課金・マルチプロバイダ・データガバナンスへ段階拡張(0012/0013/0015)」という流れで、**撤退条件を先に書いておく運用が実際に機能した実例**になっている。0016 は 0010 の「機械検証可能な制約はコード、不能な制約はプロンプト」という責務分離を「機械=幅 / 人間=深さ」の 3 層モデルへ一般化した。 +このプロダクトで最も判断の往復が大きい系統。「LLM 抽象の先行実装(0004)→ 利用見込み薄と判断して全撤去(0008)→ 対話型として価値が明確になった時点で、0008 自身が規定した手続きに従い再導入(0010)→ 課金・マルチプロバイダ・データガバナンスへ段階拡張(0012/0013/0015)」という流れで、**撤退条件を先に書いておく運用が実際に機能した実例**になっている。0016 は 0010 の「機械検証可能な制約はコード、不能な制約はプロンプト」という責務分離を「機械=幅 / 人間=深さ」の 3 層モデルへ一般化した。0018 は 0010 の不変条件と 0016 の決定論データを前提に、経歴書ドラフト生成へ「構造=機械 / 自然文=LLM」の分離を適用した。 ### 基盤(データ / インフラ / 認証) diff --git a/docs/design-principles.md b/docs/design-principles.md index dc721158..e1ef6575 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -167,3 +167,4 @@ CLAUDE.md の「過剰な抽象化を避ける」・`.claude/rules/common/duplic | [0015](./adr/0015-vertex-ai-for-gemini-anthropic.md) Vertex AI (ADC) | | ● | | | | | ○ | | [0016](./adr/0016-github-skill-inference.md) スキル推論 3 層 | | | | ● | ○ | | | | [0017](./adr/0017-mutation-testing-and-slack-notifications.md) ミューテーションテスト | | | ● | | | | ○ | +| [0018](./adr/0018-github-resume-draft-generation.md) 経歴書ドラフト生成 | ○ | | | ● | ● | ○ | | diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index fa467478..ee7d7bf7 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -1,6 +1,8 @@ +import { FALLBACK_MESSAGES } from "../constants/messages"; import { request } from "./client"; +import { getBlobUrl } from "./download"; import { PATHS } from "./paths"; -import type { AgentChatRequest, AgentChatResponse } from "./types"; +import type { AgentChatRequest, AgentChatResponse, AgentModelAlias } from "./types"; /** * Agent チャット(ADR-0010)。選択スコープの内容とプロンプトを送り、 @@ -12,3 +14,20 @@ export function postAgentChat(payload: AgentChatRequest): Promise { + return getBlobUrl( + PATHS.agent.resumeDraftPdf, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }, + FALLBACK_MESSAGES.RESUME_DRAFT, + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8cb5810b..71799572 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -18,6 +18,16 @@ function getCsrfToken(): string { return match ? match[1] : ""; } +/** + * 状態変更メソッド(POST/PUT/DELETE/PATCH)なら CSRF トークンをヘッダーに付与する。 + * request() と Blob 系 fetch(download.ts)で共有し、CSRF 付与ルールを 1 箇所に集約する。 + */ +export function applyCsrfHeader(headers: Record, method: string): void { + if (["POST", "PUT", "DELETE", "PATCH"].includes(method.toUpperCase())) { + headers["X-CSRF-Token"] = getCsrfToken(); + } +} + /** 進行中のリフレッシュ処理。複数の 401 は同じ Promise を待ち合わせる。 */ let _refreshPromise: Promise | null = null; @@ -108,6 +118,15 @@ function buildApiError(response: Response, body: ErrorResponseBody | null, fallb }); } +/** + * Response から ApiError を組み立てる(Blob 系など request() を通らない fetch 用)。 + * backend の AppErrorResponse(code / message / action)を保持して呼び出し元へ渡す。 + */ +export async function toApiError(response: Response, fallbackMessage: string): Promise { + const body = await getErrorBody(response); + return buildApiError(response, body, fallbackMessage); +} + export async function request( path: string, options: RequestInit = {}, @@ -120,9 +139,7 @@ export async function request( }; // 状態変更リクエストには CSRF トークンを付与する - if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) { - headers["X-CSRF-Token"] = getCsrfToken(); - } + applyCsrfHeader(headers, method); let response: Response; try { diff --git a/web/src/api/download.ts b/web/src/api/download.ts index 58cb4869..980958d6 100644 --- a/web/src/api/download.ts +++ b/web/src/api/download.ts @@ -1,5 +1,14 @@ import { downloadFailureMessage, FALLBACK_MESSAGES } from "../constants/messages"; -import { API_BASE_URL } from "./client"; +import { API_BASE_URL, applyCsrfHeader, toApiError } from "./client"; + +/** リクエストヘッダーを組み立てる(CSRF 付与は client.ts の共有ヘルパーに委譲)。 */ +function buildHeaders(options?: RequestInit): Record { + const headers: Record = { + ...((options?.headers as Record) ?? {}), + }; + applyCsrfHeader(headers, options?.method ?? "GET"); + return headers; +} export async function downloadBlob( url: string, @@ -8,6 +17,7 @@ export async function downloadBlob( ): Promise { const response = await fetch(`${API_BASE_URL}${url}`, { ...options, + headers: buildHeaders(options), credentials: "include", }); if (!response.ok) { @@ -22,12 +32,20 @@ export async function downloadBlob( URL.revokeObjectURL(blobUrl); } -export async function getBlobUrl(url: string): Promise { +export async function getBlobUrl( + url: string, + options?: RequestInit, + fallbackMessage: string = FALLBACK_MESSAGES.PREVIEW_FETCH, +): Promise { const response = await fetch(`${API_BASE_URL}${url}`, { + ...options, + headers: buildHeaders(options), credentials: "include", }); if (!response.ok) { - throw new Error(FALLBACK_MESSAGES.PREVIEW_FETCH); + // AppErrorResponse の code / message / action を保持して呼び出し元の分岐・表示に使う。 + // JSON エラーボディが無い場合の fallback は呼び出し元が用途別に指定できる + throw await toApiError(response, fallbackMessage); } const blob = await response.blob(); return URL.createObjectURL(blob); diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index d2394e3a..03e5889c 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -32,6 +32,31 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/resume-draft/pdf": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Resume Draft Pdf + * @description GitHub 連携データから経歴書ドラフトを生成し、PDF で返す(ADR-0018)。 + * + * 構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、 + * 自然文(職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。 + * ドラフトは DB に保存しない(生成物はレスポンスの PDF のみ。 + * クレジット消費・使用ログの記録は除く / ADR-0012)。 + */ + post: operations["generate_resume_draft_pdf_api_agent_resume_draft_pdf_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/billing/admin/grant": { parameters: { query?: never; @@ -1104,6 +1129,38 @@ export interface components { /** Output Tokens */ output_tokens: number; }; + /** + * AnalyzedRepoSummary + * @description 連携で分析したリポジトリ 1 件分のサマリ(ADR-0018)。 + * + * 経歴書ドラフト生成のルールベースマッピングが入力にする決定論データ。 + * スキル証跡(github_skill_evidence)と同一連携実行時点のスナップショットになる。 + */ + AnalyzedRepoSummary: { + /** + * Created At + * @description ISO 8601 形式の作成日時 + * @default + */ + created_at: string; + /** + * Description + * @description GitHub のリポジトリ説明(無ければ空文字) + * @default + */ + description: string; + /** + * Full Name + * @description owner/name 形式のリポジトリ名 + */ + full_name: string; + /** + * Pushed At + * @description ISO 8601 形式の最終 push 日時 + * @default + */ + pushed_at: string; + }; /** * BlogAccountCreate * @description ブログ連携アカウントの作成リクエスト。 @@ -1494,6 +1551,11 @@ export interface components { languages?: { [key: string]: number; }; + /** + * Repos + * @description 分析対象リポジトリのサマリ一覧(経歴書ドラフト生成の入力 / ADR-0018) + */ + repos?: components["schemas"]["AnalyzedRepoSummary"][]; /** Repos Analyzed */ repos_analyzed: number; /** Unique Skills */ @@ -1765,6 +1827,21 @@ export interface components { /** Self Pr */ self_pr: string; }; + /** + * ResumeDraftRequest + * @description 経歴書ドラフト生成(ADR-0018)のリクエスト。 + * + * 生成対象(リポジトリ集合)はサーバー側が連携キャッシュから決めるため、 + * クライアントが指定するのは使用モデルのみ。 + */ + ResumeDraftRequest: { + /** + * Model + * @default haiku + * @enum {string} + */ + model: "haiku" | "sonnet" | "gemini-flash" | "gemini-pro" | "gpt-mini" | "gpt"; + }; /** ResumeQualificationItem */ ResumeQualificationItem: { /** Acquired Date */ @@ -2081,6 +2158,39 @@ export interface operations { }; }; }; + generate_resume_draft_pdf_api_agent_resume_draft_pdf_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResumeDraftRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; 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 de699210..1a9c681b 100644 --- a/web/src/api/paths.ts +++ b/web/src/api/paths.ts @@ -29,6 +29,7 @@ export const PATHS = { }, agent: { chat: "/api/agent/chat", + resumeDraftPdf: "/api/agent/resume-draft/pdf", }, billing: { balance: "/api/billing/balance", diff --git a/web/src/components/github-link/GitHubLinkDashboard.tsx b/web/src/components/github-link/GitHubLinkDashboard.tsx index 89111452..f342fc4d 100644 --- a/web/src/components/github-link/GitHubLinkDashboard.tsx +++ b/web/src/components/github-link/GitHubLinkDashboard.tsx @@ -15,10 +15,14 @@ import { FALLBACK_MESSAGES, GITHUB_LINK_MESSAGES, LOADING_MESSAGES, + RESUME_DRAFT_MESSAGES, UI_MESSAGES, yearLabel, } from "../../constants/messages"; import { useAsyncTaskPage } from "../../hooks/useAsyncTaskPage"; +import { useResumeDraftPdf } from "../../hooks/useResumeDraftPdf"; +import { useAppSelector } from "../../store"; +import { PdfPreviewModal } from "../forms/PdfPreviewModal"; import { ContributionHeatmap } from "./ContributionHeatmap"; import { LanguageBar } from "./LanguageBar"; import shared from "../../styles/shared.module.css"; @@ -58,6 +62,11 @@ export function GitHubLinkDashboard() { // 連携実行・ポーリング失敗のエラー(AppErrorState)をトーストで通知する(回復アクション付き・手動クローズ)。 useAppErrorToast(error); + // 経歴書ドラフト PDF 生成(ADR-0018)。モデルはユーザーメニューで選択中のグローバル設定を使う + const agentModel = useAppSelector((state) => state.agentModel.model); + const draft = useResumeDraftPdf(agentModel); + useAppErrorToast(draft.error); + /** * GitHub 連携を実行する(非同期バックグラウンド)。 * サイドバーから渡された includeForks を使う。 @@ -160,6 +169,25 @@ export function GitHubLinkDashboard() { )} + + {/* 経歴書ドラフト PDF 生成(ADR-0018) */} +
+

{RESUME_DRAFT_MESSAGES.HEADING}

+

{RESUME_DRAFT_MESSAGES.HINT}

+ +

{RESUME_DRAFT_MESSAGES.NOT_SAVED_NOTE}

+
)} @@ -172,6 +200,9 @@ export function GitHubLinkDashboard() {

GitHub連携

{renderBody()}
+ {draft.previewUrl && ( + + )} ); } diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts index 18619ef8..c2055f55 100644 --- a/web/src/constants/messages.ts +++ b/web/src/constants/messages.ts @@ -67,6 +67,7 @@ export const FALLBACK_MESSAGES = { GITHUB_OAUTH_START: "GitHub OAuth の開始に失敗しました", GITHUB_LINK: "連携に失敗しました", AGENT_CHAT: "AI への送信に失敗しました", + RESUME_DRAFT: "経歴書ドラフトの生成に失敗しました", CREDIT_BALANCE: "クレジット残高の取得に失敗しました", USAGE_SUMMARY: "利用状況の取得に失敗しました", CHECKOUT: "決済ページへの遷移に失敗しました", @@ -193,6 +194,20 @@ export const GITHUB_LINK_MESSAGES = { LONGEST_STREAK_LABEL: "最大連続日数", } as const; +/** 経歴書ドラフト PDF 生成(ADR-0018)の UI 文言。 */ +export const RESUME_DRAFT_MESSAGES = { + /** セクション見出し。 */ + HEADING: "経歴書ドラフト", + /** 生成ボタンのラベル。 */ + GENERATE: "経歴書ドラフトPDFを生成", + /** 生成中のボタン/スピナーラベル。 */ + GENERATING: "経歴書ドラフトを生成中...", + /** 機能説明(モデルはユーザーメニューで選択中のものを使う旨)。 */ + HINT: "連携したリポジトリの情報から、AI が経歴書のたたき台(PDF)を作成します。使用モデルはユーザーメニューで変更できます。", + /** 生成物が保存されない旨の注意書き。 */ + NOT_SAVED_NOTE: "生成した PDF は保存されません。必要な内容は職務経歴書フォームへ転記してください。", +} as const; + /** 年セレクトの選択肢表記「N年」。 */ export function yearLabel(year: number): string { return `${year}年`; diff --git a/web/src/hooks/useResumeDraftPdf.test.ts b/web/src/hooks/useResumeDraftPdf.test.ts new file mode 100644 index 00000000..cab65168 --- /dev/null +++ b/web/src/hooks/useResumeDraftPdf.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { ApiError } from "../utils/appError"; +import { useResumeDraftPdf } from "./useResumeDraftPdf"; + +// API モジュールをモックし、フックの状態遷移だけを検証する +vi.mock("../api/agent", () => ({ + generateResumeDraftPdfBlobUrl: vi.fn(), +})); + +import { generateResumeDraftPdfBlobUrl } from "../api/agent"; + +const mockGenerate = vi.mocked(generateResumeDraftPdfBlobUrl); + +describe("useResumeDraftPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** 成功時: previewUrl がセットされ、選択モデルで API が呼ばれること */ + it("generate 成功で previewUrl がセットされる", async () => { + mockGenerate.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"); + expect(result.current.error).toBeNull(); + expect(result.current.generating).toBe(false); + }); + + /** 生成中: generating が true になり、完了で false に戻ること */ + it("generate 中は generating が true になる", async () => { + let resolveFetch: (url: string) => void = () => {}; + mockGenerate.mockImplementationOnce( + () => new Promise((resolve) => (resolveFetch = resolve)), + ); + + const { result } = renderHook(() => useResumeDraftPdf("haiku")); + let pending: Promise; + act(() => { + pending = result.current.generate(); + }); + expect(result.current.generating).toBe(true); + + await act(async () => { + resolveFetch("blob:http://localhost/x"); + await pending; + }); + expect(result.current.generating).toBe(false); + }); + + /** 失敗時: ApiError の message / action が AppErrorState に保持されること */ + it("generate 失敗で backend のエラー内容が error にセットされる", async () => { + mockGenerate.mockRejectedValueOnce( + new ApiError({ + code: "VALIDATION_ERROR", + message: "連携データがありません", + action: "GitHub 連携を実行してください", + }), + ); + + const { result } = renderHook(() => useResumeDraftPdf("haiku")); + await act(async () => { + await result.current.generate(); + }); + + expect(result.current.previewUrl).toBeNull(); + expect(result.current.error?.message).toBe("連携データがありません"); + expect(result.current.error?.action).toBe("GitHub 連携を実行してください"); + }); + + /** 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"); + + const { result } = renderHook(() => useResumeDraftPdf("haiku")); + await act(async () => { + await result.current.generate(); + }); + act(() => { + result.current.closePreview(); + }); + + expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/to-revoke"); + expect(result.current.previewUrl).toBeNull(); + revokeSpy.mockRestore(); + }); + + /** 再生成: 旧 previewUrl が revoke されてから新 URL に差し替わること(Blob リーク防止) */ + it("generate を再実行すると旧 Blob URL が revoke される", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + mockGenerate + .mockResolvedValueOnce("blob:http://localhost/first") + .mockResolvedValueOnce("blob:http://localhost/second"); + + const { result } = renderHook(() => useResumeDraftPdf("haiku")); + await act(async () => { + await result.current.generate(); + }); + await act(async () => { + await result.current.generate(); + }); + + expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/first"); + expect(result.current.previewUrl).toBe("blob:http://localhost/second"); + revokeSpy.mockRestore(); + }); + + /** アンマウント: プレビュー表示中に画面離脱しても Blob URL が解放されること */ + it("アンマウント時に残っている Blob URL が revoke される", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + mockGenerate.mockResolvedValueOnce("blob:http://localhost/on-unmount"); + + const { result, unmount } = renderHook(() => useResumeDraftPdf("haiku")); + await act(async () => { + await result.current.generate(); + }); + unmount(); + + expect(revokeSpy).toHaveBeenCalledWith("blob:http://localhost/on-unmount"); + revokeSpy.mockRestore(); + }); +}); diff --git a/web/src/hooks/useResumeDraftPdf.ts b/web/src/hooks/useResumeDraftPdf.ts new file mode 100644 index 00000000..31ef3ca3 --- /dev/null +++ b/web/src/hooks/useResumeDraftPdf.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from "react"; + +import { generateResumeDraftPdfBlobUrl } from "../api/agent"; +import { toAppError, type AppErrorState } from "../api"; +import type { AgentModelAlias } from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; + +/** + * 経歴書ドラフト PDF の生成とプレビュー状態を管理するフック(ADR-0018)。 + * + * 生成はサーバー側で LLM を 1 回呼ぶ同期処理(十数秒〜数十秒)のため、 + * generating 中はボタンを無効化して二重実行を防ぐ。生成物は DB に保存されず、 + * previewUrl(Blob URL)のみがこのフックのライフサイクルで管理される。 + * + * @param model 使用モデル(ユーザーメニューで選択中のグローバル設定を渡す) + */ +export function useResumeDraftPdf(model: AgentModelAlias) { + const [generating, setGenerating] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [error, setError] = useState(null); + // アンマウント時のクリーンアップと、再生成時の旧 URL 解放に使う(Blob リーク防止) + const previewUrlRef = useRef(null); + + /** プレビュー URL を差し替える。既存の Blob URL があれば解放してから新しい値をセットする。 */ + const updatePreviewUrl = (url: string | null) => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + } + previewUrlRef.current = url; + setPreviewUrl(url); + }; + + // アンマウント時に残っている Blob URL を解放する(プレビュー表示中の画面離脱でリークしない) + useEffect(() => { + return () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + } + }; + }, []); + + /** ドラフト PDF を生成してプレビュー URL をセットする。 */ + const generate = async () => { + if (generating) return; + setGenerating(true); + setError(null); + try { + updatePreviewUrl(await generateResumeDraftPdfBlobUrl(model)); + } catch (e) { + // 409(連携データ不足)等は backend の message / action をそのまま表示する + setError(toAppError(e, FALLBACK_MESSAGES.RESUME_DRAFT)); + } finally { + setGenerating(false); + } + }; + + /** プレビューを閉じ、Blob URL を解放する。 */ + const closePreview = () => { + updatePreviewUrl(null); + }; + + return { generating, previewUrl, error, generate, closePreview }; +} diff --git a/web/src/test/renderWithProviders.tsx b/web/src/test/renderWithProviders.tsx index 29be52ff..953de0eb 100644 --- a/web/src/test/renderWithProviders.tsx +++ b/web/src/test/renderWithProviders.tsx @@ -7,6 +7,7 @@ import { render, type RenderOptions } from "@testing-library/react"; import { Provider } from "react-redux"; import { MemoryRouter, type MemoryRouterProps } from "react-router-dom"; import { configureStore } from "@reduxjs/toolkit"; +import agentModelReducer from "../store/agentModelSlice"; import formCacheReducer from "../store/formCacheSlice"; import { ToastProvider } from "../components/ui/toast"; @@ -19,8 +20,10 @@ export function renderWithProviders( ui: ReactElement, { initialEntries = ["/"], ...options }: Options = {}, ) { + // 実 store(store/index.ts)と同じ slice 構成に揃える(agentModel は + // GitHubLinkDashboard 等が useAppSelector で参照するため欠けると落ちる) const store = configureStore({ - reducer: { formCache: formCacheReducer }, + reducer: { formCache: formCacheReducer, agentModel: agentModelReducer }, }); function Wrapper({ children }: { children: React.ReactNode }) {