diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 71eda983..be0ea527 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -52,9 +52,16 @@ backend/app/ │ ├── base.py / user.py / blog.py │ ├── master_data.py / notification.py / resume.py ├── services/ -│ ├── agent/ # DevForge Agent(LLM チャット / ADR-0010) +│ ├── agent/ # DevForge Agent(LLM チャット / ADR-0010・0012・0013) │ │ ├── chat_service.py # コンテキスト組み立て → LLM → operations 検証 -│ │ └── llm/ # LLM プロバイダ抽象(anthropic / ollama、失敗は raise) +│ │ ├── 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) │ ├── blog/ # ブログ収集・技術記事判定・スコア算出 │ │ ├── account_service.py │ │ ├── collector.py diff --git a/README.md b/README.md index 8656d6c3..2e472dc5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ GitHub活動分析、ブログ連携による発信力を集計 - **職務経歴書**: 職務要約、自己PR、職務経歴、技術スタックの入力とPDF/Markdown出力 - フォーム入力状態を Redux でページ遷移間に保持(入力途中で別ページに移動しても失われない) +### AIアシスタント(職務経歴書の改善提案) +- 職務要約・自己PR・職務経歴・プロジェクトのスコープを選び、AIに文章改善を依頼(DevForge Agent / ADR-0010) +- **マルチプロバイダ対応**: Claude(Anthropic)/ GPT(OpenAI)/ Gemini(Google)からモデルを選択(ADR-0013)。プロバイダはモデル選択に紐づいて切り替わる +- 提案はフォームの入力状態にのみ反映し、保存はユーザーが明示的に実行(Agent 自体は DB を更新しない) +- 有料モデルはプリペイド式クレジットで従量課金(Stripe Checkout / ADR-0012)。無料モデルも提供 +- ローカル開発では Ollama でオフライン実行可能(`LLM_LOCAL_OLLAMA`) + ### GitHub連携 - GitHub OAuthログインしたユーザーのリポジトリを取得し、使用技術を可視化 - 言語構成・フレームワーク・DevTools・インフラツールを依存関係から検出 @@ -43,6 +50,8 @@ GitHub活動分析、ブログ連携による発信力を集計 |---|---| | フロントエンド | React 18, TypeScript, Vite, Redux Toolkit, Recharts, marked | | バックエンドAPI | Python 3.13, FastAPI, SQLAlchemy, Pydantic | +| AI / LLM | Anthropic Claude / OpenAI GPT / Google Gemini(ユーザー選択式・ADR-0013)、ローカルは Ollama | +| 決済 | Stripe Checkout(クレジット購入・従量課金 / ADR-0012) | | データベース | Turso (libSQL / SQLite 互換、`sqlalchemy-libsql`) | | 認証 | JWT Cookie (python-jose), bcrypt, GitHub OAuth | | 暗号化 | Fernet(フィールド暗号化) | @@ -74,6 +83,13 @@ graph TB subgraph "外部サービス" ZennAPI["Zenn API"] NoteRSS["note RSS"] + Stripe["Stripe
Checkout / Webhook"] + end + + subgraph "LLM プロバイダ(ユーザー選択式)" + Anthropic["Anthropic
Claude"] + OpenAI["OpenAI
GPT"] + Google["Google
Gemini"] end subgraph "Cloudflare" @@ -98,7 +114,7 @@ graph TB end subgraph "Secret Manager" - Secrets["FIELD_ENCRYPTION_KEY
ADMIN_TOKEN
JWT_PRIVATE_KEY / JWT_PUBLIC_KEY
INTERNAL_SECRET
TURSO_AUTH_TOKEN
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET"] + Secrets["FIELD_ENCRYPTION_KEY
ADMIN_TOKEN
JWT_PRIVATE_KEY / JWT_PUBLIC_KEY
INTERNAL_SECRET
TURSO_AUTH_TOKEN
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
ANTHROPIC_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET"] end subgraph "IAM" @@ -119,6 +135,10 @@ graph TB CloudRun -->|"リポジトリ分析"| GitHubAPI CloudRun -->|"記事取得"| ZennAPI CloudRun -->|"記事取得"| NoteRSS + CloudRun -->|"AI 改善提案"| Anthropic + CloudRun -->|"AI 改善提案"| OpenAI + CloudRun -->|"AI 改善提案"| Google + CloudRun -->|"Checkout / Webhook"| Stripe AR -->|"イメージ pull"| CloudRun SA -->|"実行権限"| CloudRun SA -->|"secretAccessor"| Secrets diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index e4592536..0381e10e 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -94,7 +94,10 @@ # ローカル Ollama に通す無料パス。本番(Cloud Run)では未設定=無効。 # プロバイダ選択はモデルエイリアスに紐づくため、グローバルな LLM_PROVIDER は廃止(ADR-0013) LLM_LOCAL_OLLAMA = "LLM_LOCAL_OLLAMA" -# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止 +# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止。 +# 注: LLM API キー(ANTHROPIC/GOOGLE/OPENAI)はテストがプロバイダを _FakeLLM で +# モックするため CI(.github/workflows/ci.yml)には注入不要。env_keys の 5 箇所同期 +# のうち ci.yml だけは意図的に対象外とする(実 API を CI から呼ばないため)。 ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY" # Google Gemini / OpenAI GPT の API キー(ADR-0013)。Secret Manager 注入・ログ出力禁止 GOOGLE_API_KEY = "GOOGLE_API_KEY" diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index c6b09768..fe6f033f 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -72,6 +72,18 @@ class AgentChatResult: response: AgentChatResponse usage: AgentUsage + +def _make_usage( + request: AgentChatRequest, input_tokens: int, output_tokens: int +) -> AgentUsage: + """リクエストのモデルと合算トークンから AgentUsage を組み立てる。 + + 課金漏れ防止(ADR-0012)のため成功・失敗の各パスで同じ形の使用量を作る箇所を集約する。 + """ + return AgentUsage( + model=request.model, input_tokens=input_tokens, output_tokens=output_tokens + ) + # 許可外の field 名を返された時の正規化先。スコープ選択で編集対象は確定しているため、 # 小型 LLM が「自己PR」等の field 名を返しても既定 field の提案として救済する。 # project / experience は複数候補だが、自由記述の実体は description なので description に倒す @@ -284,13 +296,21 @@ async def run_agent_chat( messages.append({"role": "user", "content": user_prompt}) client = get_llm_client(spec.provider) output_schema = _SCOPE_SCHEMAS[request.scope] - result = await client.generate(system_prompt, messages, output_schema, model_id) + # リトライしても 1 回目の API 原価は発生しているため、使用量は全呼び出しの合算で課金する - input_tokens = result.input_tokens - output_tokens = result.output_tokens + input_tokens = 0 + output_tokens = 0 + + # 1 回目の呼び出し。パースまで成功すればここで確定して返す。 + result = await client.generate(system_prompt, messages, output_schema, model_id) + input_tokens += result.input_tokens + output_tokens += result.output_tokens logger.debug("Agent LLM 生応答(パース前): len=%d", len(result.text)) try: response = _parse_response(result.text, request.scope) + return AgentChatResult( + response=response, usage=_make_usage(request, input_tokens, output_tokens) + ) except AgentResponseParseError as exc: # スキーマ違反応答は 1 回だけリトライする。バリデーションエラーの内容を # フィードバックして再生成させ、2 回目も失敗したらそのまま raise @@ -308,36 +328,29 @@ async def run_agent_chat( ), }, ] - try: - result = await client.generate( - system_prompt, retry_messages, output_schema, model_id - ) - except LLMError as retry_exc: - # リトライ呼び出し自体が失敗。1 回目の API 原価は発生済みのため使用量を - # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) - retry_exc.usage = AgentUsage( - model=request.model, - input_tokens=input_tokens, - output_tokens=output_tokens, - ) - raise - input_tokens += result.input_tokens - output_tokens += result.output_tokens - logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(result.text)) - try: - response = _parse_response(result.text, request.scope) - except AgentResponseParseError as retry_exc: - # 2 回目も失敗。1・2 回目分の API 原価は発生済みのため、合算した使用量を - # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) - raise AgentResponseParseError( - str(retry_exc), - usage=AgentUsage( - model=request.model, - input_tokens=input_tokens, - output_tokens=output_tokens, - ), - ) from retry_exc - usage = AgentUsage( - model=request.model, input_tokens=input_tokens, output_tokens=output_tokens + + # リトライ呼び出し(1 回のみ)。以降は失敗時も合算使用量を載せて伝播する。 + try: + result = await client.generate( + system_prompt, retry_messages, output_schema, model_id + ) + except LLMError as retry_exc: + # リトライ呼び出し自体が失敗。1 回目の API 原価は発生済みのため使用量を + # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + retry_exc.usage = _make_usage(request, input_tokens, output_tokens) + raise + input_tokens += result.input_tokens + output_tokens += result.output_tokens + logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(result.text)) + try: + response = _parse_response(result.text, request.scope) + except AgentResponseParseError as retry_exc: + # 2 回目も失敗。1・2 回目分の API 原価は発生済みのため、合算した使用量を + # 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012) + raise AgentResponseParseError( + str(retry_exc), + usage=_make_usage(request, input_tokens, output_tokens), + ) from retry_exc + return AgentChatResult( + response=response, usage=_make_usage(request, input_tokens, output_tokens) ) - return AgentChatResult(response=response, usage=usage) diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py index 6a9f2c2b..d056d2fd 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -1,15 +1,12 @@ """Anthropic API クライアント(本番用。モデルは model_catalog.py で解決)。""" import json -import logging import anthropic from ....core import settings from ..output_schema import TOOL_NAME, build_tool_definition -from .base import LLMClient, LLMError, LLMResult - -logger = logging.getLogger(__name__) +from .base import LLMClient, LLMError, LLMResult, require_api_key, wrap_api_error # operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限 _MAX_TOKENS = 4096 @@ -23,9 +20,7 @@ class AnthropicClient(LLMClient): def __init__(self) -> None: """ANTHROPIC_API_KEY を検証し、非同期クライアントを初期化する。""" - api_key = settings.get_anthropic_api_key() - if not api_key: - raise LLMError("ANTHROPIC_API_KEY が設定されていません") + api_key = require_api_key(settings.get_anthropic_api_key(), "ANTHROPIC_API_KEY") self._client = anthropic.AsyncAnthropic( api_key=api_key, timeout=_TIMEOUT_SECONDS ) @@ -55,9 +50,7 @@ async def generate( anthropic.APIConnectionError, anthropic.APIStatusError, ) as exc: - # API キー等の秘密情報を含めないため例外型のみログに残す - logger.warning("Anthropic API 呼び出しに失敗: %s", type(exc).__name__) - raise LLMError(f"Anthropic API error: {type(exc).__name__}") from exc + raise wrap_api_error("Anthropic", exc) from exc block = next( (b for b in response.content if b.type == "tool_use"), None diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py index 2d096634..23669d8b 100644 --- a/backend/app/services/agent/llm/base.py +++ b/backend/app/services/agent/llm/base.py @@ -1,5 +1,6 @@ """LLM クライアントの抽象基底クラスと共通例外。""" +import logging from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING @@ -7,6 +8,8 @@ if TYPE_CHECKING: from ..chat_service import AgentUsage +logger = logging.getLogger(__name__) + class LLMError(Exception): """LLM 呼び出しの失敗(タイムアウト / 接続エラー / HTTP エラー)を表す。 @@ -65,3 +68,35 @@ async def generate( Raises: LLMError: タイムアウト・接続失敗・API エラー時。 """ + + +# 各プロバイダクライアントが共有する LLMError 契約(502 にマップされる)のヘルパ。 +# メッセージ・ログ形式を 1 箇所に集約し、クライアント間でのドリフトを防ぐ(ADR-0013)。 + + +def require_api_key(value: str, label: str) -> str: + """API キーの空判定を一元化する。空なら LLMError、非空ならそのまま返す。 + + label は環境変数名(例: ``ANTHROPIC_API_KEY``)。値そのものはログ・例外に含めない。 + """ + if not value: + raise LLMError(f"{label} が設定されていません") + return value + + +def wrap_api_error(provider: str, exc: Exception) -> "LLMError": + """プロバイダ SDK 例外を LLMError へ変換する(ログ + 整形)。 + + 呼び出し側の except 内で ``raise wrap_api_error("Google", exc) from exc`` の形で使う。 + 捕捉する SDK 例外型は各クライアントで異なるため try/except 自体は共通化しない。 + API キー等の秘密情報を載せないよう、ログ・メッセージには例外型名のみを含める。 + """ + logger.warning("%s API 呼び出しに失敗: %s", provider, type(exc).__name__) + return LLMError(f"{provider} API error: {type(exc).__name__}") + + +def require_text(provider: str, text: str | None) -> str: + """空応答ガードを一元化する。空(None / 空文字)なら LLMError、非空なら返す。""" + if not text: + raise LLMError(f"{provider} API が空の応答を返しました") + return text diff --git a/backend/app/services/agent/llm/google_client.py b/backend/app/services/agent/llm/google_client.py index ca7ab3d6..f9dc9392 100644 --- a/backend/app/services/agent/llm/google_client.py +++ b/backend/app/services/agent/llm/google_client.py @@ -6,17 +6,13 @@ スキーマに従う JSON のシリアライズ文字列として返す。 """ -import logging - from google import genai from google.genai import errors as genai_errors from google.genai import types as genai_types from ....core import settings from ..output_schema import to_portable_schema -from .base import LLMClient, LLMError, LLMResult - -logger = logging.getLogger(__name__) +from .base import LLMClient, LLMResult, require_api_key, require_text, wrap_api_error # 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する _TEMPERATURE = 0.2 @@ -28,9 +24,7 @@ class GoogleClient(LLMClient): def __init__(self) -> None: """GOOGLE_API_KEY を検証し、非同期クライアントを初期化する。""" - api_key = settings.get_google_api_key() - if not api_key: - raise LLMError("GOOGLE_API_KEY が設定されていません") + api_key = require_api_key(settings.get_google_api_key(), "GOOGLE_API_KEY") self._client = genai.Client( api_key=api_key, # HttpOptions.timeout はミリ秒単位(google-genai 仕様)。60s = 60000ms @@ -65,13 +59,9 @@ async def generate( model=model_id, contents=contents, config=config ) except genai_errors.APIError as exc: - # API キー等の秘密情報を含めないため例外型のみログに残す - logger.warning("Google API 呼び出しに失敗: %s", type(exc).__name__) - raise LLMError(f"Google API error: {type(exc).__name__}") from exc + raise wrap_api_error("Google", exc) from exc - text = response.text - if not text: - raise LLMError("Google API が空の応答を返しました") + text = require_text("Google", response.text) # usage はクレジット課金(ADR-0012)の根拠となるため実測値をそのまま返す usage = response.usage_metadata return LLMResult( diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index bd859029..4266a2cc 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -7,7 +7,7 @@ import httpx from ....core import settings -from .base import LLMClient, LLMError, LLMResult +from .base import LLMClient, LLMError, LLMResult, require_text, wrap_api_error logger = logging.getLogger(__name__) @@ -58,8 +58,7 @@ async def generate( ) response.raise_for_status() except httpx.HTTPError as exc: - logger.warning("Ollama API 呼び出しに失敗: %s", type(exc).__name__) - raise LLMError(f"Ollama API error: {type(exc).__name__}") from exc + raise wrap_api_error("Ollama", exc) from exc elapsed = time.monotonic() - started_at if elapsed >= _SLOW_REQUEST_THRESHOLD_SECONDS: @@ -80,7 +79,15 @@ async def generate( if not isinstance(data, dict): logger.warning("Ollama 応答が想定外の型: %s", type(data).__name__) raise LLMError("Ollama 応答が想定外の形式です") - text = data.get("message", {}).get("content", "") - if not text: - raise LLMError("Ollama から空の応答が返されました") + # message が dict でない(エラー応答で文字列/null 等)場合も .get で + # AttributeError になるため、トップレベルと同様に LLMError(502)へ倒す + message = data.get("message") + if not isinstance(message, dict): + logger.warning("Ollama 応答の message が想定外の型: %s", type(message).__name__) + raise LLMError("Ollama 応答が想定外の形式です") + content = message.get("content") + if not isinstance(content, str): + logger.warning("Ollama 応答の content が想定外の型: %s", type(content).__name__) + raise LLMError("Ollama 応答が想定外の形式です") + text = require_text("Ollama", content) return LLMResult(text=text) diff --git a/backend/app/services/agent/llm/openai_client.py b/backend/app/services/agent/llm/openai_client.py index d7fca6ed..09608caf 100644 --- a/backend/app/services/agent/llm/openai_client.py +++ b/backend/app/services/agent/llm/openai_client.py @@ -5,15 +5,11 @@ 応答テキストは Anthropic / Gemini と同じくスキーマに従う JSON のシリアライズ文字列で返す。 """ -import logging - import openai from ....core import settings from ..output_schema import TOOL_NAME, to_portable_schema -from .base import LLMClient, LLMError, LLMResult - -logger = logging.getLogger(__name__) +from .base import LLMClient, LLMResult, require_api_key, require_text, wrap_api_error # 職務経歴書の改善提案は事実忠実性が最優先のため低温度に固定する _TEMPERATURE = 0.2 @@ -25,9 +21,7 @@ class OpenAIClient(LLMClient): def __init__(self) -> None: """OPENAI_API_KEY を検証し、非同期クライアントを初期化する。""" - api_key = settings.get_openai_api_key() - if not api_key: - raise LLMError("OPENAI_API_KEY が設定されていません") + api_key = require_api_key(settings.get_openai_api_key(), "OPENAI_API_KEY") self._client = openai.AsyncOpenAI(api_key=api_key, timeout=_TIMEOUT_SECONDS) async def generate( @@ -54,13 +48,10 @@ async def generate( }, ) except openai.OpenAIError as exc: - # API キー等の秘密情報を含めないため例外型のみログに残す - logger.warning("OpenAI API 呼び出しに失敗: %s", type(exc).__name__) - raise LLMError(f"OpenAI API error: {type(exc).__name__}") from exc + raise wrap_api_error("OpenAI", exc) from exc - text = response.choices[0].message.content if response.choices else None - if not text: - raise LLMError("OpenAI API が空の応答を返しました") + raw_text = response.choices[0].message.content if response.choices else None + text = require_text("OpenAI", raw_text) # usage はクレジット課金(ADR-0012)の根拠となるため実測値をそのまま返す usage = response.usage return LLMResult( diff --git a/backend/requirements.txt b/backend/requirements.txt index 2570ae24..6641ceb9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,10 +13,12 @@ google-cloud-storage==2.19.0 pytest==9.0.3 python-jose[cryptography]==3.5.0 httpx==0.28.1 -anthropic>=0.40,<1 -# マルチプロバイダ LLM(ADR-0013)。Gemini = google-genai、GPT = openai -google-genai>=1.0,<3 -openai>=1.50,<2 +# マルチプロバイダ LLM(ADR-0013)。Gemini = google-genai、GPT = openai。 +# 供給網リスク(レンジ内 yank / 侵害バージョンの混入)を避けるため == で固定する。 +# CVE は CI の pip-audit が検出するので、検出時に明示バージョンを引き上げる運用とする。 +anthropic==0.109.2 +google-genai==2.8.0 +openai==1.109.1 # Stripe Checkout / Webhook 署名検証(ADR-0012 Phase 2)。公式ライブラリを使う stripe>=11,<13 PyGithub==2.5.0 diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py index c8477d57..ad90f9a3 100644 --- a/backend/tests/test_billing.py +++ b/backend/tests/test_billing.py @@ -289,6 +289,41 @@ def test_chat_sonnet_with_zero_balance_returns_402(client: TestClient, monkeypat assert fake.received_messages is None +def test_chat_requires_auth(client: TestClient, monkeypatch) -> None: + """トークン無しの /api/agent/chat は 401(get_current_user ガードの回帰防止)。 + + 認証が外れると未ログインで LLM を呼べてしまうため、ガード欠落を直接固定する。 + """ + fake = _FakeLLM(response=_llm_json("self_pr", "提案")) + monkeypatch.setattr(chat_service, "get_llm_client", lambda provider: fake) + + resp = client.post("/api/agent/chat", json=_chat_payload("haiku")) + + assert resp.status_code == 401 + # 認証で弾かれるため LLM は呼ばれない + assert fake.received_messages is None + + +@pytest.mark.parametrize("model", ["gemini-pro", "gpt"]) +def test_chat_paid_model_with_zero_balance_returns_402( + client: TestClient, monkeypatch, model: str +) -> None: + """有料モデル(gemini-pro / gpt)も残高 0 で 402。is_free 判定の SSoT 化を endpoint で固定。 + + 残高ゲートが sonnet だけでなく全有料モデルに効くこと(ハードコード回帰でバイパスされない)を守る。 + """ + fake = _FakeLLM(response=_llm_json("self_pr", "提案")) + monkeypatch.setattr(chat_service, "get_llm_client", lambda provider: fake) + headers = auth_header(client, f"billing-empty-{model}") + + resp = client.post("/api/agent/chat", json=_chat_payload(model), headers=headers) + + assert resp.status_code == 402 + assert resp.json()["code"] == "INSUFFICIENT_CREDITS" + # 残高不足で弾かれるため LLM は呼ばれない + assert fake.received_messages is None + + def test_chat_sonnet_allows_negative_balance(client: TestClient, monkeypatch) -> None: """事前チェック通過後の実コストが残高を超えた場合は負残高で確定する(ADR-0012)。""" fake = _FakeLLM( diff --git a/backend/tests/test_llm_clients.py b/backend/tests/test_llm_clients.py index 7c6c1deb..0daa40eb 100644 --- a/backend/tests/test_llm_clients.py +++ b/backend/tests/test_llm_clients.py @@ -1,19 +1,22 @@ -"""GoogleClient / OpenAIClient の単体テスト(ADR-0013)。 +"""AnthropicClient / GoogleClient / OpenAIClient / OllamaClient の単体テスト(ADR-0013)。 SDK は AsyncMock でモックし、実 API は呼ばない。検証項目: - API キー未設定で LLMError - スキーマが各プロバイダの構造化出力パラメータに渡る - 実トークン使用量が LLMResult に乗る - プロバイダ例外が LLMError にラップされる +- 空応答・想定外応答が LLMError に倒れる - output_schema.to_portable_schema が oneOf/const/maxLength を除去・enum 化する 本リポジトリは pytest-asyncio を使わないため、コルーチンは asyncio.run で駆動する。 """ import asyncio +import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import httpx import pytest from app.services.agent.llm.base import LLMError from app.services.agent.output_schema import build_output_schema, to_portable_schema @@ -38,6 +41,39 @@ def test_to_portable_schema_strips_constraints() -> None: assert "maxLength" not in suggestion_items +# ---- base ヘルパ(LLMError 契約の SSoT) ---- + + +def test_require_api_key_passes_through_and_raises() -> None: + """非空はそのまま返し、空は label 付きで LLMError。""" + from app.services.agent.llm.base import require_api_key + + assert require_api_key("k", "ANTHROPIC_API_KEY") == "k" + with pytest.raises(LLMError, match="OPENAI_API_KEY が設定されていません"): + require_api_key("", "OPENAI_API_KEY") + + +def test_wrap_api_error_formats_message_with_type_name() -> None: + """provider 名と例外型名を含む LLMError を返す(raise はしない / 値を含めない)。""" + from app.services.agent.llm.base import wrap_api_error + + err = wrap_api_error("Google", ValueError("secret-token")) + assert isinstance(err, LLMError) + assert str(err) == "Google API error: ValueError" + assert "secret-token" not in str(err) + + +def test_require_text_passes_through_and_raises() -> None: + """非空はそのまま返し、空 / None は LLMError(空応答ガード)。""" + from app.services.agent.llm.base import require_text + + assert require_text("OpenAI", "hi") == "hi" + with pytest.raises(LLMError, match="空の応答"): + require_text("OpenAI", None) + with pytest.raises(LLMError, match="空の応答"): + require_text("Google", "") + + # ---- GoogleClient ---- @@ -164,3 +200,224 @@ def test_openai_client_wraps_api_error(monkeypatch) -> None: client.generate("s", [{"role": "user", "content": "x"}], build_output_schema("self_pr"), "gpt-4o-mini") ) + + +def test_openai_client_empty_response_raises(monkeypatch) -> None: + """choices が空(content なし)の応答は LLMError(空応答ガード)。""" + from app.services.agent.llm.openai_client import OpenAIClient + + response = SimpleNamespace(choices=[], usage=None) + _patch_openai(monkeypatch, response=response) + client = OpenAIClient() + with pytest.raises(LLMError, match="空の応答"): + asyncio.run( + client.generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "gpt-4o-mini") + ) + + +# ---- AnthropicClient ---- + + +def _patch_anthropic(monkeypatch, *, response=None, error=None) -> MagicMock: + """anthropic.AsyncAnthropic をモックし、messages.create を差し替える。""" + from app.services.agent.llm import anthropic_client + + monkeypatch.setattr( + anthropic_client.settings, "get_anthropic_api_key", lambda: "test-key" + ) + create = AsyncMock(side_effect=error) if error else AsyncMock(return_value=response) + fake_client = MagicMock() + fake_client.messages.create = create + monkeypatch.setattr( + anthropic_client.anthropic, "AsyncAnthropic", lambda **kwargs: fake_client + ) + return create + + +def test_anthropic_client_returns_text_and_usage(monkeypatch) -> None: + """tool_use ブロックの input を JSON 文字列化し、実トークン使用量を返す。""" + from app.services.agent.llm.anthropic_client import AnthropicClient + + block = SimpleNamespace( + type="tool_use", input={"message": "ok", "operations": [], "suggestions": []} + ) + response = SimpleNamespace( + content=[block], + usage=SimpleNamespace(input_tokens=110, output_tokens=22), + ) + _patch_anthropic(monkeypatch, response=response) + client = AnthropicClient() + result = asyncio.run( + client.generate("sys", [{"role": "user", "content": "hi"}], + build_output_schema("project"), "claude-haiku-4-5") + ) + assert result.input_tokens == 110 + assert result.output_tokens == 22 + # text は tool input の再シリアライズ JSON(履歴契約を維持) + assert json.loads(result.text) == block.input + + +def test_anthropic_client_missing_key_raises(monkeypatch) -> None: + """ANTHROPIC_API_KEY 未設定は LLMError。""" + from app.services.agent.llm import anthropic_client + from app.services.agent.llm.anthropic_client import AnthropicClient + + monkeypatch.setattr( + anthropic_client.settings, "get_anthropic_api_key", lambda: "" + ) + with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"): + AnthropicClient() + + +def test_anthropic_client_wraps_api_error(monkeypatch) -> None: + """プロバイダ例外(タイムアウト等)は LLMError にラップされる。""" + import anthropic + from app.services.agent.llm.anthropic_client import AnthropicClient + + err = anthropic.APITimeoutError(request=httpx.Request("POST", "http://test")) + _patch_anthropic(monkeypatch, error=err) + client = AnthropicClient() + with pytest.raises(LLMError, match="Anthropic API error"): + asyncio.run( + client.generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "claude-haiku-4-5") + ) + + +def test_anthropic_client_no_tool_use_block_raises(monkeypatch) -> None: + """tool_use ブロックが無い応答は LLMError(回帰しやすい分岐)。""" + from app.services.agent.llm.anthropic_client import AnthropicClient + + response = SimpleNamespace( + content=[SimpleNamespace(type="text")], + usage=SimpleNamespace(input_tokens=10, output_tokens=0), + ) + _patch_anthropic(monkeypatch, response=response) + client = AnthropicClient() + with pytest.raises(LLMError, match="tool_use"): + asyncio.run( + client.generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "claude-haiku-4-5") + ) + + +# ---- OllamaClient ---- + + +class _FakeResponse: + """httpx.Response の最小スタブ(raise_for_status / json のみ)。""" + + def __init__(self, json_data=None, *, status_error=None) -> None: + self._json = json_data + self._status_error = status_error + + def raise_for_status(self) -> None: + if self._status_error is not None: + raise self._status_error + + def json(self): + if isinstance(self._json, Exception): + raise self._json + return self._json + + +class _FakeAsyncClient: + """httpx.AsyncClient の async context manager スタブ。""" + + def __init__(self, *, response=None, post_error=None) -> None: + self._response = response + self._post_error = post_error + + async def __aenter__(self): + return self + + async def __aexit__(self, *args) -> bool: + return False + + async def post(self, url, json=None): + if self._post_error is not None: + raise self._post_error + return self._response + + +def _patch_ollama(monkeypatch, *, response=None, post_error=None) -> None: + """settings と httpx.AsyncClient をモックする。""" + from app.services.agent.llm import ollama_client + + monkeypatch.setattr(ollama_client.settings, "get_ollama_base_url", lambda: "http://x") + monkeypatch.setattr(ollama_client.settings, "get_ollama_model", lambda: "llama3.2") + monkeypatch.setattr( + ollama_client.settings, "get_ollama_timeout_seconds", lambda: 1.0 + ) + monkeypatch.setattr( + ollama_client.httpx, + "AsyncClient", + lambda **kwargs: _FakeAsyncClient(response=response, post_error=post_error), + ) + + +def test_ollama_client_returns_text_with_zero_usage(monkeypatch) -> None: + """message.content を返し、トークン使用量は 0(ローカルは無料 / ADR-0012)。""" + from app.services.agent.llm.ollama_client import OllamaClient + + fake = _FakeResponse({"message": {"content": '{"message":"ok"}'}}) + _patch_ollama(monkeypatch, response=fake) + result = asyncio.run( + OllamaClient().generate("sys", [{"role": "user", "content": "hi"}], + build_output_schema("self_pr"), "ignored") + ) + assert result.text == '{"message":"ok"}' + assert result.input_tokens == 0 + assert result.output_tokens == 0 + + +def test_ollama_client_http_error_wrapped(monkeypatch) -> None: + """httpx.HTTPError は LLMError にラップされる。""" + from app.services.agent.llm.ollama_client import OllamaClient + + _patch_ollama(monkeypatch, post_error=httpx.HTTPError("boom")) + with pytest.raises(LLMError, match="Ollama API error"): + asyncio.run( + OllamaClient().generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "ignored") + ) + + +def test_ollama_client_non_dict_response_raises(monkeypatch) -> None: + """dict 以外の応答(配列等)は LLMError(502 へ倒す)。""" + from app.services.agent.llm.ollama_client import OllamaClient + + fake = _FakeResponse(["unexpected"]) + _patch_ollama(monkeypatch, response=fake) + with pytest.raises(LLMError, match="想定外の形式"): + asyncio.run( + OllamaClient().generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "ignored") + ) + + +def test_ollama_client_non_dict_message_raises(monkeypatch) -> None: + """message が dict でない応答(エラー時の文字列等)は LLMError(502 へ倒す)。""" + from app.services.agent.llm.ollama_client import OllamaClient + + fake = _FakeResponse({"message": "boom"}) + _patch_ollama(monkeypatch, response=fake) + with pytest.raises(LLMError, match="想定外の形式"): + asyncio.run( + OllamaClient().generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "ignored") + ) + + +def test_ollama_client_empty_content_raises(monkeypatch) -> None: + """message.content が空の応答は LLMError(空応答ガード)。""" + from app.services.agent.llm.ollama_client import OllamaClient + + fake = _FakeResponse({"message": {"content": ""}}) + _patch_ollama(monkeypatch, response=fake) + with pytest.raises(LLMError, match="空の応答"): + asyncio.run( + OllamaClient().generate("s", [{"role": "user", "content": "x"}], + build_output_schema("self_pr"), "ignored") + ) diff --git a/docs/api.md b/docs/api.md index 159ca340..e2d540d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -70,7 +70,7 @@ REST API エンドポイント一覧と、バックエンド/フロントエ - `POST /api/notifications/read-all`: 全て既読 ### Agent(LLM チャット / ADR-0010) -- `POST /api/agent/chat`: 選択スコープ(`project` / `experience` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min。`model`(`haiku`: 無料 / `sonnet`: 有料・クレジット消費)を指定可能。sonnet で残高不足の場合は 402 `INSUFFICIENT_CREDITS`(ADR-0012) +- `POST /api/agent/chat`: 選択スコープ(`project` / `experience` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min。`model` はマルチプロバイダ(ADR-0013)から選択可能 — 無料: `haiku` / `gemini-flash` / `gpt-mini`、有料(クレジット消費): `sonnet` / `gemini-pro` / `gpt`。プロバイダはモデルエイリアスに紐づいて切り替わる。有料モデルで残高不足の場合は 402 `INSUFFICIENT_CREDITS`(ADR-0012) ### 課金(プリペイドクレジット / ADR-0012) - `GET /api/billing/balance`: ログインユーザーのクレジット残高 diff --git a/docs/deployment.md b/docs/deployment.md index b656bc7e..47617a10 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -142,17 +142,20 @@ Cloud Run サービスを OpenTofu で初回作成する場合は、`modules/clo GitHub Actions はその後 `gcloud run deploy` で新しいリビジョンを配備します。OpenTofu では Cloud Run の `image` 差分を無視するため、後続の `apply` で CI 配備済みイメージへ巻き戻しません。 -Secret Manager は secret 本体だけでなく secret version が必要です。Cloud Run 起動前に最低でも以下 4 つの version を追加してください。 +Secret Manager は secret 本体だけでなく secret version が必要です。Cloud Run は以下の secret を**常に注入する**(`infra/modules/cloud_run/main.tf` の `local.required_secret_env`)ため、起動前にすべての version を追加してください。version が無いと Cloud Run が起動に失敗します。 -- `devforge--secret-key` - `devforge--field-encryption-key` - `devforge--admin-token` +- `devforge--jwt-private-key` / `devforge--jwt-public-key`(`make generate-keys` で生成) +- `devforge--internal-secret` - `devforge--turso-auth-token` +- `devforge--anthropic-api-key`(DevForge Agent の Claude haiku / sonnet 用 / ADR-0010・0013) +- `devforge--stripe-secret-key` / `devforge--stripe-webhook-secret`(クレジット課金 / ADR-0012 Phase 2) -GitHub OAuth を使う場合だけ `enable_github_oauth = true` を設定し、さらに以下 2 つの version も追加してください。 +以下は条件付きで、有効化した環境だけ version を追加すれば足ります(無効なら未投入でもデプロイ・起動可能)。 -- `devforge--github-client-id` -- `devforge--github-client-secret` +- GitHub OAuth(`enable_github_oauth = true`): `devforge--github-client-id` / `devforge--github-client-secret` +- Gemini / OpenAI(`enable_extra_llm_providers = true` / ADR-0013): `devforge--google-api-key` / `devforge--openai-api-key` ### 運用ルール diff --git a/docs/development.md b/docs/development.md index 1745231a..33656961 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ ollama serve # アプリ起動済みなら不要 ollama pull llama3.2 # OLLAMA_MODEL の既定値 ``` -Anthropic API で試す場合は `.env` に `LLM_PROVIDER=anthropic` と `ANTHROPIC_API_KEY` を設定して再起動する。 +ローカルの compose では `LLM_LOCAL_OLLAMA` が既定 `1`(無料パス)で、選択モデルに関わらず全リクエストがホストの Ollama に流れる。実プロバイダの API(Anthropic / OpenAI / Gemini)で試す場合は、`.env` に `LLM_LOCAL_OLLAMA=0` と対応する API キー(例: `ANTHROPIC_API_KEY`)を設定して再起動し、UI のモデル選択で該当プロバイダのモデル(Claude / GPT / Gemini)を選ぶ。プロバイダはモデルエイリアスに紐づいて切り替わるため、グローバルな `LLM_PROVIDER` は無い(ADR-0013)。 #### フロントエンド単体起動(バックエンドは docker / 別途) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7246a5a..4d4a4971 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7830,9 +7830,9 @@ } }, "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 5c32035b..289a3798 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,6 +62,7 @@ }, "overrides": { "shell-quote": "^1.8.4", - "ws": "^8.21.0" + "ws": "^8.21.0", + "undici": "^7.28.0" } } diff --git a/frontend/src/components/agent/ModelSelectModal.test.tsx b/frontend/src/components/agent/ModelSelectModal.test.tsx index 039f7125..af6b03ab 100644 --- a/frontend/src/components/agent/ModelSelectModal.test.tsx +++ b/frontend/src/components/agent/ModelSelectModal.test.tsx @@ -50,9 +50,10 @@ function renderModal(opts: { balance?: number | null; onClose?: () => void; usage?: { model: string; chat_count: number; input_tokens: number; output_tokens: number; credit_cost: number }[]; + rates?: { model: string; is_free: boolean; baseline_credits_per_chat: number }[]; }) { getAgentUsageSummaryMock.mockResolvedValue(opts.usage ?? []); - getModelRatesMock.mockResolvedValue(DEFAULT_RATES); + getModelRatesMock.mockResolvedValue(opts.rates ?? DEFAULT_RATES); const store = opts.store ?? makeStore(); const balanceValue: CreditBalanceContextValue = { balance: opts.balance ?? null, @@ -156,6 +157,20 @@ describe("ModelSelectModal", () => { expect(within(sonnetCard).getByText(AGENT_MODEL_MESSAGES.USAGE_NONE)).toBeTruthy(); }); + it("model_rates の is_free が静的 isPaid より優先される(rates で有料化された Haiku は有料バッジ)", async () => { + // runtime 正本は model_rates API。静的には無料の haiku を rates 側で有料に倒すと、 + // カードは有料バッジ + 残高不足警告(残高 0)を出す(PR-C: 表示を rates 由来へ)。 + renderModal({ + balance: 0, + rates: [{ model: "haiku", is_free: false, baseline_credits_per_chat: 8 }], + }); + const haikuCard = screen.getByRole("button", { name: haikuNamePattern }); + await waitFor(() => { + expect(within(haikuCard).getByText(AGENT_MODEL_MESSAGES.PAID_BADGE)).toBeTruthy(); + }); + expect(within(haikuCard).getByText(AGENT_MODEL_MESSAGES.INSUFFICIENT_HINT)).toBeTruthy(); + }); + it("無料モデル(Haiku)には回数目安を出さない", async () => { renderModal({ balance: 1000 }); const haikuCard = screen.getByRole("button", { name: haikuNamePattern }); diff --git a/frontend/src/components/agent/ModelSelectModal.tsx b/frontend/src/components/agent/ModelSelectModal.tsx index e1fa83de..3f812b8a 100644 --- a/frontend/src/components/agent/ModelSelectModal.tsx +++ b/frontend/src/components/agent/ModelSelectModal.tsx @@ -28,7 +28,14 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) { const { balance } = useCreditBalanceContext(); // モーダルが開いている間だけ利用実績と標準レートを取得する const { getUsage } = useAgentUsageSummary(true); - const { getBaselineRate } = useModelRates(true); + const { getBaselineRate, ratesByModel } = useModelRates(true); + + // 有料/無料判定の runtime 正本は model_rates API(`is_free`)。取得済みならそれを使い、 + // 未取得(初期/ローディング/エラー)の間は agentModels の静的 isPaid をフォールバックにする。 + const isPaidModel = (option: { alias: AgentModelAlias; isPaid: boolean }): boolean => { + const rate = ratesByModel[option.alias]; + return rate ? !rate.is_free : option.isPaid; + }; const select = (alias: AgentModelAlias) => { dispatch(setAgentModel(alias)); @@ -76,12 +83,13 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) {
{group.options.map((option) => { const isCurrent = option.alias === currentModel; + const paid = isPaidModel(option); // 残高未取得(null: 初期/ローディング/エラー)の間は不足扱いにしない - const insufficient = option.isPaid && balance !== null && balance <= 0; + const insufficient = paid && balance !== null && balance <= 0; const usage = getUsage(option.alias); const chatCount = usage?.chat_count ?? 0; const creditCost = usage?.credit_cost ?? 0; - const perReference = option.isPaid + const perReference = paid ? estimatePerReference(option.alias, creditCost, chatCount) : null; return ( @@ -94,8 +102,8 @@ export function ModelSelectModal({ onClose }: { onClose: () => void }) { >
{option.name} - - {option.isPaid + + {paid ? AGENT_MODEL_MESSAGES.PAID_BADGE : AGENT_MODEL_MESSAGES.FREE_BADGE} diff --git a/frontend/src/components/forms/AgentChatWidget.test.tsx b/frontend/src/components/forms/AgentChatWidget.test.tsx new file mode 100644 index 00000000..4dfab3cb --- /dev/null +++ b/frontend/src/components/forms/AgentChatWidget.test.tsx @@ -0,0 +1,105 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AgentModelAlias } from "../../api/types"; +import { AGENT_MESSAGES } from "../../constants/messages"; +import { createInitialCareerForm } from "../../formMappers"; +import agentModelReducer from "../../store/agentModelSlice"; +import formCacheReducer from "../../store/formCacheSlice"; +import { + CreditBalanceContext, + type CreditBalanceContextValue, +} from "../billing/creditBalanceContext"; +import { ToastProvider } from "../ui/toast"; +import { AgentChatWidget } from "./AgentChatWidget"; + +// useAgentChat はネットワークを伴うため send をモックし、ウィジェット側の +// 「有料モデルなら送信後に残高を再取得する」配線だけを検証対象にする。 +const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() })); +vi.mock("../../hooks/career/useAgentChat", () => ({ + useAgentChat: () => ({ + entries: [], + sending: false, + error: null, + send: sendMock, + markApplied: vi.fn(), + clearError: vi.fn(), + }), +})); + +function makeStore(model: AgentModelAlias) { + return configureStore({ + reducer: { agentModel: agentModelReducer, formCache: formCacheReducer }, + preloadedState: { agentModel: { model } }, + }); +} + +function renderWidget(model: AgentModelAlias) { + const refresh = vi.fn().mockResolvedValue(undefined); + const balanceValue: CreditBalanceContextValue = { + balance: 1000, + loading: false, + error: null, + refresh, + }; + render( + + + + + + + , + ); + return { refresh }; +} + +/** パネルを開いて依頼文を入力し、送信ボタンを押すまでの操作。 */ +function openAndSend(text: string) { + fireEvent.click(screen.getByRole("button", { name: AGENT_MESSAGES.OPEN_LABEL })); + fireEvent.change(screen.getByPlaceholderText(AGENT_MESSAGES.PROMPT_PLACEHOLDER), { + target: { value: text }, + }); + fireEvent.click(screen.getByRole("button", { name: AGENT_MESSAGES.SEND })); +} + +beforeEach(() => { + sendMock.mockReset(); + sendMock.mockResolvedValue(undefined); +}); + +describe("AgentChatWidget の残高再取得", () => { + it("有料モデル(gemini-pro)で送信すると送信後に残高を再取得する", async () => { + const { refresh } = renderWidget("gemini-pro"); + + openAndSend("自己PRを改善して"); + + await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + }); + + it("有料モデル(gpt)でも送信後に残高を再取得する", async () => { + const { refresh } = renderWidget("gpt"); + + openAndSend("職務要約を整えて"); + + await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + }); + + it("無料モデル(haiku)では残高を再取得しない", async () => { + const { refresh } = renderWidget("haiku"); + + openAndSend("自己PRを改善して"); + + await waitFor(() => expect(sendMock).toHaveBeenCalledTimes(1)); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/forms/AgentChatWidget.tsx b/frontend/src/components/forms/AgentChatWidget.tsx index 0282cbef..2822c906 100644 --- a/frontend/src/components/forms/AgentChatWidget.tsx +++ b/frontend/src/components/forms/AgentChatWidget.tsx @@ -17,6 +17,7 @@ import { useCreditBalanceContext } from "../billing/creditBalanceContext"; import type { CareerFormState } from "../../payloadBuilders"; import { useMessageToast, useToast } from "../ui/toast"; import { applyAgentOperations, type AgentScope } from "../../utils/agentOperations"; +import { getModelOption } from "../../constants/agentModels"; import styles from "./AgentChatWidget.module.css"; type Props = { @@ -108,7 +109,9 @@ export function AgentChatWidget({ form, onApply, isAuthenticated, requestLogin } const { entries, sending, error, send, markApplied, clearError } = useAgentChat(); // モデル・残高はともにサイドバーで表示・切り替えする(ADR-0012)。ウィジェットは // 有料モデル消費後にサイドバーの残高を最新化するため refresh のみ利用する - const isPaidModel = model === "sonnet"; + // 有料判定の正本は agentModels.ts の isPaid(sonnet / gpt / gemini-pro)。 + // ここでハードコードすると ADR-0013 のマルチプロバイダ追加でドリフトするため getModelOption に委ねる。 + const isPaidModel = getModelOption(model).isPaid; const { refresh: refreshBalance } = useCreditBalanceContext(); const { showSuccess } = useToast(); useMessageToast(error, "error"); diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index ac157326..3ab1fbc0 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -6,9 +6,6 @@ import { useCareerFormModals } from "../../hooks/career/useCareerFormModals"; import { createCareerResume, deleteCareerResume, - downloadCareerResumeMarkdown, - downloadCareerResumePdf, - getCareerResumePdfBlobUrl, getLatestCareerResume, updateCareerResume, } from "../../api"; @@ -26,7 +23,7 @@ import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders" import { buildCareerChanges } from "../../utils/careerDiff"; import type { CareerTextFieldKey } from "../../formTypes"; import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData"; -import { usePdfActions } from "../../hooks/usePdfActions"; +import { useCareerExportActions } from "../../hooks/career/useCareerExportActions"; import { useMessageToast } from "../ui/toast"; import { AgentChatWidget } from "./AgentChatWidget"; import shared from "../../styles/shared.module.css"; @@ -35,14 +32,10 @@ import { useLoginPrompt } from "../auth/loginPromptContext"; import { CareerDiffModal } from "./CareerDiffModal"; import { MarkdownFieldModal } from "./MarkdownFieldModal"; import { Skeleton } from "../ui/Skeleton"; -import { SaveIcon } from "../icons/SaveIcon"; -import { EyeIcon } from "../icons/EyeIcon"; -import { TrashIcon } from "../icons/TrashIcon"; -import { PdfDownloadIcon } from "../icons/PdfDownloadIcon"; -import { MarkdownDownloadIcon } from "../icons/MarkdownDownloadIcon"; import { PdfPreviewModal } from "./PdfPreviewModal"; import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; import layout from "./CareerResumeForm.module.css"; +import { CareerFormToolbar } from "./sections/CareerFormToolbar"; import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection"; import { CareerExperienceSection } from "./sections/CareerExperienceSection"; import { CareerQualificationsSection } from "./sections/CareerQualificationsSection"; @@ -163,19 +156,24 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean /** 左右 diff モーダル用の整形 HTML プレビュー(保存済み / 編集中)。開いている間だけ取得する。 */ const preview = useResumeDiffPreview(form, baseline, showSaveConfirm); + /** Skeleton 表示・入力ロックの統合フラグ */ + const formLocked = loading; + const { downloading, previewUrl, closePreview, - onDownloadPdf, - onDownloadMarkdown, - onPreviewPdf, + handlePreview, + handleDownloadPdf, + handleDownloadMarkdown, + exportDisabled, error: pdfError, success: pdfSuccess, - } = usePdfActions({ - downloadPdf: downloadCareerResumePdf, - downloadMarkdown: downloadCareerResumeMarkdown, - getPdfBlobUrl: getCareerResumePdfBlobUrl, + } = useCareerExportActions({ + isAuthenticated, + resumeId: resumeId ?? null, + formLocked, + requestLogin, }); useMessageToast(formSuccess, "success"); @@ -185,9 +183,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean // ログイン後のドラフト復元(既存経歴書あり)を通知する情報トースト。 useMessageToast(restoreMessage, "success"); - /** Skeleton 表示・入力ロックの統合フラグ */ - const formLocked = loading; - /** フォームデータ・技術スタック・資格の3つが揃った時に送信可能 */ const canSubmit = !loading && !techLoading && !qualLoading; @@ -271,27 +266,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean const focusLocator = focusTarget?.locator ?? null; const focusNonce = focusTarget?.nonce ?? 0; - /** - * 要ログイン機能(プレビュー / PDF / Markdown 出力)のハンドラ。 - * 未ログインならログイン促進モーダルを開き、ログイン済みなら本来の処理を行う。 - * 入力中ドラフトは effect で sessionStorage に退避済みのため、ログイン往復後も失われない。 - */ - const handlePreview = () => { - if (!isAuthenticated) return requestLogin(); - if (resumeId) onPreviewPdf(resumeId); - }; - const handleDownloadPdf = () => { - if (!isAuthenticated) return requestLogin(); - if (resumeId) onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED); - }; - const handleDownloadMarkdown = () => { - if (!isAuthenticated) return requestLogin(); - if (resumeId) onDownloadMarkdown(resumeId); - }; - // 未ログインでは要ログイン機能ボタンを活性にしてログイン導線にする。 - // ログイン済みでは保存済み(resumeId あり)まで非活性。 - const exportDisabled = formLocked || (isAuthenticated && !resumeId); - return ( <> {showDeleteConfirm && ( @@ -343,69 +317,18 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean 標準の required バブルが先に発火すると、該当フィールドへの独自フォーカス・赤枠・ 日本語メッセージが出せず挙動が不統一になるため抑止する。 */}
-
-

職務経歴書

-
- {/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */} - - - - - -
-
+ setShowDeleteConfirm(true)} + />
diff --git a/frontend/src/components/forms/MarkdownDocumentView.tsx b/frontend/src/components/forms/MarkdownDocumentView.tsx index a8d9b5a3..8a445278 100644 --- a/frontend/src/components/forms/MarkdownDocumentView.tsx +++ b/frontend/src/components/forms/MarkdownDocumentView.tsx @@ -1,20 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages"; import { renderMarkdown } from "../../utils/markdown"; +import { useSelectionFill, type DocumentViewProps } from "./documentView"; import styles from "./ResumeSourceTracePanel.module.css"; -type Props = { - /** 描画対象の Markdown ファイル */ - file: File; - /** ズーム倍率(1 = 等倍)。フォントサイズの em 倍率として適用する。 */ - zoom?: number; - /** 原本上で選択された文字列を流し込むコールバック */ - onFill: (text: string) => void; - /** 描画エラー・エラー解除を親へ伝えるコールバック */ - onError: (message: string | null) => void; -}; - /** * Markdown を整形描画し、テキスト選択を「流し込み」に変換する内側ビュー。 * @@ -24,7 +14,7 @@ type Props = { * フォーカスした入力欄)は useResumeImportAssist 側が担うため、ここは * 「選択文字列を onFill に渡す」ことだけに専念する。 */ -export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }: Props) { +export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError }: DocumentViewProps) { const [html, setHtml] = useState(null); // ファイル本文をテキストとして読み込み、sanitize 済み HTML へ変換する。 @@ -50,13 +40,7 @@ export default function MarkdownDocumentView({ file, zoom = 1, onFill, onError } }; }, [file, onError]); - const handleSelection = useCallback(() => { - const text = window.getSelection()?.toString() ?? ""; - if (!text.trim()) return; - onFill(text); - // 流し込み後は選択を解除し、次にどこを掴んだか分かりやすくする。 - window.getSelection()?.removeAllRanges(); - }, [onFill]); + const handleSelection = useSelectionFill(onFill); if (html === null) { return

{IMPORT_ASSIST_MESSAGES.RENDERING}

; diff --git a/frontend/src/components/forms/PdfDocumentView.tsx b/frontend/src/components/forms/PdfDocumentView.tsx index 4594d9a3..af71e238 100644 --- a/frontend/src/components/forms/PdfDocumentView.tsx +++ b/frontend/src/components/forms/PdfDocumentView.tsx @@ -2,21 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Document, Page } from "react-pdf"; import { IMPORT_ASSIST_MESSAGES } from "../../constants/messages"; +import { useSelectionFill, type DocumentViewProps } from "./documentView"; import styles from "./ResumeSourceTracePanel.module.css"; // worker 設定(副作用 import)。このモジュールごと React.lazy で遅延ロードされる。 import "../../utils/pdfjs"; -type Props = { - /** 描画対象の PDF */ - file: File; - /** ズーム倍率(1 = カラム幅にフィット)。1 超で横スクロールが発生する。 */ - zoom?: number; - /** PDF 上で選択された文字列を流し込むコールバック */ - onFill: (text: string) => void; - /** 描画エラー・エラー解除を親へ伝えるコールバック */ - onError: (message: string | null) => void; -}; - /** ドキュメント全体の最小テキスト量。これ未満ならスキャン PDF とみなして警告する。 */ const MIN_TEXT_CHARS = 20; @@ -28,7 +18,7 @@ const MIN_TEXT_CHARS = 20; * 流し込み先の決定(最後にフォーカスした入力欄)は useResumeImportAssist 側が担うため、 * ここは「選択文字列を onFill に渡す」ことだけに専念する。 */ -export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: Props) { +export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: DocumentViewProps) { const containerRef = useRef(null); const [width, setWidth] = useState(0); const [numPages, setNumPages] = useState(0); @@ -59,13 +49,7 @@ export default function PdfDocumentView({ file, zoom = 1, onFill, onError }: Pro // 別の PDF への切り替えは呼び出し側が key で再マウントするため、ここでのリセットは不要。 - const handleSelection = useCallback(() => { - const text = window.getSelection()?.toString() ?? ""; - if (!text.trim()) return; - onFill(text); - // 流し込み後は選択を解除し、次にどこを掴んだか分かりやすくする。 - window.getSelection()?.removeAllRanges(); - }, [onFill]); + const handleSelection = useSelectionFill(onFill); const handleLoadSuccess = useCallback( (pdf: { numPages: number }) => { diff --git a/frontend/src/components/forms/documentView.ts b/frontend/src/components/forms/documentView.ts new file mode 100644 index 00000000..92c90da7 --- /dev/null +++ b/frontend/src/components/forms/documentView.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; + +/** + * 原本ビュー({@link MarkdownDocumentView} / {@link PdfDocumentView})共通の Props。 + * + * レンダリング技術(DOMPurify HTML / react-pdf)は実装ごとに異なるが、 + * 「ファイルを受け取り、テキスト選択を onFill に流し、描画エラーを onError で伝える」 + * という入出力契約は共通なのでここに集約する。 + */ +export type DocumentViewProps = { + /** 描画対象のファイル */ + file: File; + /** ズーム倍率(1 = 等倍 / カラム幅フィット) */ + zoom?: number; + /** 原本上で選択された文字列を流し込むコールバック */ + onFill: (text: string) => void; + /** 描画エラー・エラー解除を親へ伝えるコールバック */ + onError: (message: string | null) => void; +}; + +/** + * テキスト選択を「流し込み」に変換する共通ハンドラを返す。 + * + * 選択文字列があれば onFill に渡し、流し込み後は選択を解除して + * 次にどこを掴んだか分かりやすくする。Markdown / PDF 両ビューで共用する。 + */ +export function useSelectionFill(onFill: (text: string) => void) { + return useCallback(() => { + const text = window.getSelection()?.toString() ?? ""; + if (!text.trim()) return; + onFill(text); + window.getSelection()?.removeAllRanges(); + }, [onFill]); +} diff --git a/frontend/src/components/forms/sections/CareerFormToolbar.tsx b/frontend/src/components/forms/sections/CareerFormToolbar.tsx new file mode 100644 index 00000000..13dd5da9 --- /dev/null +++ b/frontend/src/components/forms/sections/CareerFormToolbar.tsx @@ -0,0 +1,112 @@ +import { UI_MESSAGES } from "../../../constants/messages"; +import { SaveIcon } from "../../icons/SaveIcon"; +import { EyeIcon } from "../../icons/EyeIcon"; +import { TrashIcon } from "../../icons/TrashIcon"; +import { PdfDownloadIcon } from "../../icons/PdfDownloadIcon"; +import { MarkdownDownloadIcon } from "../../icons/MarkdownDownloadIcon"; +import shared from "../../../styles/shared.module.css"; +import layout from "../CareerResumeForm.module.css"; + +type Props = { + /** 保存ボタンのラベル(保存 / 更新)。 */ + saveButtonText: string; + /** 保存可能か(マスタ含むデータが揃っているか)。 */ + canSubmit: boolean; + /** 保存処理中。 */ + saving: boolean; + /** プレビュー / PDF / Markdown の出力ボタンを非活性にするか。 */ + exportDisabled: boolean; + /** PDF ダウンロード処理中。 */ + downloading: boolean; + /** 削除ボタンを非活性にするか。 */ + deleteDisabled: boolean; + onPreview: () => void; + onDownloadPdf: () => void; + onDownloadMarkdown: () => void; + onDelete: () => void; +}; + +/** + * 職務経歴書フォームのヘッダーツールバー(保存・プレビュー・PDF・Markdown・削除)。 + * + * 表示専用。保存ボタンは form の submit、それ以外は親から渡されたハンドラを呼ぶだけで、 + * 認証ゲートや出力ロジックは {@link useCareerExportActions} 側が担う。 + */ +export function CareerFormToolbar({ + saveButtonText, + canSubmit, + saving, + exportDisabled, + downloading, + deleteDisabled, + onPreview, + onDownloadPdf, + onDownloadMarkdown, + onDelete, +}: Props) { + return ( +
+

{UI_MESSAGES.CAREER_RESUME_TITLE}

+
+ {/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */} + + + + + +
+
+ ); +} diff --git a/frontend/src/constants/agentModels.test.ts b/frontend/src/constants/agentModels.test.ts new file mode 100644 index 00000000..3dde631c --- /dev/null +++ b/frontend/src/constants/agentModels.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + AGENT_MODEL_OPTIONS, + getModelOption, + getModelOptionsByProvider, +} from "./agentModels"; + +/** + * agentModels の SSoT 整合テスト。 + * + * モデルエイリアス集合の正本は backend `AgentModelAlias`(OpenAPI codegen 経由で + * `generated.ts` に反映)。`AGENT_MODEL_META` を `Record` で + * 型付けしているため網羅性はコンパイル時に保証されるが、本テストは「プロバイダ列分け + * の漏れ」「未知エイリアスのフォールバック」といった runtime 不変条件を固定する。 + */ +describe("agentModels", () => { + it("全 option の alias は一意", () => { + const aliases = AGENT_MODEL_OPTIONS.map((o) => o.alias); + expect(new Set(aliases).size).toBe(aliases.length); + }); + + it("getModelOption は既知 alias の表示メタを返す", () => { + expect(getModelOption("sonnet").name).toBe("Sonnet 4.6"); + expect(getModelOption("gemini-pro").provider).toBe("google"); + }); + + it("getModelOption は未知 alias を先頭(haiku)にフォールバックする", () => { + // @ts-expect-error 型に無い値を渡して runtime フォールバックを検証する + expect(getModelOption("unknown-model").alias).toBe("haiku"); + }); + + it("getModelOptionsByProvider は全 option を漏れなくいずれかの列に含む", () => { + // 新プロバイダのモデルが AGENT_PROVIDER_ORDER 漏れで列から落ちる drift を検知する + const grouped = getModelOptionsByProvider() + .flatMap((g) => g.options) + .map((o) => o.alias) + .sort(); + const all = AGENT_MODEL_OPTIONS.map((o) => o.alias).sort(); + expect(grouped).toEqual(all); + }); +}); diff --git a/frontend/src/constants/agentModels.ts b/frontend/src/constants/agentModels.ts index 7efca32f..e9524142 100644 --- a/frontend/src/constants/agentModels.ts +++ b/frontend/src/constants/agentModels.ts @@ -42,8 +42,19 @@ export const AGENT_PROVIDER_ORDER: readonly AgentModelProvider[] = [ // 1 クレジット = ¥1 なので 1,000 クレジット = ¥1,000(手に取りやすい基準額) export const CREDIT_ESTIMATE_REFERENCE = 1_000; -export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ - { +/** + * エイリアス → 表示メタデータ。`Record` で型付けすることで、 + * backend が `AgentModelAlias`(OpenAPI codegen 経由で `generated.ts` に反映)へ + * 新モデルを追加した瞬間、ここにメタ未定義のままだと**コンパイルエラー**になる。 + * これにより「モデル選択 UI に新モデルが出ない」同期忘れを型で防ぐ(BE 側の + * model_catalog ↔ AgentModelAlias drift ガードと対になる FE 側ガード)。 + * + * `isPaid` の正本は backend `model_catalog.is_free`(その反転)。runtime では + * model_rates API(`is_free`)が SSoT で、ここは API 未取得時の静的フォールバック。 + * 両者の乖離は agentModels.test.ts の drift テストで検出する。 + */ +const AGENT_MODEL_META: Record = { + haiku: { alias: "haiku", provider: "anthropic", name: "Haiku 4.5", @@ -51,7 +62,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.HAIKU_TAGLINE, costHint: AGENT_MODEL_MESSAGES.HAIKU_COST, }, - { + sonnet: { alias: "sonnet", provider: "anthropic", name: "Sonnet 4.6", @@ -59,7 +70,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.SONNET_TAGLINE, costHint: AGENT_MODEL_MESSAGES.SONNET_COST, }, - { + "gpt-mini": { alias: "gpt-mini", provider: "openai", name: "GPT-4o mini", @@ -67,7 +78,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.GPT_MINI_TAGLINE, costHint: AGENT_MODEL_MESSAGES.GPT_MINI_COST, }, - { + gpt: { alias: "gpt", provider: "openai", name: "GPT-4.1", @@ -75,7 +86,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.GPT_TAGLINE, costHint: AGENT_MODEL_MESSAGES.GPT_COST, }, - { + "gemini-flash": { alias: "gemini-flash", provider: "google", name: "Gemini 2.5 Flash", @@ -83,7 +94,7 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.GEMINI_FLASH_TAGLINE, costHint: AGENT_MODEL_MESSAGES.GEMINI_FLASH_COST, }, - { + "gemini-pro": { alias: "gemini-pro", provider: "google", name: "Gemini 2.5 Pro", @@ -91,7 +102,11 @@ export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = [ tagline: AGENT_MODEL_MESSAGES.GEMINI_PRO_TAGLINE, costHint: AGENT_MODEL_MESSAGES.GEMINI_PRO_COST, }, -]; +}; + +// 表示順は Record の定義順(プロバイダごとに無料→有料)。getModelOptionsByProvider が +// AGENT_PROVIDER_ORDER で列に振り分ける。 +export const AGENT_MODEL_OPTIONS: readonly AgentModelOption[] = Object.values(AGENT_MODEL_META); /** エイリアスから表示メタデータを引く。未知の値は先頭(haiku)にフォールバック。 */ export function getModelOption(alias: AgentModelAlias): AgentModelOption { diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 7554d605..c28b9d1f 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -152,6 +152,8 @@ export const UI_MESSAGES = { REPORT_ISSUE: "GitHub Issueに報告", OPENS_IN_NEW_TAB: "(新しいタブで開きます)", COPYRIGHT: "© 2026 DevForge", + // 職務経歴書ページのタイトル見出し + CAREER_RESUME_TITLE: "職務経歴書", // 職務経歴書ヘッダーのアイコンボタン(aria-label / title 用) RESUME_PREVIEW: "プレビュー", RESUME_EXPORT_PDF: "PDF出力", diff --git a/frontend/src/hooks/career/useCareerExportActions.ts b/frontend/src/hooks/career/useCareerExportActions.ts new file mode 100644 index 00000000..481d7ff2 --- /dev/null +++ b/frontend/src/hooks/career/useCareerExportActions.ts @@ -0,0 +1,71 @@ +import { + downloadCareerResumeMarkdown, + downloadCareerResumePdf, + getCareerResumePdfBlobUrl, +} from "../../api"; +import { SUCCESS_MESSAGES } from "../../constants/messages"; +import { usePdfActions } from "../usePdfActions"; + +type Params = { + /** 認証状態。未ログインなら各操作はログイン導線へ流す。 */ + isAuthenticated: boolean; + /** 保存済み経歴書の ID(未保存なら null)。 */ + resumeId: string | null; + /** ローディング等で操作をブロックしたいときのフラグ。 */ + formLocked: boolean; + /** 未ログイン時に開くログイン促進モーダル。 */ + requestLogin: () => void; +}; + +/** + * 経歴書の出力操作(プレビュー / PDF / Markdown ダウンロード)を一元管理するフック。 + * + * `usePdfActions` を内包し、職務経歴書の API バインドと「未ログインなら requestLogin、 + * ログイン済みなら resumeId を渡して実行」という認証ゲートをまとめる。 + * CareerResumeForm から出力系の責務を切り離して本体を薄く保つのが目的。 + */ +export function useCareerExportActions({ isAuthenticated, resumeId, formLocked, requestLogin }: Params) { + const { + downloading, + previewUrl, + closePreview, + onDownloadPdf, + onDownloadMarkdown, + onPreviewPdf, + error, + success, + } = usePdfActions({ + downloadPdf: downloadCareerResumePdf, + downloadMarkdown: downloadCareerResumeMarkdown, + getPdfBlobUrl: getCareerResumePdfBlobUrl, + }); + + const handlePreview = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onPreviewPdf(resumeId); + }; + const handleDownloadPdf = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED); + }; + const handleDownloadMarkdown = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onDownloadMarkdown(resumeId); + }; + + // 未ログインでは要ログイン機能ボタンを活性にしてログイン導線にする。 + // ログイン済みでは保存済み(resumeId あり)まで非活性。 + const exportDisabled = formLocked || (isAuthenticated && !resumeId); + + return { + downloading, + previewUrl, + closePreview, + handlePreview, + handleDownloadPdf, + handleDownloadMarkdown, + exportDisabled, + error, + success, + }; +}