From c867f84ea97e19be7c87b3a9551157ec952e05b9 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 11 Jun 2026 11:41:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(agent):=20DevForge=20Agent=20=E3=81=AE?= =?UTF-8?q?=20backend=20=E3=82=92=E5=AE=9F=E8=A3=85=EF=BC=88POST=20/api/ag?= =?UTF-8?q?ent/chat=E3=80=81ADR-0010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0010 を新規作成し ADR-0008 を Superseded 化(LLM 再導入) - LLM プロバイダ抽象を再構築(anthropic / ollama、失敗は LLMError を raise) - スコープ別の差分 operations 生成サービスとスキーマ(テキストフィールドのみ) - 認証ガード + rate limit 10/min、エラーコード AGENT_LLM_ERROR / AGENT_PARSE_ERROR を追加 - 環境変数 4 件追加(env_keys 正本 + docker-compose / cloud_run / docs の同期) - OpenAPI 型を再生成(frontend/src/api/generated.ts) - 統合・ユニットテスト 12 件追加(LLM のみモック、実 DB 使用) Co-Authored-By: Claude Opus 4.8 --- .claude/rules/backend/architecture.md | 3 + backend/app/core/env_keys.py | 9 + backend/app/core/errors.py | 3 + backend/app/core/settings.py | 23 ++ backend/app/main.py | 2 + backend/app/messages.json | 6 + backend/app/routers/__init__.py | 2 + backend/app/routers/agent.py | 60 ++++ backend/app/schemas/agent.py | 108 +++++++ backend/app/services/agent/__init__.py | 1 + backend/app/services/agent/chat_service.py | 155 ++++++++++ backend/app/services/agent/llm/__init__.py | 10 + .../services/agent/llm/anthropic_client.py | 53 ++++ backend/app/services/agent/llm/base.py | 27 ++ backend/app/services/agent/llm/factory.py | 20 ++ .../app/services/agent/llm/ollama_client.py | 49 +++ backend/requirements.txt | 2 + backend/tests/test_agent.py | 281 ++++++++++++++++++ docker-compose.yml | 5 + .../0008-remove-llm-to-rule-based-design.md | 7 +- docs/adr/0010-devforge-agent.md | 151 ++++++++++ docs/api.md | 12 + docs/development.md | 11 + frontend/src/api/generated.ts | 202 +++++++++++++ frontend/src/constants/errorCodes.ts | 3 + frontend/src/constants/errorMessages.ts | 8 + infra/modules/cloud_run/main.tf | 8 + 27 files changed, 1220 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/agent.py create mode 100644 backend/app/schemas/agent.py create mode 100644 backend/app/services/agent/__init__.py create mode 100644 backend/app/services/agent/chat_service.py create mode 100644 backend/app/services/agent/llm/__init__.py create mode 100644 backend/app/services/agent/llm/anthropic_client.py create mode 100644 backend/app/services/agent/llm/base.py create mode 100644 backend/app/services/agent/llm/factory.py create mode 100644 backend/app/services/agent/llm/ollama_client.py create mode 100644 backend/tests/test_agent.py create mode 100644 docs/adr/0010-devforge-agent.md diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index c3334741..fb34a3c9 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -52,6 +52,9 @@ backend/app/ │ ├── base.py / user.py / blog.py │ ├── master_data.py / notification.py / resume.py ├── services/ +│ ├── agent/ # DevForge Agent(LLM チャット / ADR-0010) +│ │ ├── chat_service.py # コンテキスト組み立て → LLM → operations 検証 +│ │ └── llm/ # LLM プロバイダ抽象(anthropic / ollama、失敗は raise) │ ├── blog/ # ブログ収集・技術記事判定・スコア算出 │ │ ├── account_service.py │ │ ├── collector.py diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index ab485841..4d6ed4fe 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -88,6 +88,15 @@ LOG_FORMAT = "LOG_FORMAT" LOG_LEVEL = "LOG_LEVEL" +# --- LLM(DevForge Agent / ADR-0010) --- + +# プロバイダ切り替え("anthropic" | "ollama"。未設定時はローカル向けの ollama) +LLM_PROVIDER = "LLM_PROVIDER" +# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止 +ANTHROPIC_API_KEY = "ANTHROPIC_API_KEY" +OLLAMA_BASE_URL = "OLLAMA_BASE_URL" +OLLAMA_MODEL = "OLLAMA_MODEL" + # --- アプリ起動制御 --- APP_BOOTSTRAPPED = "APP_BOOTSTRAPPED" diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 37f20b2b..f18c27ee 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -30,6 +30,9 @@ class ErrorCode(str, Enum): VALIDATION_ERROR = "VALIDATION_ERROR" # 外部 API QIITA_RATE_LIMITED = "QIITA_RATE_LIMITED" + # Agent(LLM チャット / ADR-0010) + AGENT_LLM_ERROR = "AGENT_LLM_ERROR" + AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR" # アプリケーション全体 RATE_LIMITED = "RATE_LIMITED" # サーバー diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index 1c9f207d..7258a70f 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -170,6 +170,29 @@ def get_internal_secret() -> str: return secret +def get_llm_provider() -> str: + """Agent 機能の LLM プロバイダ名(anthropic / ollama)を小文字で取得する。 + + 未設定時はローカル開発向けに ollama を既定とする(ADR-0010)。 + """ + return os.getenv(env_keys.LLM_PROVIDER, "ollama").strip().lower() + + +def get_anthropic_api_key() -> str: + """Anthropic API キーを取得する。値はログや例外メッセージに含めないこと。""" + return os.getenv(env_keys.ANTHROPIC_API_KEY, "").strip() + + +def get_ollama_base_url() -> str: + """ローカル Ollama のベース URL を取得する。""" + return os.getenv(env_keys.OLLAMA_BASE_URL, "http://localhost:11434").strip() + + +def get_ollama_model() -> str: + """ローカル Ollama で使用するモデル名を取得する。""" + return os.getenv(env_keys.OLLAMA_MODEL, "llama3.2").strip() + + def get_log_format() -> str: """ログフォーマット指定(json / text / 空)を小文字で取得する。""" return os.getenv(env_keys.LOG_FORMAT, "").strip().lower() diff --git a/backend/app/main.py b/backend/app/main.py index dbcf8172..f4f48f30 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ from .db.bootstrap import bootstrap # noqa: E402 from .middleware.request_id import RequestIDMiddleware # noqa: E402 from .routers import ( # noqa: E402 + agent_router, auth_router, blog_router, github_link_router, @@ -213,3 +214,4 @@ async def dispatch(self, request: Request, call_next) -> Response: app.include_router(master_data_router) app.include_router(notifications_router) app.include_router(internal_router) +app.include_router(agent_router) diff --git a/backend/app/messages.json b/backend/app/messages.json index d2899488..8cc005e0 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -67,6 +67,12 @@ "internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。", "unknown_task_type": "不明なタスク種別: {task_type}", "execution_failed": "タスク実行中に予期しないエラーが発生しました。" + }, + "agent": { + "llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。", + "parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。", + "target_required": "project スコープでは対象プロジェクトの指定が必要です。", + "target_not_found": "指定されたプロジェクトが見つかりません。" } }, "notification": { diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 53296f17..0875771b 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -1,3 +1,4 @@ +from .agent import router as agent_router from .auth import router as auth_router from .blog import router as blog_router from .github_link import router as github_link_router @@ -8,6 +9,7 @@ from .resumes import router as resumes_router __all__ = [ + "agent_router", "auth_router", "blog_router", "github_link_router", diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py new file mode 100644 index 00000000..a6afdd2d --- /dev/null +++ b/backend/app/routers/agent.py @@ -0,0 +1,60 @@ +"""DevForge Agent(LLM チャット)エンドポイント(ADR-0010)。 + +外部 LLM API を呼ぶ高コスト endpoint のため rate limit を付与する。 +DB は参照・更新しない(フロントが編集中フォームを送り、適用はフロント側)。 +""" + +import logging + +from fastapi import APIRouter, Depends, Request + +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.dependencies import limiter +from ..models import User +from ..schemas.agent import AgentChatRequest, AgentChatResponse +from ..services.agent import chat_service +from ..services.agent.chat_service import ( + AgentResponseParseError, + AgentTargetNotFoundError, +) +from ..services.agent.llm.base import LLMError + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/agent", tags=["agent"]) + + +@router.post("/chat", response_model=AgentChatResponse) +@limiter.limit("10/minute") +async def agent_chat( + request: Request, + body: AgentChatRequest, + user: User = Depends(get_current_user), +) -> AgentChatResponse: + """選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 + + レスポンスはフロントの state にのみ適用され、DB は更新しない。 + ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 + """ + try: + return await chat_service.run_agent_chat(body) + except AgentTargetNotFoundError: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.target_not_found"), + ) + except LLMError: + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_LLM_ERROR, + message=get_error("agent.llm_failed"), + ) + except AgentResponseParseError: + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_PARSE_ERROR, + message=get_error("agent.parse_failed"), + ) diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py new file mode 100644 index 00000000..9ce3b4be --- /dev/null +++ b/backend/app/schemas/agent.py @@ -0,0 +1,108 @@ +"""DevForge Agent(LLM チャット)の Pydantic スキーマ(ADR-0010)。 + +リクエストの ``resume`` は保存用の ``ResumeCreate`` ではなく緩い専用コンテキスト型を使う。 +ユーザーが編集途中のフォーム(必須項目が未入力・日付未設定など保存契約を満たさない状態) +から Agent を呼べる必要があり、保存時バリデーションを再利用すると 422 になるため。 +フィールドの max_length は保存契約(``resume.py``)と同じ上限に揃える。 + +レスポンスの ``operations`` はテキストフィールドの置換のみ(構造編集なし、Phase 1)。 +スコープ選択が必須で対象 project の位置はリクエストで確定するため、operation は +パス指定を持たず「フィールド名 + 新しい値」だけを返す。 +""" + +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from ..core.messages import get_error + +AgentScope = Literal["project", "career_summary", "self_pr"] + +# operation が編集できるフィールド(Phase 1 はテキストのみ) +AgentField = Literal["career_summary", "self_pr", "description", "role"] + + +class AgentTechnologyStack(BaseModel): + """LLM コンテキスト用の技術スタック(保存契約より緩い)。""" + + category: str = Field(default="", max_length=60) + name: str = Field(default="", max_length=120) + + +class AgentProjectContext(BaseModel): + """LLM コンテキスト用のプロジェクト情報。""" + + name: str = Field(default="", max_length=200) + role: str = Field(default="", max_length=200) + description: str = Field(default="", max_length=4500) + technology_stacks: list[AgentTechnologyStack] = Field(default_factory=list) + phases: list[str] = Field(default_factory=list) + + +class AgentClientContext(BaseModel): + """LLM コンテキスト用の取引先情報。""" + + name: str = Field(default="", max_length=200) + projects: list[AgentProjectContext] = Field(default_factory=list) + + +class AgentExperienceContext(BaseModel): + """LLM コンテキスト用の在籍企業情報。""" + + company: str = Field(default="", max_length=120) + business_description: str = Field(default="", max_length=200) + clients: list[AgentClientContext] = Field(default_factory=list) + + +class AgentResumeContext(BaseModel): + """LLM に渡す編集中の職務経歴書コンテキスト。 + + 保存契約(必須項目・日付検証)は適用しない。DB は参照せず、 + フロントが編集中のフォーム内容をそのまま送る設計(DB を更新しない原則)。 + """ + + career_summary: str = Field(default="", max_length=2000) + self_pr: str = Field(default="", max_length=2000) + experiences: list[AgentExperienceContext] = Field(default_factory=list) + + +class ProjectTarget(BaseModel): + """scope=project のとき対象プロジェクトを特定するインデックス。""" + + experience_index: int = Field(ge=0) + client_index: int = Field(ge=0) + project_index: int = Field(ge=0) + + +class AgentChatRequest(BaseModel): + """Agent チャットのリクエスト。スコープ選択は必須。""" + + scope: AgentScope + prompt: str = Field(min_length=1, max_length=2000) + resume: AgentResumeContext + target: ProjectTarget | None = None + + @model_validator(mode="after") + def validate_target(self) -> "AgentChatRequest": + """project スコープでは対象プロジェクトの指定を必須とする。""" + if self.scope == "project" and self.target is None: + raise ValueError(get_error("agent.target_required")) + return self + + +class AgentOperation(BaseModel): + """resume state へ適用する差分(テキストフィールドの置換)。 + + フロントは選択済みスコープ(と target)に対応するフィールドへ value を反映する。 + DB は更新せず、ユーザーが「適用」した時点で既存の保存 API を呼ぶ。 + """ + + field: AgentField + value: str = Field(max_length=4500) + + +class AgentChatResponse(BaseModel): + """Agent チャットのレスポンス(AI の説明文 + 差分 operations)。""" + + message: str + operations: list[AgentOperation] = Field(default_factory=list) diff --git a/backend/app/services/agent/__init__.py b/backend/app/services/agent/__init__.py new file mode 100644 index 00000000..8bc5db59 --- /dev/null +++ b/backend/app/services/agent/__init__.py @@ -0,0 +1 @@ +"""DevForge Agent(LLM チャット)サービス(ADR-0010)。""" diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py new file mode 100644 index 00000000..00cee490 --- /dev/null +++ b/backend/app/services/agent/chat_service.py @@ -0,0 +1,155 @@ +"""Agent チャットの中核ロジック(コンテキスト組み立て → LLM → operations 検証)。 + +DB アクセスは行わない。フロントが編集中フォームをリクエストに載せて送り、 +レスポンスの operations はフロントの state にのみ適用される(DB を更新しない原則 / ADR-0010)。 +""" + +import json +import logging + +from pydantic import ValidationError + +from ...schemas.agent import ( + AgentChatRequest, + AgentChatResponse, + AgentOperation, + AgentProjectContext, +) +from .llm.factory import get_llm_client + +logger = logging.getLogger(__name__) + + +class AgentTargetNotFoundError(Exception): + """target のインデックスが resume コンテキストの範囲外。""" + + +class AgentResponseParseError(Exception): + """LLM 応答の JSON パースまたはスキーマ検証に失敗。""" + + +# スコープごとに operations が編集してよいフィールドと文字数上限 +_SCOPE_FIELDS: dict[str, dict[str, int]] = { + "career_summary": {"career_summary": 2000}, + "self_pr": {"self_pr": 2000}, + "project": {"description": 4500, "role": 200}, +} + +_SYSTEM_PROMPT = """\ +あなたは日本語の職務経歴書の改善を支援するアシスタントです。 +ユーザーの依頼に基づき、編集対象フィールドの改善案を JSON で返してください。 + +# 出力形式(JSON のみ。前置き・コードフェンス・補足テキストは一切禁止) +{{"message": "<提案の説明(日本語)>", "operations": [{{"field": "<フィールド名>", "value": "<新しい本文>"}}]}} + +# ルール +- operations の field は次のみ許可: {allowed_fields} +- 各フィールドの文字数上限: {field_limits} +- 提案が不要・不可能な場合は operations を空配列にし、message で理由を説明する +- value は職務経歴書にそのまま掲載できる完成した日本語の文章にする +""" + + +def _build_context(request: AgentChatRequest) -> str: + """スコープに応じて LLM に渡すコンテキスト文字列を組み立てる。""" + resume = request.resume + if request.scope == "career_summary": + return json.dumps( + { + "現在の職務要約": resume.career_summary, + "在籍企業の概要": [ + {"会社": e.company, "事業内容": e.business_description} + for e in resume.experiences + ], + }, + ensure_ascii=False, + ) + if request.scope == "self_pr": + return json.dumps( + { + "現在の自己PR": resume.self_pr, + "職務要約": resume.career_summary, + }, + ensure_ascii=False, + ) + project = _resolve_target_project(request) + return json.dumps( + { + "プロジェクト名": project.name, + "現在の役割": project.role, + "現在の詳細": project.description, + "技術スタック": [s.name for s in project.technology_stacks if s.name], + "担当工程": project.phases, + }, + ensure_ascii=False, + ) + + +def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: + """target のインデックスから対象プロジェクトを取り出す。範囲外は専用例外。""" + target = request.target + # scope=project のとき target は schema で必須検証済み + assert target is not None + try: + return ( + request.resume.experiences[target.experience_index] + .clients[target.client_index] + .projects[target.project_index] + ) + except IndexError as exc: + raise AgentTargetNotFoundError( + f"target index out of range: {target}" + ) from exc + + +def _parse_response(raw: str, scope: str) -> AgentChatResponse: + """LLM 応答をパースし、スコープ外・上限超過の operation を破棄して返す。""" + text = raw.strip() + # JSON のみを指示しても小型モデルはコードフェンスを付けることがあるため除去する + if text.startswith("```"): + text = text.strip("`") + text = text.removeprefix("json").strip() + try: + data = json.loads(text) + parsed = AgentChatResponse.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + logger.warning("LLM 応答のパースに失敗: %s", type(exc).__name__) + raise AgentResponseParseError(str(exc)) from exc + + allowed = _SCOPE_FIELDS[scope] + operations: list[AgentOperation] = [] + for op in parsed.operations: + if op.field not in allowed: + # スコープ外フィールドの提案は適用先が特定できないため破棄する + logger.warning("スコープ外の operation を破棄: scope=%s field=%s", scope, op.field) + continue + if len(op.value) > allowed[op.field]: + logger.warning( + "文字数上限超過の operation を破棄: field=%s len=%d", op.field, len(op.value) + ) + continue + operations.append(op) + return AgentChatResponse(message=parsed.message, operations=operations) + + +async def run_agent_chat(request: AgentChatRequest) -> AgentChatResponse: + """Agent チャットを実行する。 + + Raises: + AgentTargetNotFoundError: target が範囲外。 + AgentResponseParseError: LLM 応答が不正。 + LLMError: LLM 呼び出しの失敗(llm.base 参照)。 + """ + allowed = _SCOPE_FIELDS[request.scope] + system_prompt = _SYSTEM_PROMPT.format( + allowed_fields=", ".join(allowed), + field_limits=", ".join(f"{k}: {v}文字" for k, v in allowed.items()), + ) + user_prompt = ( + f"# 編集対象スコープ\n{request.scope}\n\n" + f"# 現在の内容\n{_build_context(request)}\n\n" + f"# ユーザーの依頼\n{request.prompt}" + ) + client = get_llm_client() + raw = await client.generate(system_prompt, user_prompt) + return _parse_response(raw, request.scope) diff --git a/backend/app/services/agent/llm/__init__.py b/backend/app/services/agent/llm/__init__.py new file mode 100644 index 00000000..ad461dcd --- /dev/null +++ b/backend/app/services/agent/llm/__init__.py @@ -0,0 +1,10 @@ +"""LLM プロバイダ抽象(ADR-0010 で再構築。設計の参考: ADR-0004)。 + +ADR-0004 との差分: ``generate()`` は失敗時に空文字を返さず ``LLMError`` を raise する。 +対話型機能のため、失敗をユーザーへ日本語エラーとして伝達する必要がある。 +""" + +from .base import LLMClient, LLMError +from .factory import get_llm_client + +__all__ = ["LLMClient", "LLMError", "get_llm_client"] diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py new file mode 100644 index 00000000..6e876ccf --- /dev/null +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -0,0 +1,53 @@ +"""Anthropic API クライアント(本番用。モデル: Claude Haiku 4.5)。""" + +import logging + +import anthropic + +from ....core import settings +from .base import LLMClient, LLMError + +logger = logging.getLogger(__name__) + +# 差分 operations の小さい JSON を返す用途のため Haiku を採用(ADR-0010)。 +# 精度不足が判明した場合は claude-sonnet-4-6 へ切り替える。 +_MODEL = "claude-haiku-4-5" +# operations JSON(最大 4500 文字のテキスト置換 + 説明文)に十分な上限 +_MAX_TOKENS = 4096 +_TIMEOUT_SECONDS = 60.0 + + +class AnthropicClient(LLMClient): + """Anthropic Messages API を呼び出すクライアント。""" + + def __init__(self) -> None: + api_key = settings.get_anthropic_api_key() + if not api_key: + raise LLMError("ANTHROPIC_API_KEY が設定されていません") + self._client = anthropic.AsyncAnthropic( + api_key=api_key, timeout=_TIMEOUT_SECONDS + ) + + async def generate(self, system_prompt: str, user_prompt: str) -> str: + try: + response = await self._client.messages.create( + model=_MODEL, + max_tokens=_MAX_TOKENS, + system=system_prompt, + messages=[{"role": "user", "content": user_prompt}], + ) + except ( + anthropic.APITimeoutError, + 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 + + text = "".join( + block.text for block in response.content if block.type == "text" + ) + if not text: + raise LLMError("Anthropic API から空の応答が返されました") + return text diff --git a/backend/app/services/agent/llm/base.py b/backend/app/services/agent/llm/base.py new file mode 100644 index 00000000..10e61c22 --- /dev/null +++ b/backend/app/services/agent/llm/base.py @@ -0,0 +1,27 @@ +"""LLM クライアントの抽象基底クラスと共通例外。""" + +from abc import ABC, abstractmethod + + +class LLMError(Exception): + """LLM 呼び出しの失敗(タイムアウト / 接続エラー / HTTP エラー)を表す。 + + プロバイダ固有の例外は各クライアントで本例外にラップする。 + 呼び出し側(router)は本例外を 502 + ``AGENT_LLM_ERROR`` にマッピングする。 + """ + + +class LLMClient(ABC): + """LLM プロバイダの共通インターフェース。 + + 失敗時は例外を握りつぶさず ``LLMError`` を raise する契約 + (ADR-0004 の「空文字を返す」設計は採用しない。ADR-0010 参照)。 + """ + + @abstractmethod + async def generate(self, system_prompt: str, user_prompt: str) -> str: + """system / user プロンプトを渡して応答テキストを返す。 + + Raises: + LLMError: タイムアウト・接続失敗・API エラー時。 + """ diff --git a/backend/app/services/agent/llm/factory.py b/backend/app/services/agent/llm/factory.py new file mode 100644 index 00000000..e1c4b92f --- /dev/null +++ b/backend/app/services/agent/llm/factory.py @@ -0,0 +1,20 @@ +"""LLM プロバイダの切り替え(LLM_PROVIDER 環境変数で分岐)。""" + +from ....core import settings +from .base import LLMClient + + +def get_llm_client() -> LLMClient: + """設定に応じた LLM クライアントを返す。 + + 遅延 import により、anthropic SDK 未インストール環境でも + ollama モード(ローカル開発・テスト)で動作できるようにする。 + """ + provider = settings.get_llm_provider() + if provider == "anthropic": + from .anthropic_client import AnthropicClient + + return AnthropicClient() + from .ollama_client import OllamaClient + + return OllamaClient() diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py new file mode 100644 index 00000000..2de9063d --- /dev/null +++ b/backend/app/services/agent/llm/ollama_client.py @@ -0,0 +1,49 @@ +"""Ollama クライアント(ローカル開発用。REST 直呼び)。""" + +import logging + +import httpx + +from ....core import settings +from .base import LLMClient, LLMError + +logger = logging.getLogger(__name__) + +_TIMEOUT_SECONDS = 120.0 + + +class OllamaClient(LLMClient): + """ローカル Ollama の /api/chat を呼び出すクライアント。 + + ``format: json`` を指定して JSON のみの応答を強制する + (ローカルの小型モデルは前置きテキストを混ぜやすいため)。 + """ + + def __init__(self) -> None: + self._base_url = settings.get_ollama_base_url() + self._model = settings.get_ollama_model() + + async def generate(self, system_prompt: str, user_prompt: str) -> str: + payload = { + "model": self._model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "stream": False, + "format": "json", + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: + response = await client.post( + f"{self._base_url}/api/chat", json=payload + ) + 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 + + text = response.json().get("message", {}).get("content", "") + if not text: + raise LLMError("Ollama から空の応答が返されました") + return text diff --git a/backend/requirements.txt b/backend/requirements.txt index 5e5ebd81..4c3b25c6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,8 @@ google-cloud-storage==2.19.0 pytest==9.0.3 python-jose[cryptography]==3.5.0 httpx==0.28.1 +# DevForge Agent(ADR-0010)。Claude Haiku 4.5 呼び出し用 +anthropic>=0.40,<1 PyGithub==2.5.0 pandas==2.2.3 slowapi==0.1.9 diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py new file mode 100644 index 00000000..59aa6004 --- /dev/null +++ b/backend/tests/test_agent.py @@ -0,0 +1,281 @@ +"""Agent チャットエンドポイント(POST /api/agent/chat)の統合テスト(ADR-0010)。 + +LLM(外部 API)のみモックし、リクエスト検証・エラーマッピング・operations の +スコープ整合フィルタは実コードを通す。 +""" + +import asyncio +import json +from unittest.mock import AsyncMock + +import pytest +from app.services.agent import chat_service +from app.services.agent.chat_service import ( + AgentResponseParseError, + AgentTargetNotFoundError, + _parse_response, +) +from app.services.agent.llm.base import LLMClient, LLMError +from fastapi.testclient import TestClient + +from conftest import auth_header + + +class _FakeLLM(LLMClient): + """テスト用の LLM クライアント(固定応答 or 例外)。""" + + def __init__(self, response: str | None = None, error: Exception | None = None): + self._response = response + self._error = error + + async def generate(self, system_prompt: str, user_prompt: str) -> str: + if self._error: + raise self._error + assert self._response is not None + return self._response + + +def _mock_llm(monkeypatch, *, response: str | None = None, error: Exception | None = None): + fake = _FakeLLM(response=response, error=error) + monkeypatch.setattr(chat_service, "get_llm_client", lambda: fake) + return fake + + +def _resume_payload() -> dict: + return { + "career_summary": "Web エンジニアとして5年の経験。", + "self_pr": "粘り強く課題解決に取り組みます。", + "experiences": [ + { + "company": "株式会社テスト", + "business_description": "受託開発", + "clients": [ + { + "name": "クライアントA", + "projects": [ + { + "name": "EC サイト構築", + "role": "バックエンド開発", + "description": "API 設計と実装を担当。", + "technology_stacks": [ + {"category": "language", "name": "Python"} + ], + "phases": ["詳細設計", "実装"], + } + ], + } + ], + } + ], + } + + +def _llm_json(field: str, value: str, message: str = "改善案です。") -> str: + return json.dumps( + {"message": message, "operations": [{"field": field, "value": value}]}, + ensure_ascii=False, + ) + + +def test_chat_career_summary_success(client: TestClient, monkeypatch) -> None: + """正常系: career_summary スコープで operations が返る。""" + _mock_llm(monkeypatch, response=_llm_json("career_summary", "改善された職務要約。")) + headers = auth_header(client, "agentuser") + + resp = client.post( + "/api/agent/chat", + json={ + "scope": "career_summary", + "prompt": "職務要約をより具体的にしてください", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["message"] == "改善案です。" + assert body["operations"] == [ + {"field": "career_summary", "value": "改善された職務要約。"} + ] + + +def test_chat_project_success(client: TestClient, monkeypatch) -> None: + """正常系: project スコープ + target 指定で operations が返る。""" + _mock_llm(monkeypatch, response=_llm_json("description", "改善されたプロジェクト詳細。")) + headers = auth_header(client, "agentuser") + + resp = client.post( + "/api/agent/chat", + json={ + "scope": "project", + "prompt": "成果がより伝わる詳細にしてください", + "resume": _resume_payload(), + "target": {"experience_index": 0, "client_index": 0, "project_index": 0}, + }, + headers=headers, + ) + assert resp.status_code == 200 + assert resp.json()["operations"][0]["field"] == "description" + + +def test_chat_requires_auth(client: TestClient) -> None: + """認可: 未認証は 401。""" + resp = client.post( + "/api/agent/chat", + json={ + "scope": "career_summary", + "prompt": "test", + "resume": _resume_payload(), + }, + ) + assert resp.status_code == 401 + + +def test_chat_project_scope_requires_target(client: TestClient) -> None: + """バリデーション: project スコープで target 欠落は 422(日本語メッセージ)。""" + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "project", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 422 + assert resp.json()["code"] == "VALIDATION_ERROR" + + +def test_chat_target_index_out_of_range(client: TestClient, monkeypatch) -> None: + """バリデーション: target インデックス範囲外は 422 + VALIDATION_ERROR。""" + _mock_llm(monkeypatch, response=_llm_json("description", "x")) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "project", + "prompt": "改善して", + "resume": _resume_payload(), + "target": {"experience_index": 0, "client_index": 0, "project_index": 9}, + }, + headers=headers, + ) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == "VALIDATION_ERROR" + assert "プロジェクトが見つかりません" in body["message"] + + +def test_chat_llm_failure_returns_502(client: TestClient, monkeypatch) -> None: + """失敗系: LLMError は 502 + AGENT_LLM_ERROR(日本語メッセージ)。""" + _mock_llm(monkeypatch, error=LLMError("timeout")) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "self_pr", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 502 + body = resp.json() + assert body["code"] == "AGENT_LLM_ERROR" + assert "AI の応答取得に失敗" in body["message"] + + +def test_chat_invalid_json_returns_502(client: TestClient, monkeypatch) -> None: + """失敗系: LLM が不正 JSON を返したら 502 + AGENT_PARSE_ERROR。""" + _mock_llm(monkeypatch, response="すみません、JSON では返せません。") + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "self_pr", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 502 + assert resp.json()["code"] == "AGENT_PARSE_ERROR" + + +def test_chat_discards_out_of_scope_operations(client: TestClient, monkeypatch) -> None: + """契約: スコープ外フィールドの operation は破棄され、message は返る。""" + response = json.dumps( + { + "message": "提案です。", + "operations": [ + {"field": "self_pr", "value": "スコープ外の提案"}, + {"field": "career_summary", "value": "スコープ内の提案"}, + ], + }, + ensure_ascii=False, + ) + _mock_llm(monkeypatch, response=response) + headers = auth_header(client, "agentuser") + resp = client.post( + "/api/agent/chat", + json={ + "scope": "career_summary", + "prompt": "改善して", + "resume": _resume_payload(), + }, + headers=headers, + ) + assert resp.status_code == 200 + ops = resp.json()["operations"] + assert len(ops) == 1 + assert ops[0]["field"] == "career_summary" + + +# --- ユニットテスト(service 層) --- + + +def test_parse_response_strips_code_fence() -> None: + """小型モデルが付けるコードフェンスを除去してパースできる。""" + raw = "```json\n" + _llm_json("self_pr", "提案") + "\n```" + result = _parse_response(raw, "self_pr") + assert result.operations[0].value == "提案" + + +def test_parse_response_discards_over_limit_value() -> None: + """文字数上限(role: 200)を超える operation は破棄される。""" + raw = json.dumps( + {"message": "m", "operations": [{"field": "role", "value": "あ" * 201}]}, + ensure_ascii=False, + ) + result = _parse_response(raw, "project") + assert result.operations == [] + + +def test_parse_response_invalid_schema_raises() -> None: + """operations の形式不正は AgentResponseParseError。""" + with pytest.raises(AgentResponseParseError): + _parse_response('{"message": 1, "operations": "x"}', "self_pr") + + +def test_run_agent_chat_target_not_found(monkeypatch) -> None: + """target 範囲外は LLM を呼ぶ前に AgentTargetNotFoundError。""" + from app.schemas.agent import AgentChatRequest + + called = AsyncMock() + monkeypatch.setattr(chat_service, "get_llm_client", lambda: called) + request = AgentChatRequest.model_validate( + { + "scope": "project", + "prompt": "改善して", + "resume": {"experiences": []}, + "target": {"experience_index": 0, "client_index": 0, "project_index": 0}, + } + ) + loop = asyncio.new_event_loop() + try: + with pytest.raises(AgentTargetNotFoundError): + loop.run_until_complete(chat_service.run_agent_chat(request)) + finally: + loop.close() + called.generate.assert_not_called() diff --git a/docker-compose.yml b/docker-compose.yml index 510fe8c9..d7cbdf7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,11 @@ services: INTERNAL_SECRET: ${INTERNAL_SECRET} CALLBACK_BASE_URL: ${CALLBACK_BASE_URL} TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS} + # DevForge Agent(ADR-0010)。ローカルは Ollama を既定とする + LLM_PROVIDER: ${LLM_PROVIDER:-ollama} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} + OLLAMA_MODEL: ${OLLAMA_MODEL} LOG_LEVEL: ${LOG_LEVEL} LOG_FORMAT: ${LOG_FORMAT} APP_VERSION: ${APP_VERSION} diff --git a/docs/adr/0008-remove-llm-to-rule-based-design.md b/docs/adr/0008-remove-llm-to-rule-based-design.md index 8fe27560..aa4571d5 100644 --- a/docs/adr/0008-remove-llm-to-rule-based-design.md +++ b/docs/adr/0008-remove-llm-to-rule-based-design.md @@ -2,7 +2,7 @@ ## ステータス -Accepted +Superseded by ADR-0010 ## コンテキスト @@ -47,3 +47,8 @@ LLM プロバイダ抽象化と関連資産を全て撤去し、ルールベー ## 関連リンク - [ADR-0004: LLM プロバイダ抽象化(Ollama/Vertex AI)の設計判断](0004-llm-provider-abstraction.md) +- [ADR-0010: DevForge Agent 機能の導入](0010-devforge-agent.md)(本 ADR を Superseded とし LLM を再導入) + +--- + +2026-06-11: DevForge Agent(対話型キャリア戦略支援)の導入に伴い LLM を再導入したため、本 ADR の「将来の移行条件」に従い [ADR-0010](0010-devforge-agent.md) で Superseded とした。 diff --git a/docs/adr/0010-devforge-agent.md b/docs/adr/0010-devforge-agent.md new file mode 100644 index 00000000..ae6a6686 --- /dev/null +++ b/docs/adr/0010-devforge-agent.md @@ -0,0 +1,151 @@ +# ADR-0010: DevForge Agent 機能の導入 + +## ステータス + +Accepted + +## コンテキスト + +DevForge は現在、手入力データをもとに職務経歴書(`Resume`)を生成する「経歴書ビルダー」に留まっている。本来の価値である「GitHub・ブログ分析データを活用したキャリア戦略支援」を実現するために、蓄積データをコンテキストとして持つ LLM Agent 機能を追加する。 + +この判断は ADR-0008 で「LLM をサービス内で使う可能性は低い」としてルールベース設計に一本化し LLM プロバイダ抽象化を撤去した方針を覆すものである。ADR-0008 の「将来の移行条件」に従い、本 ADR の起票をもって **ADR-0008 を `Superseded by ADR-0010` とする**(再導入時の手続きは ADR-0008 が規定済み)。 + +ただし今回再導入する LLM の用途は ADR-0004 / ADR-0008 時代の「キャリア分析・PDF からの AI 抽出(バックグラウンド非同期処理・ルールベースパイプラインの一部)」とは異なり、**ユーザー対話型のフォアグラウンド機能(チャット)**である。したがって ADR-0004 で積み残した「LLM 失敗を UI に伝達できない(`generate()` が失敗時に空文字を返す)」という既知のリスクを、本機能では設計段階で解消する(後述)。 + +### 現行スキーマの前提(実装との照合結果) + +`backend/app/schemas/resume.py` を確認した結果、Agent が扱う 3 スコープは現行スキーマ上で次の位置にある。これは「差分 operations のパス指定」設計に影響する。 + +| スコープ | 現行スキーマ上の位置 | 構造 | +|---|---|---| +| `career_summary` | `ResumeBase.career_summary`(`str`, `max_length=2000`, 必須) | トップレベルのフラットなフィールド | +| `self_pr` | `ResumeBase.self_pr`(`str`, `max_length=2000`, 必須) | トップレベルのフラットなフィールド | +| `project` | `Experience.clients[].projects[].description`(`str`, `max_length=4500`) | `experiences[] > clients[] > projects[]` の深いネスト | + +`career_summary` / `self_pr` は単一文字列フィールドだが、`project` は配列の深いネスト下にあり「どの experience のどの client のどの project か」をパスで特定する必要がある。差分 operations はこのパス指定を含む設計とする。 + +## 決定内容 + +### アーキテクチャ概要 + +``` +[フロント] +ユーザーがスコープを選択(必須) +↓ プロンプト入力 +POST /agent/chat +↓ +[FastAPI] +スコープデータ + GitHub/ブログサマリーを組み立て +↓ Claude API (Haiku 4.5) +差分 operations + message を返却 +↓ +[フロント] +resume state に差分適用(DB 更新なし) +↓ ユーザーが確認して「適用」 +既存の保存 API(PUT /resumes 系)を呼び出し +``` + +### LLM モデル + +- **採用**: Claude Haiku 4.5(モデル ID: `claude-haiku-4-5`) +- **料金**: $1.00 / 1M input トークン、$5.00 / 1M output トークン(2026-06 時点の正規料金) +- **理由**: 差分生成に特化した小さい JSON を返させる用途のため、重いモデルは不要。精度不足が判明した場合は Sonnet 4.6(`claude-sonnet-4-6`, $3.00 / $15.00)へ切り替える。 +- **ローカル開発**: Ollama(ローカル LLM)を使用し API コストを発生させない。ADR-0004 と同じ「ローカル=ローカル LLM / 本番=ホスト型 API」の dev/prod 分離思想を踏襲する。切り替えはプロバイダ抽象 1 箇所に閉じ込め、呼び出し側コードを変えない。 + +> 補足: 元の検討メモでは Haiku 4.5 の料金を $0.25 / $1.25 と記載していたが、これは旧 Haiku 3.x 世代の料金であり Haiku 4.5 の実料金ではない。正規料金 $1.00 / $5.00 に補正した。コスト試算(トークン量見積り・月額予測)はこの数値で行うこと。 + +### スコープ設計(Phase 1) + +Agent が対応するスコープは以下の 3 種類。スコープはフロントで**必須選択**とし、選択に応じて対象データ(および対応する operations の適用先パス)が自動セットされる。 + +| スコープ | 対象 | operations 適用先 | +|---|---|---| +| `project` | 選択中の案件(experience/client/project) | 該当 project の `description` ほか(`role` / `technology_stacks` / `phases`) | +| `career_summary` | 職務要約 | `ResumeBase.career_summary` | +| `self_pr` | 自己 PR | `ResumeBase.self_pr` | + +スコープ未選択での汎用モードは Phase 1 では提供しない(Phase 3 で検討)。 + +### DB を更新しない原則 + +Agent のレスポンス(差分 operations)はフロントの state にのみ反映する。ユーザーが内容を確認して「適用」を押した時点で初めて**既存の保存 API**(`backend/app/routers/resumes.py` の更新エンドポイント)を呼び出す。Agent エンドポイント自体は DB を書き換えない。これにより「AI の提案を確認せず保存してしまう」事故を防ぎ、既存のバリデーション(`Experience.validate_dates` / 各 `model_validator`)を保存時に再利用できる。 + +### LLM 失敗の UI 伝達(ADR-0004 積み残しの解消) + +ADR-0004 の `generate()` は失敗時に空文字を返す設計で、UI がエラーを検知できなかった。本機能は対話型のため、`POST /agent/chat` では LLM 呼び出しの失敗(タイムアウト / モデル未起動 / API エラー / JSON パース失敗)を**明示的に区別して HTTP エラー(日本語メッセージ)で返す**。エラーメッセージは `backend/app/messages.json` を正本とし、frontend は `AppErrorResponse.message` を表示する(`.claude/rules/frontend/messages.md` 準拠)。空文字フォールバックで握りつぶさない。 + +### セキュリティ・横断要件 + +- **認証ガード**: `POST /agent/chat` は `get_current_user` 依存を付与する(未認証アクセス不可)。 +- **Rate Limit**: 外部 API を叩く高コスト endpoint のため `slowapi` でレート制限を付ける(`.claude/rules/backend/auth-security.md`)。 +- **環境変数**: `ANTHROPIC_API_KEY` を新規追加する。`backend/app/core/env_keys.py` を正本に定数定義し、env_keys のコメントが規定する 5 箇所同期(env_keys / `infra/modules/cloud_run/main.tf` / `.github/workflows/ci.yml` / `docker-compose.yml` / `docs/api.md`)を行う。backend 内で `os.getenv("ANTHROPIC_API_KEY")` のリテラル参照は禁止。本番では Secret Manager 経由で注入する。 +- **入力バリデーション**: リクエスト body は `app/schemas/` の Pydantic モデルで型・制約を検証する(`Any` / `dict` 素通し禁止)。 + +## 実装フェーズ + +**Phase 1(初期リリース)** + +- `POST /agent/chat` エンドポイント実装(認証ガード + rate limit) +- スコープ 3 種(`project` / `career_summary` / `self_pr`)対応 +- 差分 operations のパス指定スキーマ設計 +- system prompt 設計・チューニング +- フロント: チャットウィジェット UI +- フロント: スコープ選択 → operations 適用ロジック(state プレビュー、DB 未更新) + +**Phase 2(拡張)** + +- experience 単位のスコープ追加 +- 会話履歴の保持(マルチターン対応) +- GitHub / ブログ分析との連携強化 + +**Phase 3(将来)** + +- スコープ未選択でも動く汎用モード +- モデルを Sonnet 4.6 へ切り替え検討 + +## 代替案 + +**resume_state 全体を返す(差分 operations ではなく)** +`Resume` のネスト構造(`experiences[] > clients[] > projects[] > {periods, team, technology_stacks, phases}`)が深く、Haiku では JSON 構造を崩すリスクがある。差分 operations に絞ることで精度とコストを両立する。採用しない。 + +**Node.js PDF 生成マイクロサービス** +2 コンテナ構成になりデプロイ・管理コストが増大するため却下。PDF 生成は既存の Python バックエンド(WeasyPrint)を継続使用する。 + +**pdfme カスタムテンプレート(今回の優先度外)** +DevForge の本質的価値は「蓄積データ × LLM による経歴書生成」であり、PDF レイアウトのカスタマイズは副次的機能と判断。Agent 機能が完成し GitHub・ブログ分析との連携が充実した段階で改めて検討する。 + +**LLM を再導入せずルールベースを維持(ADR-0008 の現状維持)** +対話的なキャリア戦略支援はルールベースでは表現力が不足する。本機能の中核価値が LLM による自由記述生成であるため、ADR-0008 を Superseded として LLM を再導入する。 + +## トレードオフ・既知のリスク + +- **LLM 再導入に伴う運用コスト**: API コスト(従量課金)・プロバイダ抽象・env・依存の再構築が必要。ADR-0008 で撤去した資産を ADR-0004 の設計を参考に再構築する(ただし対話型ゆえ失敗の握りつぶしは行わない)。 +- **差分 operations のパス整合**: フロントの resume state とバックエンドが返すパス(特に `project` の深いネスト)がズレると適用に失敗する。パス指定スキーマを BE/FE で同期させる仕組みが要る(OpenAPI 生成物経由が望ましい)。 +- **Haiku の構造化出力精度**: 小さい JSON でも構造崩れの可能性は残る。パース失敗時は明示エラーで返し、リトライ/モデル昇格(Sonnet 4.6)の判断材料とする。 +- **コスト見積りの前提**: 料金は Haiku 4.5 の正規値($1.00 / $5.00)で算定する。旧 $0.25 / $1.25 前提の試算は過小評価になるため使わない。 + +## 将来の移行条件 + +- **モデル昇格**: 差分生成の精度が Haiku で不足する場合、`claude-sonnet-4-6` へ切り替える。プロバイダ/モデル切り替えは抽象層 1 箇所で完結させる。 +- **再びの LLM 撤去**: 将来 LLM 利用を再び停止する場合は、本 ADR を `Superseded` とした上で新規 ADR を起票する(ADR-0008 と同じ手続き)。 +- **codegen 系統**: `POST /agent/chat` の追加・スキーマ変更は OpenAPI スペックに反映されるため、`make codegen-types` で `frontend/src/api/generated.ts` を再生成し同一 PR でコミットする(ADR-0007 / `codegen-drift`)。 + +## 影響範囲 + +| 対象 | 変更内容 | +|---|---| +| `backend/app/routers/` | `agent.py` 追加(`POST /agent/chat`、認証ガード + rate limit) | +| `backend/app/services/` | `agent_service.py` 追加(スコープデータ組み立て + LLM 呼び出し + 差分生成)。LLM プロバイダ抽象は ADR-0004 を参考に再構築 | +| `backend/app/schemas/` | Agent リクエスト/レスポンス(差分 operations のパス指定)スキーマ追加 | +| `backend/app/messages.json` | Agent 関連のエラーメッセージ(LLM 失敗・パース失敗)追加 | +| `backend/app/core/env_keys.py` ほか 4 箇所 | `ANTHROPIC_API_KEY` 追加(5 箇所同期) | +| `frontend/src/` | チャットウィジェットコンポーネント追加 | +| `frontend/src/api/` | `/agent/chat` クライアント追加 | +| `frontend/src/api/generated.ts` | OpenAPI 再生成(`make codegen-types`) | +| ローカル | Ollama セットアップ | + +## 関連リンク + +- [ADR-0004: LLM プロバイダ抽象化(Ollama/Vertex AI)の設計判断](0004-llm-provider-abstraction.md) +- [ADR-0008: LLM プロバイダ抽象化の撤去とルールベース設計への統一](0008-remove-llm-to-rule-based-design.md)(本 ADR で Superseded) +- [ADR-0007: OpenAPI → TypeScript codegen](0007-openapi-typescript-codegen.md) diff --git a/docs/api.md b/docs/api.md index 7c17bd3f..a1c37c04 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,6 +69,9 @@ REST API エンドポイント一覧と、バックエンド/フロントエ - `PATCH /api/notifications/{id}/read`: 個別既読 - `POST /api/notifications/read-all`: 全て既読 +### Agent(LLM チャット / ADR-0010) +- `POST /api/agent/chat`: 選択スコープ(`project` / `career_summary` / `self_pr`)の内容とプロンプトをもとに、職務経歴書への差分 operations を返す。DB は更新せず、適用はフロント側でユーザー確認後に既存保存 API を呼ぶ。rate limit 10/min + ### 内部 API(Cloud Tasks コールバック専用) - `POST /internal/tasks/{task_type}`: Cloud Tasks からのタスク実行リクエストを受け付ける。`TASK_RUNNER=cloud_tasks` の場合は `X-CloudTasks-QueueName` ヘッダで検証 @@ -125,6 +128,15 @@ REST API エンドポイント一覧と、バックエンド/フロントエ | `UPSTASH_REDIS_URL` | Upstash Redis REST URL(本番) | | `UPSTASH_REDIS_TOKEN` | Upstash Redis REST トークン | +### LLM(DevForge Agent / ADR-0010) + +| 変数 | 用途 | +|---|---| +| `LLM_PROVIDER` | LLM プロバイダ切り替え(`anthropic`: 本番 / `ollama`: ローカル既定) | +| `ANTHROPIC_API_KEY` | Anthropic API キー(本番は Secret Manager `anthropic-api-key` から注入) | +| `OLLAMA_BASE_URL` | ローカル Ollama のベース URL(既定: `http://localhost:11434`) | +| `OLLAMA_MODEL` | ローカル Ollama のモデル名(既定: `llama3.2`) | + ### 運用・ロギング | 変数 | 用途 | diff --git a/docs/development.md b/docs/development.md index ccd09bbd..52bc8596 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,6 +53,17 @@ make dev-down # 停止 DB 接続先は compose 内で `TURSO_DATABASE_URL=http://libsql:8080` に固定されている。 +#### Agent 機能(LLM)をローカルで試す場合 + +Ollama は compose に含めず**ホスト側で起動する**設計(ADR-0010)。macOS の Docker は GPU(Metal)を使えず、コンテナ内推論は大幅に遅くなるため。API コンテナは `OLLAMA_BASE_URL`(既定: `http://host.docker.internal:11434`)でホストの Ollama に接続する。 + +```bash +ollama serve # アプリ起動済みなら不要 +ollama pull llama3.2 # OLLAMA_MODEL の既定値 +``` + +Anthropic API で試す場合は `.env` に `LLM_PROVIDER=anthropic` と `ANTHROPIC_API_KEY` を設定して再起動する。 + #### フロントエンド単体起動(バックエンドは docker / 別途) ```bash diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index a32c5a0e..112e8d3c 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -7,6 +7,29 @@ * このファイルはその機械的ミラー。直接編集しても次回生成で上書きされる。 */ export interface paths { + "/api/agent/chat": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Agent Chat + * @description 選択スコープの内容とプロンプトをもとに、職務経歴書への差分 operations を返す。 + * + * レスポンスはフロントの state にのみ適用され、DB は更新しない。 + * ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 + */ + post: operations["agent_chat_api_agent_chat_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/blog/accounts": { parameters: { query?: never; @@ -694,6 +717,140 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * AgentChatRequest + * @description Agent チャットのリクエスト。スコープ選択は必須。 + */ + AgentChatRequest: { + /** Prompt */ + prompt: string; + resume: components["schemas"]["AgentResumeContext"]; + /** + * Scope + * @enum {string} + */ + scope: "project" | "career_summary" | "self_pr"; + target?: components["schemas"]["ProjectTarget"] | null; + }; + /** + * AgentChatResponse + * @description Agent チャットのレスポンス(AI の説明文 + 差分 operations)。 + */ + AgentChatResponse: { + /** Message */ + message: string; + /** Operations */ + operations?: components["schemas"]["AgentOperation"][]; + }; + /** + * AgentClientContext + * @description LLM コンテキスト用の取引先情報。 + */ + AgentClientContext: { + /** + * Name + * @default + */ + name: string; + /** Projects */ + projects?: components["schemas"]["AgentProjectContext"][]; + }; + /** + * AgentExperienceContext + * @description LLM コンテキスト用の在籍企業情報。 + */ + AgentExperienceContext: { + /** + * Business Description + * @default + */ + business_description: string; + /** Clients */ + clients?: components["schemas"]["AgentClientContext"][]; + /** + * Company + * @default + */ + company: string; + }; + /** + * AgentOperation + * @description resume state へ適用する差分(テキストフィールドの置換)。 + * + * フロントは選択済みスコープ(と target)に対応するフィールドへ value を反映する。 + * DB は更新せず、ユーザーが「適用」した時点で既存の保存 API を呼ぶ。 + */ + AgentOperation: { + /** + * Field + * @enum {string} + */ + field: "career_summary" | "self_pr" | "description" | "role"; + /** Value */ + value: string; + }; + /** + * AgentProjectContext + * @description LLM コンテキスト用のプロジェクト情報。 + */ + AgentProjectContext: { + /** + * Description + * @default + */ + description: string; + /** + * Name + * @default + */ + name: string; + /** Phases */ + phases?: string[]; + /** + * Role + * @default + */ + role: string; + /** Technology Stacks */ + technology_stacks?: components["schemas"]["AgentTechnologyStack"][]; + }; + /** + * AgentResumeContext + * @description LLM に渡す編集中の職務経歴書コンテキスト。 + * + * 保存契約(必須項目・日付検証)は適用しない。DB は参照せず、 + * フロントが編集中のフォーム内容をそのまま送る設計(DB を更新しない原則)。 + */ + AgentResumeContext: { + /** + * Career Summary + * @default + */ + career_summary: string; + /** Experiences */ + experiences?: components["schemas"]["AgentExperienceContext"][]; + /** + * Self Pr + * @default + */ + self_pr: string; + }; + /** + * AgentTechnologyStack + * @description LLM コンテキスト用の技術スタック(保存契約より緩い)。 + */ + AgentTechnologyStack: { + /** + * Category + * @default + */ + category: string; + /** + * Name + * @default + */ + name: string; + }; /** * BlogAccountCreate * @description ブログ連携アカウントの作成リクエスト。 @@ -1304,6 +1461,18 @@ export interface components { */ start_date: string; }; + /** + * ProjectTarget + * @description scope=project のとき対象プロジェクトを特定するインデックス。 + */ + ProjectTarget: { + /** Client Index */ + client_index: number; + /** Experience Index */ + experience_index: number; + /** Project Index */ + project_index: number; + }; /** * ProjectTeam * @description プロジェクト体制(全体人数 + 役割別内訳)。 @@ -1550,6 +1719,39 @@ export interface components { } export type $defs = Record; export interface operations { + agent_chat_api_agent_chat_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentChatRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentChatResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_accounts_api_blog_accounts_get: { parameters: { query?: never; diff --git a/frontend/src/constants/errorCodes.ts b/frontend/src/constants/errorCodes.ts index fcdb8f59..746f268e 100644 --- a/frontend/src/constants/errorCodes.ts +++ b/frontend/src/constants/errorCodes.ts @@ -27,6 +27,9 @@ export const ERROR_CODES = [ "VALIDATION_ERROR", // 外部 API "QIITA_RATE_LIMITED", + // Agent(LLM チャット / ADR-0010) + "AGENT_LLM_ERROR", + "AGENT_PARSE_ERROR", // アプリケーション全体 "RATE_LIMITED", // サーバー diff --git a/frontend/src/constants/errorMessages.ts b/frontend/src/constants/errorMessages.ts index b8490fa1..bf107d59 100644 --- a/frontend/src/constants/errorMessages.ts +++ b/frontend/src/constants/errorMessages.ts @@ -39,6 +39,14 @@ export const ERROR_CONFIG: Record< message: "Qiita API の制限に達しました", recovery: { label: "1時間後に再試行", fn: null }, }, + AGENT_LLM_ERROR: { + message: "AI の応答取得に失敗しました", + recovery: { label: "少し待って再試行", fn: null }, + }, + AGENT_PARSE_ERROR: { + message: "AI の応答を解釈できませんでした", + recovery: { label: "もう一度試す", fn: null }, + }, RATE_LIMITED: { message: "リクエストが集中しています", recovery: { label: "少し待って再試行", fn: null }, diff --git a/infra/modules/cloud_run/main.tf b/infra/modules/cloud_run/main.tf index 1810c857..d4591171 100644 --- a/infra/modules/cloud_run/main.tf +++ b/infra/modules/cloud_run/main.tf @@ -9,6 +9,8 @@ locals { "jwt-public-key", "internal-secret", "turso-auth-token", + # DevForge Agent(ADR-0010)の Anthropic API キー + "anthropic-api-key", # 棚卸し TODO: "field-encryption-key"(FIELD_ENCRYPTION_KEY / Fernet 鍵)は # このリストから除外し対応する Secret Manager シークレットを削除すること。 # 削除前に全環境(dev/stg/prod)の Cloud Run 設定から環境変数を外すこと。 @@ -20,6 +22,7 @@ locals { JWT_PUBLIC_KEY = "jwt-public-key" INTERNAL_SECRET = "internal-secret" TURSO_AUTH_TOKEN = "turso-auth-token" + ANTHROPIC_API_KEY = "anthropic-api-key" } github_secret_env = var.enable_github_oauth ? { GITHUB_CLIENT_ID = "github-client-id" @@ -130,6 +133,11 @@ resource "google_cloud_run_v2_service" "app" { name = "LOG_LEVEL" value = "INFO" } + env { + # DevForge Agent(ADR-0010)。本番は Anthropic API(Haiku 4.5)を使用 + name = "LLM_PROVIDER" + value = "anthropic" + } dynamic "env" { for_each = local.required_secret_env From 885d24235c0943b7fa1d7189a3807d703b78e1c9 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 11 Jun 2026 11:44:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20backend/.env.example=20=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 backend/.env.example diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 32c54709..00000000 --- a/backend/.env.example +++ /dev/null @@ -1,30 +0,0 @@ -# ---- Turso (libSQL) ---- -# ローカル開発時は `turso dev --db-file ./backend/local.sqlite` で起動した HTTP サーバーに接続する -# 本番は libsql://.turso.io(authToken は Secret Manager 経由で注入) -TURSO_DATABASE_URL= -TURSO_AUTH_TOKEN= - -CORS_ORIGINS= -COOKIE_SECURE= -COOKIE_SAMESITE= -ADMIN_TOKEN= - -# DBフィールド暗号化キー(生成方法: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") -FIELD_ENCRYPTION_KEY= - -# ---- GitHub OAuth ---- -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= - -# ---- バックグラウンドタスク設定 ---- -TASK_RUNNER= -# Cloud Tasks 用(TASK_RUNNER=cloud_tasks の場合のみ必要) -# GCP_PROJECT_ID= -# CLOUD_TASKS_QUEUE=devforge-ai-tasks -# CLOUD_TASKS_LOCATION=asia-northeast1 -# CLOUD_TASKS_SERVICE_URL=https:// -# CLOUD_TASKS_SERVICE_ACCOUNT= - -# ---- Upstash Redis(GitHub分析進捗機能)---- -UPSTASH_REDIS_URL= -UPSTASH_REDIS_TOKEN= From 3e4cbfdaa99e05b601ce3f551b3bb3c71f8b6d55 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Thu, 11 Jun 2026 13:52:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(agent):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=EF=BC=88fail=20fa?= =?UTF-8?q?st=E3=83=BBJSON=20=E3=83=91=E3=83=BC=E3=82=B9=E3=83=BB=E3=83=89?= =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - factory: 未対応 LLM_PROVIDER をフォールバックせず LLMError で fail fast(テスト追加) - ollama_client: 応答の JSON パース失敗を LLMError にラップ - docker-compose: OLLAMA_BASE_URL / OLLAMA_MODEL に既定値を付与(未設定時の空文字化を防止) - ADR-0010: 影響範囲の agent_service.py を実装名 agent/chat_service.py に修正、コードフェンスに言語指定 - architecture.md: LLM 撤去の記述を intelligence モジュール限定の表現に修正 Co-Authored-By: Claude Opus 4.8 --- .claude/rules/backend/architecture.md | 2 +- backend/app/services/agent/llm/factory.py | 10 +++++++--- backend/app/services/agent/llm/ollama_client.py | 7 ++++++- backend/tests/test_agent.py | 9 +++++++++ docker-compose.yml | 4 ++-- docs/adr/0010-devforge-agent.md | 4 ++-- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index fb34a3c9..71eda983 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -96,4 +96,4 @@ backend/app/ - **routers/auth/ と routers/blog/**: いずれもパッケージ化されている。auth は `endpoints` / `github_auth` / `oauth_flow` / `token_manager`、blog は `accounts` / `score` / `sync` に責務分割 - **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う。現在登録されているタスクは `GITHUB_LINK` の 1 種類のみだが、`AsyncTaskCacheService` / `TaskHandler` は新規タスク追加の拡張ポイントとして汎用化してある(インライン化しない) -- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路。LLM は使わず決定論的(ルールベース)に処理する(LLM プロバイダ抽象化は ADR-0008 で撤去済み) +- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路。LLM は使わず決定論的(ルールベース)に処理する(intelligence モジュールは LLM を使わない。LLM は services/agent/ のみ / ADR-0010) diff --git a/backend/app/services/agent/llm/factory.py b/backend/app/services/agent/llm/factory.py index e1c4b92f..7812b00c 100644 --- a/backend/app/services/agent/llm/factory.py +++ b/backend/app/services/agent/llm/factory.py @@ -1,7 +1,7 @@ """LLM プロバイダの切り替え(LLM_PROVIDER 環境変数で分岐)。""" from ....core import settings -from .base import LLMClient +from .base import LLMClient, LLMError def get_llm_client() -> LLMClient: @@ -15,6 +15,10 @@ def get_llm_client() -> LLMClient: from .anthropic_client import AnthropicClient return AnthropicClient() - from .ollama_client import OllamaClient + if provider == "ollama": + from .ollama_client import OllamaClient - return OllamaClient() + return OllamaClient() + # 設定ミス("openai" 等)をフォールバックで隠さず fail fast にする。 + # LLMError は router で 502 + 日本語メッセージにマッピングされる + raise LLMError(f"未対応の LLM_PROVIDER です: {provider}") diff --git a/backend/app/services/agent/llm/ollama_client.py b/backend/app/services/agent/llm/ollama_client.py index 2de9063d..75b19016 100644 --- a/backend/app/services/agent/llm/ollama_client.py +++ b/backend/app/services/agent/llm/ollama_client.py @@ -1,5 +1,6 @@ """Ollama クライアント(ローカル開発用。REST 直呼び)。""" +import json import logging import httpx @@ -43,7 +44,11 @@ async def generate(self, system_prompt: str, user_prompt: str) -> str: logger.warning("Ollama API 呼び出しに失敗: %s", type(exc).__name__) raise LLMError(f"Ollama API error: {type(exc).__name__}") from exc - text = response.json().get("message", {}).get("content", "") + try: + text = response.json().get("message", {}).get("content", "") + except json.JSONDecodeError as exc: + logger.warning("Ollama 応答の JSON パースに失敗: %s", type(exc).__name__) + raise LLMError("Ollama 応答が JSON ではありません") from exc if not text: raise LLMError("Ollama から空の応答が返されました") return text diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index 59aa6004..aa6e7988 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -258,6 +258,15 @@ def test_parse_response_invalid_schema_raises() -> None: _parse_response('{"message": 1, "operations": "x"}', "self_pr") +def test_factory_rejects_unknown_provider(monkeypatch) -> None: + """設定ミス(未対応プロバイダ)は LLMError で fail fast。""" + from app.services.agent.llm import factory + + monkeypatch.setenv("LLM_PROVIDER", "openai") + with pytest.raises(LLMError, match="LLM_PROVIDER"): + factory.get_llm_client() + + def test_run_agent_chat_target_not_found(monkeypatch) -> None: """target 範囲外は LLM を呼ぶ前に AgentTargetNotFoundError。""" from app.schemas.agent import AgentChatRequest diff --git a/docker-compose.yml b/docker-compose.yml index d7cbdf7a..ed94681a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,8 +37,8 @@ services: # DevForge Agent(ADR-0010)。ローカルは Ollama を既定とする LLM_PROVIDER: ${LLM_PROVIDER:-ollama} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} - OLLAMA_MODEL: ${OLLAMA_MODEL} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-llama3.2} LOG_LEVEL: ${LOG_LEVEL} LOG_FORMAT: ${LOG_FORMAT} APP_VERSION: ${APP_VERSION} diff --git a/docs/adr/0010-devforge-agent.md b/docs/adr/0010-devforge-agent.md index ae6a6686..1fd0ecd9 100644 --- a/docs/adr/0010-devforge-agent.md +++ b/docs/adr/0010-devforge-agent.md @@ -28,7 +28,7 @@ DevForge は現在、手入力データをもとに職務経歴書(`Resume`) ### アーキテクチャ概要 -``` +```text [フロント] ユーザーがスコープを選択(必須) ↓ プロンプト入力 @@ -135,7 +135,7 @@ DevForge の本質的価値は「蓄積データ × LLM による経歴書生成 | 対象 | 変更内容 | |---|---| | `backend/app/routers/` | `agent.py` 追加(`POST /agent/chat`、認証ガード + rate limit) | -| `backend/app/services/` | `agent_service.py` 追加(スコープデータ組み立て + LLM 呼び出し + 差分生成)。LLM プロバイダ抽象は ADR-0004 を参考に再構築 | +| `backend/app/services/` | `agent/chat_service.py` 追加(スコープデータ組み立て + LLM 呼び出し + 差分生成)。LLM プロバイダ抽象は ADR-0004 を参考に再構築 | | `backend/app/schemas/` | Agent リクエスト/レスポンス(差分 operations のパス指定)スキーマ追加 | | `backend/app/messages.json` | Agent 関連のエラーメッセージ(LLM 失敗・パース失敗)追加 | | `backend/app/core/env_keys.py` ほか 4 箇所 | `ANTHROPIC_API_KEY` 追加(5 箇所同期) |