diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 9bae8aaa..c3334741 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -58,7 +58,7 @@ backend/app/ │ │ ├── scorer.py │ │ ├── sync_service.py │ │ └── tech_keywords.json -│ ├── intelligence/ # GitHub 連携パイプラインと LLM 連携 +│ ├── intelligence/ # GitHub 連携パイプライン(決定論的・ルールベース) │ │ ├── pipeline.py │ │ ├── github_collector.py │ │ ├── github_link_service.py @@ -67,14 +67,7 @@ backend/app/ │ │ │ └── repo_analyzer.py │ │ ├── response_mapper.py │ │ ├── skill_extractor.py -│ │ ├── skill_taxonomy/ # スキル分類(言語・トピック・キーワードマップ) -│ │ └── llm/ # LLM クライアント実装(休眠インフラ・温存) -│ │ ├── base.py -│ │ ├── factory.py -│ │ ├── ollama_client.py -│ │ └── vertex_client.py -│ ├── llm/ # LLM 入出力サニタイザ(intelligence/llm とは別・休眠) -│ │ └── sanitizer.py +│ │ └── skill_taxonomy/ # スキル分類(言語・トピック・キーワードマップ) │ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル) │ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ) │ │ ├── exceptions.py # RetryableError / NonRetryableError @@ -92,15 +85,12 @@ backend/app/ │ └── shared/ # ドメイン横断の service util │ ├── resume_format.py # レジュメ整形の共通ロジック(md/pdf 共有) │ └── sort_utils.py -├── prompts/ # LLM プロンプトテンプレート(MD) ├── fonts/ # PDF 生成用フォント └── utils/ - └── prompt_loader.py # プロンプトファイルローダ ``` ## 主要モジュールのポイント - **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 経路 -- **services/llm/ と services/intelligence/llm/**: 別物。前者は入出力サニタイザ等の横断 util、後者は LLM プロバイダクライアント(Ollama / Vertex AI を `LLMClient` 抽象で切替)の実装。**両者とも現状どの live エンドポイントからも到達しない休眠インフラ**で、将来用に温存している。デッドコードとして削除しないこと +- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路。LLM は使わず決定論的(ルールベース)に処理する(LLM プロバイダ抽象化は ADR-0008 で撤去済み) diff --git a/.claude/rules/backend/auth-security.md b/.claude/rules/backend/auth-security.md index 09079aef..87383451 100644 --- a/.claude/rules/backend/auth-security.md +++ b/.claude/rules/backend/auth-security.md @@ -21,7 +21,7 @@ paths: ## セキュリティ -- 外部 API 呼び出しや LLM 実行のような**高コスト endpoint**には rate limit を付けること(`slowapi`) +- 外部 API 呼び出しのような**高コスト endpoint**には rate limit を付けること(`slowapi`) - OAuth 開始 URL は backend で発行し、許可された `CORS_ORIGINS` のみをリダイレクト先に使うこと - Cookie 認証を使う変更では `Secure` / `SameSite` / CORS の整合を必ず確認すること - Cloudflare Pages → Cloud Run 間は `INTERNAL_SECRET` ヘッダで認証する(local 環境では省略可) diff --git a/.claude/rules/backend/llm.md b/.claude/rules/backend/llm.md deleted file mode 100644 index 79b703fb..00000000 --- a/.claude/rules/backend/llm.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -paths: - - backend/app/services/intelligence/** ---- - -# LLM 統合 - -- `LLM_PROVIDER` 環境変数で `ollama`(デフォルト)/ `vertex` を切替 -- `LLMClient` 抽象基底クラスの `generate(system_prompt, user_prompt)` インターフェースに従うこと -- Vertex AI は `google-genai` SDK(`genai.Client(vertexai=True)` + `client.aio.models.generate_content()`)を使用 -- 非同期 API は `client.aio.models.generate_content()` を使う(`generate_content_async` ではない) diff --git a/.claude/rules/backend/test.md b/.claude/rules/backend/test.md index eaea6e06..a3585cae 100644 --- a/.claude/rules/backend/test.md +++ b/.claude/rules/backend/test.md @@ -37,7 +37,7 @@ nix develop --command bash -c "cd backend && .venv/bin/python -m pytest tests/te - 新規サービス関数 → 主要分岐ごとに 1 ケース - タスクハンドラ → 成功 / `NonRetryableError` / `RetryableError` の 3 パス 3. **失敗パスを明示的に検証している**: 例外を `pytest.raises(ExpectedError)` で必ず assert する。silent return を許容するテスト(過去の `test_no_cache_returns_early` のようなもの)は書かない -4. **モックは最小限**: DB はモックしない(実 SQLite セッションを使う)。外部サービス(GitHub API / LLM / Cloud Tasks / Redis)はモックする +4. **モックは最小限**: DB はモックしない(実 SQLite セッションを使う)。外部サービス(GitHub API / Cloud Tasks / Redis)はモックする 5. **lint が pass**: `make lint-backend` も同時に通ること ## アンチパターン diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 0373f954..8ea388bd 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -43,7 +43,6 @@ backend 内で文字列リテラル `os.getenv("XXX")` を使うことは禁止 - **Pydantic バリデーション必須**: API エンドポイントへの入力はすべて `app/schemas/` の Pydantic モデルで型・制約を検証する。`Any` 型や `dict` 型の素通しは避ける - **SQL インジェクション防止**: SQLAlchemy ORM / Core のパラメータバインドを使う。文字列連結でクエリを組み立てることは禁止 -- **LLM プロンプトへのユーザー入力**: ユーザー由来の文字列を LLM プロンプトに埋め込む場合は `backend/app/services/llm/sanitizer.py` を通す ### Frontend @@ -125,6 +124,6 @@ AI エージェントがコードを変更した後に確認する項目: - [ ] `dangerouslySetInnerHTML` / `innerHTML` の新規使用がないか - [ ] ログに個人情報・認証情報が出力されないか - [ ] 新規エンドポイントに認証ガード(`get_current_user` 依存)が付いているか -- [ ] 高コスト処理(外部 API 呼び出し・LLM 実行)に rate limit があるか(`slowapi`) +- [ ] 高コスト処理(外部 API 呼び出し等)に rate limit があるか(`slowapi`) - [ ] `target="_blank"` に `rel="noopener noreferrer"` が付いているか - [ ] 新規 IAM ロール付与に最小権限の原則を守っているか diff --git a/.claude/skills/BE_refacter/SKILL.md b/.claude/skills/BE_refacter/SKILL.md index 34a77e07..8a8fa97a 100644 --- a/.claude/skills/BE_refacter/SKILL.md +++ b/.claude/skills/BE_refacter/SKILL.md @@ -13,7 +13,6 @@ description: Use when reviewing or planning refactors for the DevForge FastAPI b - `.claude/rules/backend/database.md` - `.claude/rules/backend/auth-security.md` - `.claude/rules/common/duplication.md`(DRY / 重複検知ポリシー) -- LLM やブログ AI 分析を含む場合だけ `.claude/rules/backend/llm.md` - `report/dupe/jscpd-report.json` が存在すれば最新を読み込み、backend に該当する clone を抽出して Duplication Findings の素材にする ## 対象 @@ -62,7 +61,7 @@ description: Use when reviewing or planning refactors for the DevForge FastAPI b - 大きいファイルを洗う: `rg --files backend/app backend/tests | xargs wc -l | sort -nr | head -n 30` - まず以下の境界で構造を把握する - router: HTTP 入出力、認証、rate limit、エラー変換 - - service: ビジネスロジック、外部 API、LLM、PDF/Markdown 生成 + - service: ビジネスロジック、外部 API、PDF/Markdown 生成 - repository: 永続化と問い合わせ - schema/model: API 契約、DB 契約 - tests: ルータ、サービス、純粋関数、外部依存のモック @@ -105,7 +104,7 @@ description: Use when reviewing or planning refactors for the DevForge FastAPI b - 純粋関数の分岐、スコア計算、日付処理、マッピング - router の認証、認可、validation、HTTP status、エラーメッセージ変換 - repository の upsert、一意制約、削除 cascade、ユーザー境界 -- 外部 API や LLM の timeout / unavailable / partial failure +- 外部 API の timeout / unavailable / partial failure - マイグレーション後の不変条件 - セキュリティ設定: cookie, CSRF, GitHub OAuth state, rate limit @@ -118,7 +117,7 @@ description: Use when reviewing or planning refactors for the DevForge FastAPI b - **本質的重複**(抽出すべき): ドメインロジック / バリデーション / スコア計算 / エラーマッピング / API パスや env 名リテラル / DTO 変換ロジック - **偶発的重複**(抽出しない): SQLAlchemy の `created_at` / `updated_at` 定義などの boilerplate、Pydantic schema の field 列、pytest fixture の minimal scaffolding、import 文の塊 -- **意味的重複**(jscpd では拾えない): 変数名やシグネチャは違うが「同じ判断・同じ整形・同じ I/O パターン」を行うコード。grep + 目視で別途探す。例: 似た Cloud Tasks エンキューロジック、似た LLM 呼び出しエラーハンドリング、似たエラー → HTTPException 変換 +- **意味的重複**(jscpd では拾えない): 変数名やシグネチャは違うが「同じ判断・同じ整形・同じ I/O パターン」を行うコード。grep + 目視で別途探す。例: 似た Cloud Tasks エンキューロジック、似たエラー → HTTPException 変換 本質的重複は「3 回目で抽出(Rule of Three)」を守る。2 箇所だけの重複は **記録するが、抽出は次の重複出現時まで保留**。 diff --git a/.claude/skills/SEC_apply/SKILL.md b/.claude/skills/SEC_apply/SKILL.md index 738addb6..0c138f41 100644 --- a/.claude/skills/SEC_apply/SKILL.md +++ b/.claude/skills/SEC_apply/SKILL.md @@ -93,7 +93,7 @@ Missing Exploit Tests: 4 - 不審な新規依存(typosquatting / dependency confusion / postinstall)は導入を止め、代替を提案。 8. **Missing Exploit Tests(攻撃者視点の回帰テスト)**: - レポートの「Missing Exploit Tests」と、今回直した設計穴を **攻撃が失敗することを assert** するテストで固定する。 - - `.claude/rules/backend/test.md` 準拠: DB はモックせず実 SQLite セッション、外部 API(GitHub / LLM / Cloud Tasks / Redis)はモック。失敗パスは `pytest.raises` か HTTP status assert で必ず明示検証(silent return を許容しない)。 + - `.claude/rules/backend/test.md` 準拠: DB はモックせず実 SQLite セッション、外部 API(GitHub / Cloud Tasks / Redis)はモック。失敗パスは `pytest.raises` か HTTP status assert で必ず明示検証(silent return を許容しない)。 - テスト名に守る仕様を書く(例: `test_他人のresumeはget_404` / `test_internal_secret欠落は拒否` / `test_state不一致のoauth_callbackは拒否`)。 - 配置先は既存方針: 認可/エンドポイントは `tests/test_.py`、認証は `tests/test_auth.py` / `tests/test_oauth_flow.py`、タスクは `tests/test_worker_*.py`。 diff --git a/.claude/skills/SEC_review/SKILL.md b/.claude/skills/SEC_review/SKILL.md index b707952f..e0b92235 100644 --- a/.claude/skills/SEC_review/SKILL.md +++ b/.claude/skills/SEC_review/SKILL.md @@ -77,7 +77,7 @@ description: Use when running a security review / vulnerability check against th - **OAuth フローの設計**: GitHub OAuth の `state` が backend Cookie で検証されているか(frontend のみ検証は不可)、`redirect_uri` が許可リスト内に固定されているか(オープンリダイレクト防止)。 - **トークンライフサイクル**: アクセス/リフレッシュトークンの失効・ローテーション、ログアウト時の Cookie 破棄、リフレッシュトークン再利用検知の有無。 - **マスアサインメント**: Pydantic スキーマが更新系で「ユーザーが書き換えてはいけないフィールド」(`user_id` / `role` / `is_admin` 相当 / タイムスタンプ)まで受け付けていないか。入力スキーマと DB モデルのフィールド差を確認。 -- **ビジネスロジック濫用**: rate limit のない高コスト経路(LLM / 外部 API / PDF 生成)を繰り返し叩くコスト増幅、冪等性のない副作用の二重実行。 +- **ビジネスロジック濫用**: rate limit のない高コスト経路(外部 API / PDF 生成)を繰り返し叩くコスト増幅、冪等性のない副作用の二重実行。 - **エラー / 例外からの情報漏洩**: スタックトレースや内部パス・SQL をユーザー向けレスポンスに返していないか(`detail` に生例外を載せていないか)。 - **暗号設計**: `FIELD_ENCRYPTION_KEY`(Fernet)で暗号化すべき機微フィールドが平文保存されていないか。鍵の取り違え・固定 IV 等。 @@ -113,7 +113,7 @@ description: Use when running a security review / vulnerability check against th ### 5. LLM プロンプトのサニタイズ -- ユーザー由来文字列を `services/llm/sanitizer.py` を通さずプロンプトに埋め込んでいないか: `rg -n 'prompt|messages=' backend/app/services` 周辺を目視 +- 本プロジェクトは LLM 連携を廃止済み(ADR-0008)。現状この観点は該当なし。将来 LLM 連携を再導入する場合のみ、ユーザー由来文字列をプロンプトに埋め込む際のサニタイズ要否を検討する ### 6. Frontend XSS @@ -132,7 +132,7 @@ description: Use when running a security review / vulnerability check against th ### 9. Rate limit -- 高コスト処理(外部 API / LLM 実行)に `slowapi` の `@limiter.limit` が付いているか +- 高コスト処理(外部 API 呼び出し等)に `slowapi` の `@limiter.limit` が付いているか ### 10. ファイルアップロード diff --git a/AGENTS.md b/AGENTS.md index f510582a..7d6c20f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,6 @@ Claude Code では対象パス編集時に自動ロードされる領域別ル - [.claude/rules/backend/python.md](./.claude/rules/backend/python.md) — Python コーディング規約 - [.claude/rules/backend/database.md](./.claude/rules/backend/database.md) — DB / マイグレーション - [.claude/rules/backend/auth-security.md](./.claude/rules/backend/auth-security.md) — 認証・認可 -- [.claude/rules/backend/llm.md](./.claude/rules/backend/llm.md) — LLM 連携 - [.claude/rules/backend/test.md](./.claude/rules/backend/test.md) — テスト方針 ### frontend(`frontend/` を触るとき) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cb5685b..31dafd09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,7 @@ docs/adr/XXXX-kebab-case-title.md | [ADR-0001](docs/adr/0001-sqlite-gcs-backup.md) | SQLite + GCS バックアップ方式の採用 | Accepted | | [ADR-0002](docs/adr/0002-jwt-cookie-auth.md) | JWT + Cookie 認証方式の採用 | Accepted | | [ADR-0003](docs/adr/0003-redux-toolkit-persist.md) | Redux Toolkit + redux-persist の採用 | Accepted | -| [ADR-0004](docs/adr/0004-llm-provider-abstraction.md) | LLM プロバイダ抽象化(Ollama/Vertex AI) | Accepted | +| [ADR-0004](docs/adr/0004-llm-provider-abstraction.md) | LLM プロバイダ抽象化(Ollama/Vertex AI) | Superseded by ADR-0008 | | [ADR-0005](docs/adr/0005-cloudrun-single-instance.md) | Cloud Run single instance 構成の採用 | Accepted | | [ADR-0006](docs/adr/0006-tanstack-query.md) | TanStack Query 導入検討 | Proposed | +| [ADR-0008](docs/adr/0008-remove-llm-to-rule-based-design.md) | LLM プロバイダ抽象化の撤去とルールベース設計への統一 | Accepted | diff --git a/Makefile b/Makefile index ddcb5de5..4290656a 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ help: @echo " generate-keys JWT RSA 鍵ペアを生成" @echo "" @echo "ローカル開発" - @echo " dev docker-compose で API + Ollama を起動" + @echo " dev docker-compose で API を起動" @echo " dev-build 再ビルドして起動" @echo " dev-down docker-compose を停止" @echo " dev-frontend Frontend 開発サーバーを起動 (Vite / localhost:5173)" diff --git a/README.md b/README.md index 27aa6bca..29cdf6f2 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,9 @@ GitHub活動分析、ブログ連携による発信力を集計 - **Zenn** / **note** のアカウント連携・記事同期 - 記事メトリクス(タイトル、URL、公開日、いいね数、タグ)の一覧管理 - **ブログスコアリング**: 投稿頻度・反応数・技術記事比率等をもとにスコアを算出 -- AIによるブログ活動要約(バックグラウンド非同期、ステータスポーリング方式) - -### AIキャリアパス分析 -- 職務経歴書データをもとに、LLMが希望ポジションへのキャリアパスを生成 -- 分析履歴を複数バージョン保持・管理 -- バックグラウンド非同期処理(202 Accepted → ステータスポーリング方式) ### 通知 -- GitHub連携・ブログ要約・キャリア分析などのバックグラウンドタスクの成功/失敗をサイドバーの通知ベルで通知 +- GitHub連携などのバックグラウンドタスクの成功/失敗をサイドバーの通知ベルで通知 - 未読バッジ表示(30秒ポーリング)・ドロップダウンパネルで一覧表示 - 「全て既読」ボタン・パネル外クリックで閉じる @@ -53,7 +47,6 @@ GitHub活動分析、ブログ連携による発信力を集計 | 認証 | JWT Cookie (python-jose), bcrypt, GitHub OAuth | | 暗号化 | Fernet(フィールド暗号化), bcrypt(パスワード) | | PDF出力 | WeasyPrint(職務経歴書), ReportLab(分析レポート補助) | -| LLM | Ollama / Vertex AI(設定で切替、任意) | | インフラ | GCP (Cloud Run, Artifact Registry, Secret Manager), Turso, Cloudflare Pages | | IaC | OpenTofu(モジュール構成、マルチ環境) | | CI/CD | GitHub Actions | diff --git a/backend/.env.example b/backend/.env.example index 67875ec7..32c54709 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,15 +16,6 @@ FIELD_ENCRYPTION_KEY= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# ---- LLM 設定 ---- -LLM_PROVIDER= -OLLAMA_BASE_URL= -OLLAMA_MODEL= -OLLAMA_TIMEOUT= -VERTEX_PROJECT_ID= -VERTEX_LOCATION= -VERTEX_MODEL= - # ---- バックグラウンドタスク設定 ---- TASK_RUNNER= # Cloud Tasks 用(TASK_RUNNER=cloud_tasks の場合のみ必要) diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index d323aa0a..ab485841 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -64,20 +64,6 @@ APP_VERSION = "APP_VERSION" ENVIRONMENT = "ENVIRONMENT" -# --- LLM --- - -LLM_PROVIDER = "LLM_PROVIDER" -VERTEX_PROJECT_ID = "VERTEX_PROJECT_ID" -VERTEX_LOCATION = "VERTEX_LOCATION" -VERTEX_MODEL = "VERTEX_MODEL" -# Ollama プロバイダ(休眠インフラ・温存)。ローカル開発では docker-compose.yml の -# environment ブロックで注入する。本番(Cloud Run)には注入していない。 -# なお `OLLAMA_KEEP_ALIVE`(docker-compose.yml の ollama サービス側)は -# Ollama サーバ自身の設定であり backend は参照しないため、ここには定義しない。 -OLLAMA_BASE_URL = "OLLAMA_BASE_URL" -OLLAMA_MODEL = "OLLAMA_MODEL" -OLLAMA_TIMEOUT = "OLLAMA_TIMEOUT" - # --- 非同期タスク(Cloud Tasks / Local BackgroundTasks) --- TASK_RUNNER = "TASK_RUNNER" diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index bc182bef..37f20b2b 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -26,9 +26,6 @@ class ErrorCode(str, Enum): # GitHub GITHUB_RATE_LIMITED = "GITHUB_RATE_LIMITED" GITHUB_USER_NOT_FOUND = "GITHUB_USER_NOT_FOUND" - # LLM - LLM_TIMEOUT = "LLM_TIMEOUT" - LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # バリデーション VALIDATION_ERROR = "VALIDATION_ERROR" # 外部 API @@ -109,10 +106,6 @@ def infer_error_code(status_code: int, detail: Any = None) -> ErrorCode: if "GitHubユーザーが見つかりません" in message: return ErrorCode.GITHUB_USER_NOT_FOUND - if "タイムアウト" in message: - return ErrorCode.LLM_TIMEOUT - if ("LLM" in message or "AI " in message or "AI分析" in message) and "利用できません" in message: - return ErrorCode.LLM_UNAVAILABLE if status_code == 401: return ErrorCode.AUTH_REQUIRED diff --git a/backend/app/core/logging_utils.py b/backend/app/core/logging_utils.py index 726cbf00..1b6610c9 100644 --- a/backend/app/core/logging_utils.py +++ b/backend/app/core/logging_utils.py @@ -15,7 +15,6 @@ duration_ms : int - 処理時間(ミリ秒) operation : str - 計測対象の操作名(metrics.py が付与) platform : str - "qiita" | "zenn" | "note" 等 - model : str - "vertex" | "ollama" error_type : str - 例外クラス名 http_status : int - HTTP ステータスコード error_id : str - エラー追跡 ID diff --git a/backend/app/core/metrics.py b/backend/app/core/metrics.py index 2e576eda..679132f3 100644 --- a/backend/app/core/metrics.py +++ b/backend/app/core/metrics.py @@ -6,7 +6,7 @@ - ``measure_block`` : 非同期コンテキストマネージャ 出力ログ例 (JSON フォーマット時): - {"severity": "INFO", "message": "performance", "operation": "llm.summarize", + {"severity": "INFO", "message": "performance", "operation": "github_collector.fetch", "duration_ms": 3241, "status": "success", "request_id": "550e8400-..."} エラー時は status="error", error_type="TimeoutError" を付与して re-raise する。 @@ -55,8 +55,8 @@ def measure_time_async(operation: str) -> Callable[[F], F]: 使用例:: - @measure_time_async("llm.summarize") - async def summarize(...): ... + @measure_time_async("github_collector.fetch") + async def fetch_repositories(...): ... """ def decorator(fn: F) -> F: diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index f9372f68..1c9f207d 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -170,23 +170,6 @@ def get_internal_secret() -> str: return secret -def get_llm_provider() -> str: - return os.environ.get(env_keys.LLM_PROVIDER, "ollama") - - -def get_vertex_project_id() -> str: - return os.environ.get(env_keys.VERTEX_PROJECT_ID, "") - - -def get_vertex_location() -> str: - return os.environ.get(env_keys.VERTEX_LOCATION, "asia-northeast1") - - -def get_vertex_model(default: str) -> str: - """Vertex AI のモデル名を取得する。default は呼び出し元(Vertex クライアント)の既定値を渡す。""" - return os.environ.get(env_keys.VERTEX_MODEL, default) - - def get_log_format() -> str: """ログフォーマット指定(json / text / 空)を小文字で取得する。""" return os.getenv(env_keys.LOG_FORMAT, "").strip().lower() diff --git a/backend/app/prompts/.gitkeep b/backend/app/prompts/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/app/services/intelligence/llm/__init__.py b/backend/app/services/intelligence/llm/__init__.py deleted file mode 100644 index 957fe56d..00000000 --- a/backend/app/services/intelligence/llm/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""LLM クライアント抽象化レイヤー。""" - -from .base import LLMClient -from .factory import get_llm_client - -__all__ = ["LLMClient", "get_llm_client"] diff --git a/backend/app/services/intelligence/llm/base.py b/backend/app/services/intelligence/llm/base.py deleted file mode 100644 index e1a190a9..00000000 --- a/backend/app/services/intelligence/llm/base.py +++ /dev/null @@ -1,15 +0,0 @@ -"""LLM クライアントの基底クラス。""" - -from abc import ABC, abstractmethod - - -class LLMClient(ABC): - """LLM バックエンド共通インターフェース。""" - - @abstractmethod - async def generate(self, system_prompt: str, user_prompt: str) -> str | None: - """テキスト生成を実行する。失敗時は None を返す。""" - - @abstractmethod - async def check_available(self) -> bool: - """バックエンドが利用可能か確認する。""" diff --git a/backend/app/services/intelligence/llm/factory.py b/backend/app/services/intelligence/llm/factory.py deleted file mode 100644 index dbf4a735..00000000 --- a/backend/app/services/intelligence/llm/factory.py +++ /dev/null @@ -1,33 +0,0 @@ -"""LLM クライアントのファクトリ。""" - -import logging - -from ....core.settings import get_llm_provider -from .base import LLMClient - -logger = logging.getLogger(__name__) - - -def get_llm_client() -> LLMClient: - """環境変数 LLM_PROVIDER に応じた LLM クライアントを返す。 - - - ``"ollama"`` (デフォルト): OllamaClient - - ``"vertex"``: VertexClient (google-cloud-aiplatform が必要) - """ - provider = get_llm_provider().lower() - - if provider == "vertex": - from .vertex_client import VertexClient - - client = VertexClient() - logger.info( - "LLM backend: vertex (model=%s, project=%s, location=%s)", - client.model_name, client.project_id, client.location, - ) - return client - - from .ollama_client import OllamaClient - - client = OllamaClient() - logger.info("LLM backend: ollama (model=%s)", client.model) - return client diff --git a/backend/app/services/intelligence/llm/ollama_client.py b/backend/app/services/intelligence/llm/ollama_client.py deleted file mode 100644 index 70647733..00000000 --- a/backend/app/services/intelligence/llm/ollama_client.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Ollama バックエンドクライアント。""" - -import logging -import os -import time - -import httpx - -from ....core import env_keys -from ....services.tasks.exceptions import NonRetryableError, RetryableError -from .base import LLMClient - -logger = logging.getLogger(__name__) - -# 一時障害とみなす HTTP ステータスコード -_RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504} - - -class OllamaClient(LLMClient): - """Ollama API を使用した LLM クライアント。""" - - def __init__(self) -> None: - self.base_url = os.environ.get(env_keys.OLLAMA_BASE_URL, "http://localhost:11434") - self.model = os.environ.get(env_keys.OLLAMA_MODEL, "gemma3:4b") - self.timeout = float(os.environ.get(env_keys.OLLAMA_TIMEOUT, "1200.0")) # デフォルトは 20 分 - - async def generate(self, system_prompt: str, user_prompt: str) -> str | None: - """Ollama API でテキスト生成を実行する。 - - タイムアウト・5xx・429 は ``RetryableError``、4xx は ``NonRetryableError`` - を raise する。``ConnectError``(Ollama 未起動時)および未分類エラーは ``None`` を返す。 - """ - start = time.monotonic() - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - resp = await client.post( - f"{self.base_url}/api/generate", - json={ - "model": self.model, - "system": system_prompt, - "prompt": user_prompt, - "stream": False, - }, - ) - resp.raise_for_status() - data = resp.json() - duration_ms = int((time.monotonic() - start) * 1000) - logger.info( - "Ollama 生成完了", - extra={"status": "completed", "duration_ms": duration_ms}, - ) - return data.get("response", "").strip() - except httpx.ConnectError: - logger.info("Ollama が %s で利用できません", self.base_url) - return None - except httpx.TimeoutException as exc: - duration_ms = int((time.monotonic() - start) * 1000) - logger.warning( - "Ollama 生成がタイムアウトしました (%.1f秒)", - self.timeout, - extra={ - "status": "failed", - "error_type": "TimeoutException", - "duration_ms": duration_ms, - }, - ) - raise RetryableError(f"Ollama タイムアウト: {exc}") from exc - except httpx.HTTPStatusError as exc: - duration_ms = int((time.monotonic() - start) * 1000) - status_code = exc.response.status_code - logger.warning( - "Ollama が %d を返しました", status_code, - extra={"status": "failed", "duration_ms": duration_ms}, - ) - if status_code in _RETRYABLE_STATUS_CODES: - retry_after = _parse_retry_after(exc.response) if status_code == 429 else None - raise RetryableError( - f"Ollama {status_code}: {exc}", retry_after=retry_after, - ) from exc - raise NonRetryableError(f"Ollama {status_code}: {exc}") from exc - except Exception: - duration_ms = int((time.monotonic() - start) * 1000) - logger.exception( - "Ollama による生成に失敗しました", - extra={"status": "failed", "duration_ms": duration_ms}, - ) - return None - - async def check_available(self) -> bool: - """Ollama サーバーに接続可能、かつ指定モデルが利用可能か確認する。""" - try: - async with httpx.AsyncClient(timeout=3.0) as client: - resp = await client.get(f"{self.base_url}/api/tags") - if resp.status_code != 200: - return False - models = [m.get("name", "") for m in resp.json().get("models", [])] - return self.model in models - except (httpx.ConnectError, httpx.TimeoutException): - return False - - -def _parse_retry_after(response: httpx.Response) -> float | None: - """HTTP レスポンスから ``Retry-After`` ヘッダを秒単位で抽出する。""" - value = response.headers.get("retry-after") - if not value: - return None - try: - return float(value) - except (TypeError, ValueError): - return None diff --git a/backend/app/services/intelligence/llm/vertex_client.py b/backend/app/services/intelligence/llm/vertex_client.py deleted file mode 100644 index 1fd314bc..00000000 --- a/backend/app/services/intelligence/llm/vertex_client.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Vertex AI Gemini バックエンドクライアント(google-genai SDK)。""" - -import asyncio -import logging -import time - -from ....core.settings import get_vertex_location, get_vertex_model, get_vertex_project_id -from ....services.tasks.exceptions import NonRetryableError, RetryableError -from .base import LLMClient - -logger = logging.getLogger(__name__) -DEFAULT_VERTEX_MODEL = "gemini-2.5-flash-lite" - -# 一時障害とみなす HTTP ステータスコード -_RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504} - - -def _extract_usage(response) -> dict[str, int]: - """Vertex レスポンスの ``usage_metadata`` からトークン数を抜き出す。 - - SDK バージョンや部分障害でフィールドが欠ける可能性があるので、 - 取れたものだけを dict で返す。計測用途なので失敗しても握りつぶす。 - """ - meta = getattr(response, "usage_metadata", None) - if meta is None: - return {} - fields = { - "prompt_tokens": "prompt_token_count", - "output_tokens": "candidates_token_count", - "total_tokens": "total_token_count", - "cached_tokens": "cached_content_token_count", - } - out: dict[str, int] = {} - for key, attr in fields.items(): - value = getattr(meta, attr, None) - if isinstance(value, int): - out[key] = value - return out - - -def _parse_retry_after(exc: Exception) -> float | None: - """APIError.response から ``Retry-After`` ヘッダを抽出する。""" - response = getattr(exc, "response", None) - if response is None: - return None - headers = getattr(response, "headers", None) or {} - value = headers.get("retry-after") or headers.get("Retry-After") - if not value: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -class VertexClient(LLMClient): - """google-genai SDK を使用した Vertex AI Gemini クライアント。""" - - def __init__(self) -> None: - self.project_id = get_vertex_project_id() - self.location = get_vertex_location() - self.model_name = get_vertex_model(DEFAULT_VERTEX_MODEL) - self._client = None - - def _get_client(self): - """genai.Client の遅延初期化。""" - if self._client is None: - from google import genai - - self._client = genai.Client( - vertexai=True, - project=self.project_id, - location=self.location, - ) - return self._client - - async def generate(self, system_prompt: str, user_prompt: str) -> str | None: - """Vertex AI Gemini でテキスト生成を実行する。 - - 一時障害は ``RetryableError``、恒久的な障害は ``NonRetryableError`` を - raise する。未分類の例外は ``None`` を返す。 - """ - start = time.monotonic() - try: - from google.genai import types - - client = self._get_client() - response = await client.aio.models.generate_content( - model=self.model_name, - contents=user_prompt, - config=types.GenerateContentConfig( - system_instruction=system_prompt, - max_output_tokens=1024, - temperature=0.3, - ), - ) - duration_ms = int((time.monotonic() - start) * 1000) - logger.info( - "Vertex AI 生成完了", - extra={ - "status": "completed", - "duration_ms": duration_ms, - **_extract_usage(response), - }, - ) - return response.text.strip() - except (asyncio.TimeoutError, TimeoutError) as exc: - duration_ms = int((time.monotonic() - start) * 1000) - logger.warning( - "Vertex AI 生成がタイムアウトしました", - extra={"status": "failed", "duration_ms": duration_ms}, - ) - raise RetryableError(f"Vertex AI タイムアウト: {exc}") from exc - except Exception as exc: - duration_ms = int((time.monotonic() - start) * 1000) - classified = _classify_vertex_exception(exc) - if classified is not None: - logger.warning( - "Vertex AI による生成に失敗しました (%s)", - type(classified).__name__, - extra={"status": "failed", "duration_ms": duration_ms}, - ) - raise classified from exc - logger.exception( - "Vertex AI による生成に失敗しました", - extra={"status": "failed", "duration_ms": duration_ms}, - ) - return None - - async def check_available(self) -> bool: - """VERTEX_PROJECT_ID が設定されていれば利用可能とみなす。""" - return bool(self.project_id) - - -def _classify_vertex_exception(exc: Exception) -> Exception | None: - """Vertex AI / google-genai の例外をリトライ可否で分類する。 - - 分類できた場合は ``RetryableError`` / ``NonRetryableError`` を返し、 - 呼び出し元は ``raise`` する。分類できなければ ``None`` を返す。 - """ - # google-genai SDK の APIError ファミリー(code 属性で HTTP ステータスを持つ) - try: - from google.genai.errors import APIError - except ImportError: - APIError = None # type: ignore[assignment] - - if APIError is not None and isinstance(exc, APIError): - code = getattr(exc, "code", None) - if isinstance(code, int): - if code in _RETRYABLE_STATUS_CODES: - retry_after = _parse_retry_after(exc) if code == 429 else None - return RetryableError( - f"Vertex AI {code}: {exc}", retry_after=retry_after - ) - if 400 <= code < 500: - return NonRetryableError(f"Vertex AI {code}: {exc}") - - # google.api_core 例外のフォールバック(低レベル呼び出しから伝播するケース) - try: - from google.api_core import exceptions as gax - except ImportError: - return None - - if isinstance( - exc, - ( - gax.DeadlineExceeded, - gax.ResourceExhausted, - gax.ServiceUnavailable, - gax.InternalServerError, - gax.TooManyRequests, - ), - ): - retry_after = _parse_retry_after(exc) if isinstance(exc, gax.TooManyRequests) else None - return RetryableError( - f"Vertex AI {type(exc).__name__}: {exc}", retry_after=retry_after - ) - if isinstance( - exc, - ( - gax.InvalidArgument, - gax.PermissionDenied, - gax.NotFound, - gax.Unauthenticated, - gax.BadRequest, - ), - ): - return NonRetryableError(f"Vertex AI {type(exc).__name__}: {exc}") - return None diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py deleted file mode 100644 index df5ff9d4..00000000 --- a/backend/app/services/llm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""LLM 送信前サニタイズ層。""" diff --git a/backend/app/services/llm/sanitizer.py b/backend/app/services/llm/sanitizer.py deleted file mode 100644 index 2fd15dd7..00000000 --- a/backend/app/services/llm/sanitizer.py +++ /dev/null @@ -1,139 +0,0 @@ -"""LLM 送信前のデータサニタイズモジュール。 - -エンティティを3分類して処理する: -- A分類: そのまま送ってよい(スキル名・言語名・ポジションスコア等) -- B分類: マスキングして送る(企業名・顧客名・案件名・自由記述テキスト) -- C分類: 送らない(氏名・メール・志望動機等) - -マスキングは辞書ベースで同一セッション(SanitizeContext)内の一貫性を保つ。 -NER 等の自動抽出はスコープ外。辞書への登録は構造化フィールドから事前に行う。 - -住所・郵便番号・生年月日・電話番号・名前ふりがな・写真はシステム上入力されないため除外。 -""" - -from dataclasses import dataclass, field - -# C分類(原則送らない)フィールド一覧 -# ※ 住所・郵便番号・生年月日・電話番号・名前ふりがな・写真はシステム上入力されないため除外 -_PROHIBITED_FIELDS: frozenset[str] = frozenset( - { - "full_name", - "email", - "motivation", - "personal_preferences", - "username", - } -) - - -def _alpha_label(prefix: str, index: int) -> str: - """0起算インデックスから「[企業A]」「[企業B]」... のラベルを生成する。 - - 26以上のインデックスは数値サフィックス([企業27]等)にフォールバックする。 - """ - suffix = chr(ord("A") + index) if index < 26 else str(index + 1) - return f"[{prefix}{suffix}]" - - -@dataclass -class SanitizeContext: - """セッション単位でエンティティラベルの一貫性を保つ状態管理。 - - 同一の raw 名が複数箇所に出現しても同じラベルに変換される。 - 1リクエストスコープで生成し、DB保存・キャッシュは不要。 - """ - - companies: dict[str, str] = field(default_factory=dict) - """raw 企業名 → 「[企業A]」「[企業B]」...""" - customers: dict[str, str] = field(default_factory=dict) - """raw 顧客名 → 「[顧客A]」「[顧客B]」...""" - projects: dict[str, str] = field(default_factory=dict) - """raw 案件名 → 「[案件A]」「[案件B]」...""" - products: dict[str, str] = field(default_factory=dict) - """raw プロダクト名 → 「[プロダクトA]」...""" - domains: dict[str, str] = field(default_factory=dict) - """raw 業務ドメイン名 → 「[業務ドメインA]」...""" - - def register_company(self, name: str) -> str: - """企業名をコンテキストに登録してラベルを返す。空文字は登録しない。""" - if not name: - return name - if name not in self.companies: - self.companies[name] = _alpha_label("企業", len(self.companies)) - return self.companies[name] - - def register_customer(self, name: str) -> str: - """顧客名をコンテキストに登録してラベルを返す。空文字は登録しない。""" - if not name: - return name - if name not in self.customers: - self.customers[name] = _alpha_label("顧客", len(self.customers)) - return self.customers[name] - - def register_project(self, name: str) -> str: - """案件名をコンテキストに登録してラベルを返す。空文字は登録しない。""" - if not name: - return name - if name not in self.projects: - self.projects[name] = _alpha_label("案件", len(self.projects)) - return self.projects[name] - - def register_product(self, name: str) -> str: - """プロダクト名をコンテキストに登録してラベルを返す。空文字は登録しない。""" - if not name: - return name - if name not in self.products: - self.products[name] = _alpha_label("プロダクト", len(self.products)) - return self.products[name] - - def register_domain(self, name: str) -> str: - """業務ドメイン名をコンテキストに登録してラベルを返す。空文字は登録しない。""" - if not name: - return name - if name not in self.domains: - self.domains[name] = _alpha_label("業務ドメイン", len(self.domains)) - return self.domains[name] - - def _all_masks(self) -> dict[str, str]: - """全カテゴリの辞書を統合して返す。 - - 後から更新されたカテゴリが優先されるよう、具体性の高い順に結合する。 - """ - merged: dict[str, str] = {} - merged.update(self.domains) - merged.update(self.products) - merged.update(self.projects) - merged.update(self.customers) - merged.update(self.companies) - return merged - - -def sanitize_text(text: str, context: SanitizeContext) -> str: - """自由記述テキストをマスキングして返す。 - - 辞書に登録済みのエンティティを同一ラベルに置換する。 - 長い名前から優先して置換することで部分一致の誤変換を防ぐ。 - 辞書未登録の固有名詞はそのまま残る(MVP仕様として許容)。 - """ - if not text: - return text or "" - masks = context._all_masks() - if not masks: - return text - masked = text - for name in sorted(masks.keys(), key=len, reverse=True): - masked = masked.replace(name, masks[name]) - return masked - - -def sanitize_project_name(name: str, context: SanitizeContext) -> str: - """project.name を匿名化ラベルに変換する。""" - return context.register_project(name) - - -def strip_prohibited_fields(data: dict) -> dict: - """C分類(原則送らない)フィールドを dict から除去して返す。 - - 対象: full_name, email, motivation, personal_preferences, username - """ - return {k: v for k, v in data.items() if k not in _PROHIBITED_FIELDS} diff --git a/backend/app/services/tasks/exceptions.py b/backend/app/services/tasks/exceptions.py index 839ea317..9fe526ac 100644 --- a/backend/app/services/tasks/exceptions.py +++ b/backend/app/services/tasks/exceptions.py @@ -1,6 +1,6 @@ """バックグラウンドタスクのリトライ用例外。 -タスク内で発生した外部サービス例外(httpx / Vertex AI / DB 等)を、 +タスク内で発生した外部サービス例外(httpx / GitHub API / DB 等)を、 - ``RetryableError`` : 一時的な障害(タイムアウト / 5xx / レート制限) - ``NonRetryableError`` : 恒久的な障害(バリデーション / 401/403/404) の二種類に分類して raise し直すための例外クラスを提供する。 diff --git a/backend/app/services/tasks/handlers/base.py b/backend/app/services/tasks/handlers/base.py index fbc90692..d3fd0047 100644 --- a/backend/app/services/tasks/handlers/base.py +++ b/backend/app/services/tasks/handlers/base.py @@ -8,7 +8,7 @@ worker 側で本基底クラスが提供する共通ロジックを通じて行う。 ``run`` が ``session_factory`` を受け取るのは、libSQL (Hrana over HTTP) の idle -stream timeout に対する根本対策のため。LLM や外部 API のような長時間処理の前後で +stream timeout に対する根本対策のため。外部 API 呼び出しのような長時間処理の前後で セッションを開閉し、stream を都度更新する。``get_record`` は短命セッション内で 呼ばれる前提で ``Session`` を受け取る。 """ @@ -38,6 +38,6 @@ def get_record(self, db: Session, payload: dict) -> Any | None: async def run(self, session_factory: SessionFactory, payload: dict) -> None: """タスク本体を実行する。 - 長時間処理(LLM / 外部 API)の前後でセッションを開閉し、libSQL の + 長時間処理(外部 API 呼び出し等)の前後でセッションを開閉し、libSQL の idle stream timeout を避ける。状態遷移は呼び出し側 (worker) が担う。 """ diff --git a/backend/app/utils/prompt_loader.py b/backend/app/utils/prompt_loader.py deleted file mode 100644 index 273b59df..00000000 --- a/backend/app/utils/prompt_loader.py +++ /dev/null @@ -1,26 +0,0 @@ -"""app/prompts/ 配下の MD ファイルを読み込むユーティリティ。""" - -from pathlib import Path - -# backend/app/utils/prompt_loader.py から見て backend/app/prompts を指すように修正 -PROMPTS_DIR = Path(__file__).parent.parent / "prompts" - - -def load_prompt(filename: str) -> str: - """app/prompts/ 配下の MD ファイルを読み込んで文字列で返す。 - - 呼び出し時に都度ファイルを読み込む(キャッシュなし)。 - - Args: - filename: 拡張子込みのファイル名(例: "github_trend.md") - - Returns: - MD ファイルの内容文字列(前後の空白・改行を除去済み) - - Raises: - FileNotFoundError: 指定ファイルが存在しない場合 - """ - path = PROMPTS_DIR / filename - if not path.exists(): - raise FileNotFoundError(f"プロンプトファイルが見つかりません: {path}") - return path.read_text(encoding="utf-8").strip() diff --git a/backend/docs/llm_data_policy.md b/backend/docs/llm_data_policy.md deleted file mode 100644 index 377502cd..00000000 --- a/backend/docs/llm_data_policy.md +++ /dev/null @@ -1,116 +0,0 @@ -# LLM 入力データポリシー - -LLM へ送信するデータは以下の3分類で管理する。 - ---- - -## フィールド分類一覧 - -### A分類 — そのまま送ってよい - -技術的な事実・統計であり、個人を特定できる情報を含まない。 - -| フィールド | 説明 | -|---|---| -| `qualifications[].name` | 資格名 | -| `qualifications[].acquired_date` | 資格取得日 | -| `technology_stacks[].name` | 技術スタック名 | -| `project.{start_date, end_date}` | 案件期間 | -| `project.phases` | 担当フェーズ | -| `analysis.repos_analyzed` | 分析リポジトリ数 | -| `analysis.unique_skills` | ユニークスキル数 | -| `analysis.languages` | 使用言語と割合 | -| `analysis.position_scores` | ポジションスコア(backend/frontend等) | -| `analysis.missing_skills` | 不足スキル一覧 | -| `analysis.repositories[].skills` | リポジトリ検出スキル | -| `blog.tags` | 記事タグ | -| `blog.likes_count` | いいね数 | - -### B分類 — マスキングして送る - -固有名詞・自由記述であり、`SanitizeContext` を通じてラベルに置換してから送信する。 - -| フィールド | 変換内容 | 関数 | -|---|---|---| -| `experience.company` | `[企業A]` `[企業B]`... | `context.register_company()` | -| `client.name` | `[顧客A]` `[顧客B]`... | `context.register_customer()` | -| `project.name` | `[案件A]` `[案件B]`... | `sanitize_project_name()` | -| `project.description` | 辞書登録済み名称をラベルに置換 | `sanitize_text()` | -| `resume.career_summary` | 辞書登録済み名称をラベルに置換 | `sanitize_text()` | -| `blog_cache.summary` | 辞書登録済み名称をラベルに置換 | `sanitize_text()` | -| `blog.article.title` | 辞書登録済み名称をラベルに置換 | `sanitize_text()` | -| `blog.article.summary` | 辞書登録済み名称をラベルに置換 | `sanitize_text()` | -| `work_histories[].name` | `[案件A]` `[案件B]`... | `sanitize_work_history_name()` | - -> **注意**: 辞書未登録の固有名詞は現状マスキングされない(MVP仕様)。 -> NER による自動抽出はスコープ外。 - -### C分類 — 原則送らない - -氏名・個人情報であり、`strip_prohibited_fields()` で除去してから渡す。 - -| フィールド | 理由 | -|---|---| -| `full_name` | 氏名(個人識別情報) | -| `email` | メールアドレス | -| `motivation` | 志望動機 | -| `personal_preferences` | 個人の嗜好 | -| `username` | ユーザーID(サービス内識別子) | - -> 住所・郵便番号・生年月日・電話番号・名前ふりがな・写真はシステム上入力されないため対象外。 - ---- - -## SanitizeContext の使い方 - -`SanitizeContext` は1リクエストスコープで生成し、複数のフィールドにわたって共有する。 -これにより「株式会社テスト」が複数箇所に出ても常に `[企業A]` に統一される。 - -```python -from app.services.llm.sanitizer import SanitizeContext, sanitize_text, sanitize_project_name - -# リクエスト開始時に生成 -context = SanitizeContext() - -# 構造化フィールドから事前登録 -context.register_company("株式会社テスト") # → [企業A] -context.register_customer("顧客企業株式会社") # → [顧客A] -context.register_project("基幹システム刷新") # → [案件A] - -# 自由記述のマスキング -masked = sanitize_text("株式会社テストで基幹システム刷新を担当", context) -# → "[企業A]で[案件A]を担当" - -# 案件名のラベル化 -label = sanitize_project_name("基幹システム刷新", context) -# → "[案件A]"(既登録のため同じラベル) -``` - ---- - -## 新しい LLM 入力フィールドを追加する際の判断フロー - -``` -新しいフィールドをプロンプトに追加しようとしている - ↓ -そのフィールドは個人を特定できるか? - YES → C分類: strip_prohibited_fields() に追加し、送信しない - NO ↓ -そのフィールドは固有名詞・案件名・社名を含む可能性があるか? - YES → B分類: sanitize_text() または register_*() でマスキング - NO ↓ -A分類: そのまま渡してよい -``` - ---- - -## ラベルの番号体系 - -- 企業: `[企業A]` `[企業B]` ... `[企業Z]` `[企業27]` ... -- 顧客: `[顧客A]` `[顧客B]` ... -- 案件: `[案件A]` `[案件B]` ... -- プロダクト: `[プロダクトA]` ... -- 業務ドメイン: `[業務ドメインA]` ... - -ラベルは `SanitizeContext` がリクエスト単位で採番するため、 -リクエストをまたいでラベルが変わることは許容する(MVP仕様)。 diff --git a/backend/requirements.txt b/backend/requirements.txt index 78919d7a..5e5ebd81 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,7 +20,6 @@ autopep8==2.3.2 markdown==3.8.1 weasyprint==68.0 pydyf==0.12.1 -google-genai==1.46.0 redis==5.0.1 isort==5.12.0 black==26.3.1 diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh index 90f551fd..cdc534c6 100755 --- a/backend/scripts/entrypoint.sh +++ b/backend/scripts/entrypoint.sh @@ -4,39 +4,4 @@ set -eu python -m app.db.bootstrap export APP_BOOTSTRAPPED=1 -# Ollama モデルを同期で pull してから uvicorn を起動する -# バックグラウンド pull だと pull 完了前にリクエストが来た際に 404 になるため同期化する -if [ -n "${OLLAMA_BASE_URL:-}" ]; then - OLLAMA_MODEL="${OLLAMA_MODEL:-gemma3:4b}" - OLLAMA_TIMEOUT="${OLLAMA_TIMEOUT:-1200}" - - # Ollama サーバが起動するまで最大 30 秒待機 - echo "Waiting for Ollama to be ready..." - for i in $(seq 1 30); do - if curl -sf "${OLLAMA_BASE_URL}/api/tags" >/dev/null 2>&1; then - echo "Ollama is ready." - break - fi - sleep 1 - done - - # モデルを同期で pull(失敗してもサーバは起動する) - echo "Pulling Ollama model: ${OLLAMA_MODEL} ..." - if curl -sf "${OLLAMA_BASE_URL}/api/pull" \ - -d "{\"name\":\"${OLLAMA_MODEL}\",\"stream\":false}" \ - -H "Content-Type: application/json" \ - --max-time 600; then - echo "Ollama model pull complete: ${OLLAMA_MODEL}" - echo "Warming up Ollama model: ${OLLAMA_MODEL} ..." - curl -sf "${OLLAMA_BASE_URL}/api/generate" \ - -d "{\"model\":\"${OLLAMA_MODEL}\",\"prompt\":\"Respond with OK.\",\"stream\":false}" \ - -H "Content-Type: application/json" \ - --max-time "${OLLAMA_TIMEOUT}" >/dev/null \ - && echo "Ollama model warm-up complete: ${OLLAMA_MODEL}" \ - || echo "Warning: Ollama model warm-up failed (AI features may be slow on first request)" - else - echo "Warning: Ollama model pull failed (AI features may be unavailable)" - fi -fi - exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/backend/scripts/measure_career_analysis.py b/backend/scripts/measure_career_analysis.py deleted file mode 100644 index fe9715bb..00000000 --- a/backend/scripts/measure_career_analysis.py +++ /dev/null @@ -1,353 +0,0 @@ -"""AI キャリア分析の並列化検証 Phase 0 計測スクリプト。 - -以下を測定する: -1. 現状プロンプトのトークン量(system + user / 出力) -2. 分割プロンプト(短期 / 中期 / 長期)のトークン量(共通コンテキスト x 3 のオーバーヘッドを可視化) -3. 直列実行 vs `asyncio.gather` 並列実行のレイテンシ -4. 並列リクエスト時のエラー率 / QPS 限界の初期観測 - -実行方法: - cd backend - VERTEX_PROJECT_ID=... VERTEX_LOCATION=asia-northeast1 \ - SQLITE_DB_PATH=./local.sqlite \ - .venv/bin/python scripts/measure_career_analysis.py --user-id --target SRE --runs 3 - -LLM を叩かず静的にトークン量だけ見たい場合: - .venv/bin/python scripts/measure_career_analysis.py --user-id --target SRE --no-call - -結果は標準出力 + 指定時は Markdown で -`docs/runbook/career_analysis_parallel_investigation.md` に追記する。 -""" - -from __future__ import annotations - -import argparse -import asyncio -import os -import statistics -import sys -import time -from dataclasses import dataclass -from pathlib import Path - -# backend/ をパスに追加 -BACKEND_ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(BACKEND_ROOT)) - -from app.db.database import SessionLocal # noqa: E402 -from app.models import BlogSummaryCache, GitHubLinkCache, Resume # noqa: E402 -from app.services.career_analysis.prompt_builder import build_user_prompt # noqa: E402 -from app.services.career_analysis.tech_stack_merger import ( # noqa: E402 - collect_github_skills, - collect_qualification_names, - collect_resume_tech_stacks, - merge_tech_stacks, -) -from app.services.intelligence.llm import get_llm_client # noqa: E402 -from app.utils.prompt_loader import load_prompt # noqa: E402 - -# ─────────────────────────────────────────────── -# サブステップ用の分割プロンプト -# career_analysis.md の分析指針を継承し、出力を horizon 単位に絞る -# ─────────────────────────────────────────────── - -_HORIZON_LABELS = { - "short": "短期(1年以内)", - "mid": "中期(3年以内)", - "long": "長期(5年以内)", -} - -_SPLIT_OUTPUT_OVERRIDE = """ - ---- - -## 追加指示(サブステップ分割用) - -このリクエストでは上記スキーマのうち **{horizon_label} のパスのみ** を返してください。 -他の horizon は含めず、以下の単一オブジェクトを JSON で返すこと(コードブロック・前置き不要): - -{{ - "horizon": "{horizon}", - "label": "{horizon_label}", - "title": "...", - "description": "...", - "required_skills": [], - "gap_skills": [], - "fit_score": 0-100 -}} - -growth_summary / tech_stack / strengths / action_items は含めないこと。 -「技術スタック評価の優先順位」「入力データの取り扱い」「ルール」は引き続き厳守すること。 -""" - - -def _build_split_system_prompt(horizon: str) -> str: - """実運用プロンプト(career_analysis.md)に出力範囲の上書きのみ追加する。 - - 検証で測るトークン量・品質を実装時と揃えるため、分析指針は共通のものを使う。 - """ - base = load_prompt("career_analysis.md") - override = _SPLIT_OUTPUT_OVERRIDE.format( - horizon=horizon, horizon_label=_HORIZON_LABELS[horizon] - ) - return base + override - - -# ─────────────────────────────────────────────── -# 計測ユーティリティ -# ─────────────────────────────────────────────── - - -@dataclass -class PromptBundle: - system_prompt: str - user_prompt: str - - -@dataclass -class LatencyStats: - label: str - samples: list[float] - - @property - def avg_ms(self) -> float: - return statistics.mean(self.samples) * 1000 if self.samples else 0.0 - - @property - def p95_ms(self) -> float: - if not self.samples: - return 0.0 - ordered = sorted(self.samples) - idx = min(len(ordered) - 1, int(len(ordered) * 0.95)) - return ordered[idx] * 1000 - - def dump(self) -> str: - return f"{self.label}: avg={self.avg_ms:.0f}ms p95={self.p95_ms:.0f}ms n={len(self.samples)}" - - -def _approx_tokens(text: str) -> int: - """ローカルで使える簡易トークン概算。 - - 日本語混在テキスト向けに char/2 を採用(Gemini/Claude の経験則)。 - 厳密な値は Vertex の ``count_tokens`` を使うので、ここはオフライン確認用の - 大枠のあたりをつけるためだけに使う。 - """ - return max(1, len(text) // 2) - - -async def _count_tokens_remote(client, contents: str, system: str) -> int | None: - """google-genai の count_tokens で正確なトークン数を取得する。 - - Vertex 以外(Ollama)では None を返す。 - """ - try: - inner = getattr(client, "_get_client", None) - if inner is None: - return None - real = inner() - resp = await real.aio.models.count_tokens( - model=getattr(client, "model_name", "gemini-2.5-flash-lite"), - contents=f"{system}\n\n{contents}", - ) - return int(getattr(resp, "total_tokens", 0)) - except Exception as exc: # 計測失敗は致命ではない - print(f"[warn] count_tokens 失敗: {exc}", file=sys.stderr) - return None - - -# ─────────────────────────────────────────────── -# プロンプト構築 -# ─────────────────────────────────────────────── - - -def build_prompts_for_user(user_id: str, target_position: str) -> tuple[PromptBundle, dict[str, PromptBundle]]: - """現状の単発プロンプトと、サブステップ分割プロンプト 3 本を作って返す。""" - db = SessionLocal() - try: - resume = db.query(Resume).filter_by(user_id=user_id).first() - analysis_cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first() - blog_cache = db.query(BlogSummaryCache).filter_by(user_id=user_id).first() - - resume_techs = collect_resume_tech_stacks(resume) if resume else set() - github_skills = collect_github_skills(analysis_cache) - qualification_names = collect_qualification_names(resume) - merged_stacks_text = merge_tech_stacks(resume_techs, github_skills, qualification_names) - - user_prompt = build_user_prompt( - target_position, resume, analysis_cache, blog_cache, merged_stacks_text, - ) - finally: - db.close() - - current = PromptBundle( - system_prompt=load_prompt("career_analysis.md"), - user_prompt=user_prompt, - ) - split = { - h: PromptBundle( - system_prompt=_build_split_system_prompt(h), - user_prompt=user_prompt, - ) - for h in ("short", "mid", "long") - } - return current, split - - -# ─────────────────────────────────────────────── -# LLM 呼び出し -# ─────────────────────────────────────────────── - - -async def _single_call(client, bundle: PromptBundle) -> tuple[float, str]: - start = time.monotonic() - out = await client.generate(bundle.system_prompt, bundle.user_prompt) - elapsed = time.monotonic() - start - return elapsed, out - - -async def measure_serial(client, bundles: list[PromptBundle]) -> LatencyStats: - times: list[float] = [] - start = time.monotonic() - for b in bundles: - t, _ = await _single_call(client, b) - times.append(t) - total = time.monotonic() - start - return LatencyStats(label=f"serial x{len(bundles)}", samples=[total, *times]) - - -def _error_key(exc: Exception) -> str: - """例外を `種別名(HTTPステータス)` の形に要約する。QPS 限界の切り分けに使う。""" - name = type(exc).__name__ - status = getattr(exc, "code", None) - if not isinstance(status, int): - status = getattr(exc, "status_code", None) - return f"{name}({status})" if isinstance(status, int) else name - - -async def measure_parallel( - client, bundles: list[PromptBundle] -) -> tuple[LatencyStats, dict[str, int]]: - """並列実行の合計レイテンシと、エラー種別別カウントを返す。""" - start = time.monotonic() - results = await asyncio.gather( - *[_single_call(client, b) for b in bundles], return_exceptions=True, - ) - total = time.monotonic() - start - per_call: list[float] = [] - error_counts: dict[str, int] = {} - error_samples: dict[str, str] = {} - for r in results: - if isinstance(r, Exception): - key = _error_key(r) - error_counts[key] = error_counts.get(key, 0) + 1 - error_samples.setdefault(key, str(r)[:200]) - else: - t, _ = r - per_call.append(t) - for key, count in error_counts.items(): - print(f"[warn] parallel error {key} x{count}: {error_samples[key]}", file=sys.stderr) - return ( - LatencyStats(label=f"parallel x{len(bundles)}", samples=[total, *per_call]), - error_counts, - ) - - -# ─────────────────────────────────────────────── -# メイン -# ─────────────────────────────────────────────── - - -async def main_async(args) -> None: - current, split = build_prompts_for_user(args.user_id, args.target) - - print("=" * 72) - print("Phase 0 計測 — AI キャリア分析サブステップ分割") - print("=" * 72) - - # 1. トークン量 - print("\n## 1. プロンプトサイズ(文字数 / 概算トークン)") - print(f" [current] system={len(current.system_prompt)}c user={len(current.user_prompt)}c " - f"≈{_approx_tokens(current.system_prompt) + _approx_tokens(current.user_prompt)} tok") - split_total = 0 - for h, b in split.items(): - approx = _approx_tokens(b.system_prompt) + _approx_tokens(b.user_prompt) - split_total += approx - print(f" [split:{h}] system={len(b.system_prompt)}c user={len(b.user_prompt)}c ≈{approx} tok") - current_approx = _approx_tokens(current.system_prompt) + _approx_tokens(current.user_prompt) - overhead_pct = (split_total / current_approx - 1) * 100 if current_approx else 0 - print(f" [split:total] ≈{split_total} tok vs current: +{overhead_pct:.1f}%") - - if args.no_call: - print("\n(--no-call 指定のため LLM 呼び出しをスキップ)") - return - - client = get_llm_client() - if not await client.check_available(): - print("\n[error] LLM クライアントが利用不可(VERTEX_PROJECT_ID 等を確認)", file=sys.stderr) - sys.exit(1) - - # 2. Vertex 正確トークン - print("\n## 2. Vertex count_tokens(正確値)") - current_tokens = await _count_tokens_remote(client, current.user_prompt, current.system_prompt) - if current_tokens is not None: - print(f" [current] total={current_tokens} tok") - per_split_tokens: dict[str, int | None] = {} - for h, b in split.items(): - per_split_tokens[h] = await _count_tokens_remote(client, b.user_prompt, b.system_prompt) - print(f" [split:{h}] total={per_split_tokens[h]} tok") - split_sum = sum(v for v in per_split_tokens.values() if v is not None) - if current_tokens and split_sum: - print(f" [split:total] {split_sum} tok vs current: +{(split_sum / current_tokens - 1) * 100:.1f}%") - - # 3. レイテンシ計測 - print(f"\n## 3. レイテンシ計測(runs={args.runs})") - all_serial: list[float] = [] - all_parallel: list[float] = [] - total_requests = 0 - aggregated_errors: dict[str, int] = {} - for i in range(args.runs): - print(f" run {i + 1}/{args.runs}...") - s = await measure_serial(client, list(split.values())) - all_serial.append(s.samples[0]) - print(f" {s.dump()}") - p, errors = await measure_parallel(client, list(split.values())) - all_parallel.append(p.samples[0]) - total_requests += len(split) - for key, count in errors.items(): - aggregated_errors[key] = aggregated_errors.get(key, 0) + count - print(f" {p.dump()}") - - print("\n## 4. 集約") - print(f" serial avg={statistics.mean(all_serial)*1000:.0f}ms (total of 3 sub-tasks)") - print(f" parallel avg={statistics.mean(all_parallel)*1000:.0f}ms (asyncio.gather)") - speedup = statistics.mean(all_serial) / statistics.mean(all_parallel) if all_parallel else 0 - print(f" speedup x{speedup:.2f}") - - total_errors = sum(aggregated_errors.values()) - error_rate = (total_errors / total_requests * 100) if total_requests else 0 - print( - f" parallel errors: {total_errors}/{total_requests} ({error_rate:.1f}%)" - ) - for key, count in sorted(aggregated_errors.items(), key=lambda kv: -kv[1]): - print(f" - {key}: {count}") - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="AI キャリア分析 Phase 0 計測") - p.add_argument("--user-id", required=True, help="計測対象ユーザーの user_id") - p.add_argument("--target", required=True, help="ターゲットポジション(例: SRE)") - p.add_argument("--runs", type=int, default=3, help="レイテンシ計測の試行回数") - p.add_argument("--no-call", action="store_true", help="LLM を呼ばずプロンプトサイズだけ確認") - return p.parse_args() - - -def main() -> None: - args = parse_args() - # SessionLocal が SQLITE_DB_PATH を見るため念のためエクスポート確認 - if not os.environ.get("SQLITE_DB_PATH"): - print("[warn] SQLITE_DB_PATH 未設定(local.sqlite を見にいきます)", file=sys.stderr) - asyncio.run(main_async(args)) - - -if __name__ == "__main__": - main() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 35284d1f..33038155 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -42,9 +42,6 @@ def _generate_test_rsa_keys() -> tuple[str, str]: os.environ.setdefault("FIELD_ENCRYPTION_KEY", "pVo6M_raAWEpAv25F4p4RziywsjfPENokI10DZbNO7E=") os.environ.setdefault("CORS_ORIGINS", "http://localhost:8788") os.environ.setdefault("TASK_RUNNER", "local") -os.environ.setdefault("OLLAMA_BASE_URL", "http://localhost:11434") -os.environ.setdefault("OLLAMA_MODEL", "gemma3:4b") -os.environ.setdefault("OLLAMA_TIMEOUT", "1200") os.environ.setdefault("UPSTASH_REDIS_URL", "redis://redis:6379") os.environ.setdefault("UPSTASH_REDIS_TOKEN", "") os.environ.setdefault("GCP_PROJECT_ID", "") @@ -55,10 +52,6 @@ def _generate_test_rsa_keys() -> tuple[str, str]: os.environ.setdefault("COOKIE_SECURE", "true") os.environ.setdefault("COOKIE_SAMESITE", "lax") os.environ.setdefault("ADMIN_TOKEN", "test-admin-token") -os.environ.setdefault("LLM_PROVIDER", "ollama") -os.environ.setdefault("VERTEX_PROJECT_ID", "") -os.environ.setdefault("VERTEX_LOCATION", "") -os.environ.setdefault("VERTEX_MODEL", "") os.environ.setdefault("ENVIRONMENT", "local") os.environ.setdefault("INTERNAL_SECRET", "") os.environ.setdefault("TASK_MAX_ATTEMPTS", "3") @@ -97,7 +90,7 @@ def session_factory(tmp_path): ハンドラの ``run(session_factory, payload)`` 引数に渡す前提。本番の ``SessionLocal`` と同様に「呼ぶたびに新セッション」を提供する。 - libSQL の Hrana 失効回避策で、ハンドラは LLM 前後でセッションを開閉する設計に + libSQL の Hrana 失効回避策で、ハンドラは外部 API 前後でセッションを開閉する設計に なっており、テストでも同じインターフェイスで呼べるよう本ファクトリを使う。 ``expire_on_commit=False`` は本番 ``SessionLocal`` と揃える。 """ diff --git a/backend/tests/services/llm/__init__.py b/backend/tests/services/llm/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/services/llm/test_sanitizer.py b/backend/tests/services/llm/test_sanitizer.py deleted file mode 100644 index 9e306b30..00000000 --- a/backend/tests/services/llm/test_sanitizer.py +++ /dev/null @@ -1,159 +0,0 @@ -"""sanitizer モジュールのユニットテスト。""" - -from app.services.llm.sanitizer import ( - SanitizeContext, - sanitize_project_name, - sanitize_text, - strip_prohibited_fields, -) - -# ============================================================ -# strip_prohibited_fields -# ============================================================ - - -def test_prohibited_fields_are_stripped(): - """strip_prohibited_fields が C分類フィールドを除去する。 - - 住所・郵便番号・生年月日・電話番号・名前ふりがな・写真はシステム上入力されないため除外対象外。 - """ - data = { - "full_name": "山田太郎", - "email": "yamada@example.com", - "motivation": "貢献したい", - "personal_preferences": "朝型", - "username": "yamada_t", - # A分類(残るべきフィールド) - "skills": ["Python", "FastAPI"], - "repos_analyzed": 10, - } - result = strip_prohibited_fields(data) - - prohibited = { - "full_name", - "email", - "motivation", - "personal_preferences", - "username", - } - for field in prohibited: - assert field not in result, f"C分類フィールド '{field}' が残っている" - - assert "skills" in result - assert "repos_analyzed" in result - - -def test_strip_prohibited_fields_returns_new_dict(): - """strip_prohibited_fields は元の dict を変更せず新しい dict を返す。""" - data = {"username": "test", "repos_analyzed": 5} - result = strip_prohibited_fields(data) - assert "username" not in result - assert "username" in data # 元は変更されない - - -# ============================================================ -# sanitize_project_name -# ============================================================ - - -def test_project_name_is_anonymized(): - """sanitize_project_name が raw の案件名をラベルに変換する。""" - context = SanitizeContext() - raw_name = "ネット銀行基幹システム刷新" - label = sanitize_project_name(raw_name, context) - - assert label != raw_name - assert raw_name not in label - assert label == "[案件A]" - - -# ============================================================ -# sanitize_text -# ============================================================ - - -def test_career_summary_is_masked(): - """career_summary 内の既知企業名・案件名がマスキングされる。""" - context = SanitizeContext() - context.register_company("株式会社サンプル") - context.register_project("決済基盤刷新プロジェクト") - - summary = "株式会社サンプルに勤務し、決済基盤刷新プロジェクトを担当しました。" - masked = sanitize_text(summary, context) - - assert "株式会社サンプル" not in masked - assert "決済基盤刷新プロジェクト" not in masked - assert "[企業A]" in masked - assert "[案件A]" in masked - - -def test_sanitize_text_no_op_on_empty_context(): - """空のコンテキストでは sanitize_text はテキストをそのまま返す。""" - context = SanitizeContext() - text = "これはテストテキストです。" - assert sanitize_text(text, context) == text - - -def test_sanitize_text_empty_string(): - """sanitize_text に空文字列を渡しても例外が発生しない。""" - context = SanitizeContext() - assert sanitize_text("", context) == "" - - -def test_sanitize_text_none_input(): - """sanitize_text に None を渡しても例外が発生しない。""" - context = SanitizeContext() - assert sanitize_text(None, context) == "" - - -def test_sanitize_text_longer_name_takes_priority(): - """長い名前から置換することで短い名前の部分一致誤変換を防ぐ。""" - context = SanitizeContext() - context.register_company("株式会社テスト") - context.register_company("株式会社テストグループ") - - text = "株式会社テストグループの案件を担当した。" - masked = sanitize_text(text, context) - - # 「株式会社テスト」で途中まで置換され「グループ」が残らないことを確認 - assert "株式会社テストグループ" not in masked - # 長い名前のラベルで正しく置換されていること - assert "[企業B]" in masked - - -# ============================================================ -# SanitizeContext 一貫性 -# ============================================================ - - -def test_context_consistency(): - """同一 SanitizeContext 内で同じ企業名は同じラベルに変換される。""" - context = SanitizeContext() - label1 = context.register_company("株式会社テスト") - label2 = context.register_company("株式会社テスト") - - assert label1 == label2 - assert label1 == "[企業A]" - - label3 = context.register_company("別会社株式会社") - assert label3 == "[企業B]" - assert label1 != label3 - - -def test_context_separate_categories(): - """異なるカテゴリは独立した採番を持つ。""" - context = SanitizeContext() - company_label = context.register_company("株式会社テスト") - customer_label = context.register_customer("株式会社テスト") - - # 同じ raw 名でもカテゴリが違えば別ラベル - assert company_label == "[企業A]" - assert customer_label == "[顧客A]" - - -def test_context_empty_name_not_registered(): - """空文字はコンテキストに登録されない。""" - context = SanitizeContext() - result = context.register_company("") - assert result == "" - assert len(context.companies) == 0 diff --git a/backend/tests/test_llm_clients.py b/backend/tests/test_llm_clients.py deleted file mode 100644 index d27a0657..00000000 --- a/backend/tests/test_llm_clients.py +++ /dev/null @@ -1,212 +0,0 @@ -"""LLM クライアント抽象化レイヤーのテスト。""" - -import asyncio -import os -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -from app.services.intelligence.llm.factory import get_llm_client -from app.services.intelligence.llm.ollama_client import OllamaClient -from app.services.intelligence.llm.vertex_client import DEFAULT_VERTEX_MODEL, VertexClient - - -def _run(coro): - """async 関数を同期的に実行するヘルパー。""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -# ---------- OllamaClient ---------- - - -def test_ollama_generate_success(): - """Ollama の正常系: レスポンスからテキストを取得する。""" - client = OllamaClient() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.raise_for_status = MagicMock() - mock_response.json.return_value = {"response": " テスト要約 "} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.post.return_value = mock_response - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_http - - result = _run(client.generate("system", "user")) - assert result == "テスト要約" - mock_http.post.assert_called_once() - - -def test_ollama_generate_connect_error(): - """Ollama 接続エラー時に None を返す。""" - client = OllamaClient() - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.post.side_effect = httpx.ConnectError("接続失敗") - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_http - - result = _run(client.generate("system", "user")) - assert result is None - - -def test_ollama_check_available_success(): - """Ollama ヘルスチェック: 指定モデルが存在する場合に True を返す。""" - client = OllamaClient() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"models": [{"name": client.model}]} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.get.return_value = mock_response - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_http - - assert _run(client.check_available()) is True - - -def test_ollama_check_available_model_not_found(): - """指定モデルが pull されていない場合に False を返す。""" - client = OllamaClient() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"models": [{"name": "other_model:7b"}]} - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.get.return_value = mock_response - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_http - - assert _run(client.check_available()) is False - - -def test_ollama_check_available_timeout(): - """Ollama ヘルスチェック: タイムアウトで False を返す。""" - client = OllamaClient() - - with patch("httpx.AsyncClient") as mock_cls: - mock_http = AsyncMock() - mock_http.get.side_effect = httpx.TimeoutException("タイムアウト") - mock_http.__aenter__ = AsyncMock(return_value=mock_http) - mock_http.__aexit__ = AsyncMock(return_value=False) - mock_cls.return_value = mock_http - - assert _run(client.check_available()) is False - - -def test_ollama_uses_lightweight_defaults(): - """既定では軽量モデルと長めのタイムアウトを使う。""" - env = os.environ.copy() - env.pop("OLLAMA_MODEL", None) - env.pop("OLLAMA_TIMEOUT", None) - - with patch.dict(os.environ, env, clear=True): - client = OllamaClient() - - assert client.model == "gemma3:4b" - assert client.timeout == 1200.0 - - -# ---------- VertexClient ---------- - - -def test_vertex_generate_success(): - """Vertex AI の正常系: google-genai Client をモックしてテキスト取得。""" - with patch.dict(os.environ, {"VERTEX_PROJECT_ID": "test-project"}): - client = VertexClient() - - mock_response = MagicMock() - mock_response.text = " テスト要約 " - - mock_generate = AsyncMock(return_value=mock_response) - mock_models = MagicMock() - mock_models.generate_content = mock_generate - mock_aio = MagicMock() - mock_aio.models = mock_models - mock_genai_client = MagicMock() - mock_genai_client.aio = mock_aio - - client._client = mock_genai_client - result = _run(client.generate("system", "user")) - assert result == "テスト要約" - mock_generate.assert_called_once() - - -def test_vertex_generate_exception(): - """Vertex AI で例外発生時に None を返す。""" - with patch.dict(os.environ, {"VERTEX_PROJECT_ID": "test-project"}): - client = VertexClient() - - mock_generate = AsyncMock(side_effect=Exception("API エラー")) - mock_models = MagicMock() - mock_models.generate_content = mock_generate - mock_aio = MagicMock() - mock_aio.models = mock_models - mock_genai_client = MagicMock() - mock_genai_client.aio = mock_aio - - client._client = mock_genai_client - result = _run(client.generate("system", "user")) - assert result is None - - -def test_vertex_check_available_with_project(): - """VERTEX_PROJECT_ID があれば True。""" - with patch.dict(os.environ, {"VERTEX_PROJECT_ID": "test-project"}): - client = VertexClient() - assert _run(client.check_available()) is True - - -def test_vertex_check_available_without_project(): - """VERTEX_PROJECT_ID がなければ False。""" - env = os.environ.copy() - env.pop("VERTEX_PROJECT_ID", None) - with patch.dict(os.environ, env, clear=True): - client = VertexClient() - assert _run(client.check_available()) is False - - -def test_vertex_uses_supported_default_model(): - """VERTEX_MODEL 未設定時はサポート中の既定モデルを使う。""" - env = os.environ.copy() - env.pop("VERTEX_MODEL", None) - with patch.dict(os.environ, env, clear=True): - client = VertexClient() - assert client.model_name == DEFAULT_VERTEX_MODEL - - -# ---------- ファクトリ ---------- - - -def test_factory_returns_ollama_by_default(): - """デフォルトで OllamaClient を返す。""" - env = os.environ.copy() - env.pop("LLM_PROVIDER", None) - with patch.dict(os.environ, env, clear=True): - client = get_llm_client() - assert isinstance(client, OllamaClient) - - -def test_factory_returns_ollama_explicitly(): - """LLM_PROVIDER=ollama で OllamaClient を返す。""" - with patch.dict(os.environ, {"LLM_PROVIDER": "ollama"}): - client = get_llm_client() - assert isinstance(client, OllamaClient) - - -def test_factory_returns_vertex(): - """LLM_PROVIDER=vertex で VertexClient を返す。""" - with patch.dict(os.environ, {"LLM_PROVIDER": "vertex"}): - client = get_llm_client() - assert isinstance(client, VertexClient) diff --git a/backend/tests/test_prompt_loader.py b/backend/tests/test_prompt_loader.py deleted file mode 100644 index bf4bcb6d..00000000 --- a/backend/tests/test_prompt_loader.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from app.utils import prompt_loader -from app.utils.prompt_loader import load_prompt - - -def test_load_prompt_success(tmp_path, monkeypatch): - """プロンプトファイルが正常に読み込めること(前後の空白は除去される)。""" - # 実プロンプトは将来の LLM 機能向けに空のため、テスト用ファイルを一時生成して検証する - prompt_file = tmp_path / "sample.md" - prompt_file.write_text(" GitHub 連携プロンプト \n", encoding="utf-8") - monkeypatch.setattr(prompt_loader, "PROMPTS_DIR", tmp_path) - - content = load_prompt("sample.md") - assert content == "GitHub 連携プロンプト" - - -def test_load_prompt_not_found(): - """存在しないファイルを指定した場合に FileNotFoundError が発生すること。""" - with pytest.raises(FileNotFoundError): - load_prompt("non_existent_file.md") diff --git a/docker-compose.yml b/docker-compose.yml index 8a456639..510fe8c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,6 @@ services: container_name: devforge-api restart: unless-stopped depends_on: - ollama: - condition: service_healthy redis: condition: service_healthy libsql: @@ -21,20 +19,11 @@ services: JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - LLM_PROVIDER: ${LLM_PROVIDER} - # OLLAMA_* の名前の正本は backend/app/core/env_keys.py(OLLAMA_BASE_URL / OLLAMA_MODEL / OLLAMA_TIMEOUT)。 - # 下記 ollama サービスの OLLAMA_KEEP_ALIVE は Ollama サーバ自身の設定で backend は参照しない。 - OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} - OLLAMA_MODEL: ${OLLAMA_MODEL} - OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT} UPSTASH_REDIS_URL: ${UPSTASH_REDIS_URL} UPSTASH_REDIS_TOKEN: ${UPSTASH_REDIS_TOKEN} COOKIE_SECURE: ${COOKIE_SECURE} COOKIE_SAMESITE: ${COOKIE_SAMESITE} ADMIN_TOKEN: ${ADMIN_TOKEN} - VERTEX_PROJECT_ID: ${VERTEX_PROJECT_ID} - VERTEX_LOCATION: ${VERTEX_LOCATION} - VERTEX_MODEL: ${VERTEX_MODEL} TASK_RUNNER: ${TASK_RUNNER} GCP_PROJECT_ID: ${GCP_PROJECT_ID} CLOUD_TASKS_QUEUE: ${CLOUD_TASKS_QUEUE} @@ -54,22 +43,6 @@ services: extra_hosts: - "host.docker.internal:host-gateway" - ollama: - image: ollama/ollama:latest - container_name: devforge-ollama - restart: unless-stopped - environment: - OLLAMA_KEEP_ALIVE: 30m - ports: - - "11434:11434" - volumes: - - ollama_data:/root/.ollama - healthcheck: - test: ["CMD", "ollama", "ps"] - interval: 5s - timeout: 5s - retries: 15 - redis: image: redis:7-alpine container_name: devforge-redis @@ -97,5 +70,4 @@ services: - libsql_data:/var/lib/sqld volumes: - ollama_data: libsql_data: diff --git a/docs/adr/0004-llm-provider-abstraction.md b/docs/adr/0004-llm-provider-abstraction.md index a9dfd7c4..45f0081c 100644 --- a/docs/adr/0004-llm-provider-abstraction.md +++ b/docs/adr/0004-llm-provider-abstraction.md @@ -2,7 +2,7 @@ ## ステータス -Accepted +Superseded by ADR-0008 ## コンテキスト @@ -95,3 +95,7 @@ Vertex AI は「設定されていれば動く前提」、Ollama は「起動し ## 関連リンク - [ADR-0004 PR](https://github.com/yusuke0610/devforge/pulls) + +--- + +2026-05-31: LLM をサービス内で使う可能性が低くルールベース設計に統一したため、本 ADR で定義した LLM プロバイダ抽象化・関連 env・依存・ErrorCode を撤去した。詳細は [ADR-0008](0008-remove-llm-to-rule-based-design.md) を参照。 diff --git a/docs/adr/0008-remove-llm-to-rule-based-design.md b/docs/adr/0008-remove-llm-to-rule-based-design.md new file mode 100644 index 00000000..8fe27560 --- /dev/null +++ b/docs/adr/0008-remove-llm-to-rule-based-design.md @@ -0,0 +1,49 @@ +# ADR-0008: LLM プロバイダ抽象化の撤去とルールベース設計への統一 + +## ステータス + +Accepted + +## コンテキスト + +ADR-0004 で LLM プロバイダ抽象化(Ollama / Vertex AI、`services/intelligence/llm/`)を導入したが、その後の方針転換により状況が変わった。 + +- LLM を利用していた機能(キャリア分析 AI・職務経歴書 PDF の AI 抽出)は既に削除済みで、現在のサービスはルールベース(決定論的パイプライン)設計に統一されている。 +- 残った `services/intelligence/llm/`(OllamaClient / VertexClient / factory)は本番フロー(ルーター・タスクハンドラ・サービス)から一切呼ばれておらず、参照はテストとゴーストスクリプトのみの **休眠コード**になっている。 +- 付随して、LLM 用環境変数 7 種(`LLM_PROVIDER` / `VERTEX_*` / `OLLAMA_*`)、`google-genai` 依存、LLM 用 ErrorCode(`LLM_TIMEOUT` / `LLM_UNAVAILABLE`)、サニタイザ・プロンプトローダ・`app/prompts/` も発火元を失った dead code として残存している。 +- 今後 LLM をサービス内で使う可能性は低いと判断した。 + +休眠コードを残すと、依存の脆弱性追従コスト・誤った「使われている」前提・env 同期の負担が積み上がる。 + +## 決定内容 + +LLM プロバイダ抽象化と関連資産を全て撤去し、ルールベース設計に一本化する。 + +撤去対象: + +- **コード**: `services/intelligence/llm/`、`services/llm/`(サニタイザ)、`utils/prompt_loader.py`、`app/prompts/`、`scripts/measure_career_analysis.py`、`settings.py` の LLM ゲッター 4 関数 +- **環境変数**: `LLM_PROVIDER` / `VERTEX_PROJECT_ID` / `VERTEX_LOCATION` / `VERTEX_MODEL` / `OLLAMA_BASE_URL` / `OLLAMA_MODEL` / `OLLAMA_TIMEOUT`(env_keys.py / cloud_run / docker-compose / docs/api.md / conftest.py から削除) +- **依存**: `google-genai` +- **ErrorCode**: `LLM_TIMEOUT` / `LLM_UNAVAILABLE`(backend errors.py の enum・分類ロジック、frontend の errorCodes.ts / errorMessages.ts) +- **ルール**: `.claude/rules/backend/llm.md` + +これに伴い ADR-0004 を `Superseded by ADR-0008` とする。 + +## 代替案 + +- **休眠のまま温存する**: 将来 LLM を使う余地を残せるが、未使用コードの保守・依存追従コストが継続し、`generate()` が失敗時に空文字を返す既知のリスク(ADR-0004)も塩漬けになる。利用見込みが低い以上、温存の利点は薄いと判断し却下。 +- **コードだけ消して env / ErrorCode は残す**: 中途半端な dead code が残り、env 同期ルール(4+1 箇所)の対象に居座る。完全撤去の方が状態が単純になるため却下。 + +## トレードオフ・既知のリスク + +- 将来 LLM を再導入する場合は、抽象化層・env・依存をゼロから再構築する必要がある(ただし git 履歴と ADR-0004 から設計を復元可能)。 +- 公開 ErrorCode 2 種の削除は OpenAPI 生成物・フロントエンドの型 union に波及する破壊的変更。外部にこの API を消費するクライアントがいないことを前提とする。 + +## 将来の移行条件 + +- LLM 利用を再開する場合は、本 ADR を `Superseded` とした上で新規 ADR を起票し、ADR-0004 の設計(プロバイダ抽象化・`generate()` の契約)を再評価して再構築する。 +- その際は ADR-0004 で積み残した「LLM 失敗を UI に伝達できない」「`check_available()` の非対称性」を設計段階で解消すること。 + +## 関連リンク + +- [ADR-0004: LLM プロバイダ抽象化(Ollama/Vertex AI)の設計判断](0004-llm-provider-abstraction.md) diff --git a/docs/api.md b/docs/api.md index 40677876..7c17bd3f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -106,17 +106,6 @@ REST API エンドポイント一覧と、バックエンド/フロントエ |---|---| | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub OAuth クライアント情報 | -### LLM(Ollama / Vertex AI) - -| 変数 | 用途 | -|---|---| -| `LLM_PROVIDER` | `ollama` または `vertex` | -| `OLLAMA_BASE_URL` | Ollama エンドポイント(`LLM_PROVIDER=ollama` 時必須) | -| `OLLAMA_MODEL` | Ollama 利用時のモデル名(デフォルト: `gemma3:4b`) | -| `OLLAMA_TIMEOUT` | Ollama 生成タイムアウト秒数(デフォルト: `1200`) | -| `VERTEX_PROJECT_ID` / `VERTEX_LOCATION` | Vertex AI 利用時の設定 | -| `VERTEX_MODEL` | Vertex AI 利用時のモデル名(デフォルト: `gemini-2.5-flash-lite`) | - ### 非同期タスク(Cloud Tasks) | 変数 | 用途 | diff --git a/docs/development.md b/docs/development.md index e20c28a6..ccd09bbd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,7 +37,7 @@ make generate-keys # JWT RS256 鍵ペアを生成 cp backend/.env.example backend/.env # 環境変数を埋める ``` -#### Docker 起動(推奨: FastAPI + Ollama + Redis + libSQL) +#### Docker 起動(推奨: FastAPI + Redis + libSQL) ```bash make dev # docker compose up @@ -48,14 +48,11 @@ make dev-down # 停止 `docker-compose.yml` で以下のサービスをまとめて起動する: - `api`: FastAPI(`backend/Dockerfile` をビルド) -- `ollama`: LLM ランタイム(`ollama/ollama:latest`、`gemma3:4b` を自動 pull) - `redis`: レート制限・キャッシュ - `libsql`: libSQL サーバー(`ghcr.io/tursodatabase/libsql-server`)。`/var/lib/sqld` を `libsql_data` ボリュームに永続化 DB 接続先は compose 内で `TURSO_DATABASE_URL=http://libsql:8080` に固定されている。 -> **方針**: Ollama はコンテナまたは Nix 環境内で動かす想定。ホスト OS に直接 `ollama serve` する運用はサポート外。 - #### フロントエンド単体起動(バックエンドは docker / 別途) ```bash diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index fe7eb288..a149636e 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -149,8 +149,8 @@ describe("api/client request", () => { "fetch", vi.fn().mockResolvedValue( makeResponse(503, { - code: "LLM_UNAVAILABLE", - message: "AI 分析サービスが一時的に利用できません", + code: "GITHUB_RATE_LIMITED", + message: "GitHub API の制限に達しました(1時間あたりの上限)", action: "しばらく待ってから再試行してください", error_id: "err-1234", }), @@ -158,8 +158,8 @@ describe("api/client request", () => { ); await expect(request("/api/test")).rejects.toMatchObject({ - code: "LLM_UNAVAILABLE", - message: "AI 分析サービスが一時的に利用できません", + code: "GITHUB_RATE_LIMITED", + message: "GitHub API の制限に達しました(1時間あたりの上限)", errorId: "err-1234", } satisfies Partial); }); @@ -170,13 +170,13 @@ describe("api/client request", () => { "fetch", vi.fn().mockResolvedValue( makeResponse(503, { - detail: "AI キャリアパス分析サービスが利用できません。LLM の設定または接続状態を確認してください。", + detail: "外部サービスが一時的に利用できません。接続状態を確認してください。", }), ), ); await expect(request("/api/test")).rejects.toThrow( - "AI キャリアパス分析サービスが利用できません。LLM の設定または接続状態を確認してください。", + "外部サービスが一時的に利用できません。接続状態を確認してください。", ); }); diff --git a/frontend/src/components/github-link/GitHubLinkDashboard.test.tsx b/frontend/src/components/github-link/GitHubLinkDashboard.test.tsx index ed64eb47..179d302c 100644 --- a/frontend/src/components/github-link/GitHubLinkDashboard.test.tsx +++ b/frontend/src/components/github-link/GitHubLinkDashboard.test.tsx @@ -169,8 +169,8 @@ describe("GitHubLinkDashboard", () => { http.post("*/api/github-link/run", () => HttpResponse.json( { - code: "LLM_UNAVAILABLE", - message: "AI 分析サービスが一時的に利用できません", + code: "INTERNAL_ERROR", + message: "分析サービスが一時的に利用できません", error_id: "err-ui-500", }, { status: 503 }, @@ -183,7 +183,7 @@ describe("GitHubLinkDashboard", () => { await waitFor(() => { // エラーメッセージが表示されること(アプリがクラッシュしないこと) expect( - screen.getByText(/AI 分析サービスが一時的に利用できません/), + screen.getByText(/分析サービスが一時的に利用できません/), ).toBeInTheDocument(); expect(screen.getByText(/エラーID: err-ui-500/)).toBeInTheDocument(); }); diff --git a/frontend/src/constants/errorCodes.ts b/frontend/src/constants/errorCodes.ts index 932fb0b9..fcdb8f59 100644 --- a/frontend/src/constants/errorCodes.ts +++ b/frontend/src/constants/errorCodes.ts @@ -23,9 +23,6 @@ export const ERROR_CODES = [ // GitHub "GITHUB_RATE_LIMITED", "GITHUB_USER_NOT_FOUND", - // LLM - "LLM_TIMEOUT", - "LLM_UNAVAILABLE", // バリデーション "VALIDATION_ERROR", // 外部 API diff --git a/frontend/src/constants/errorMessages.ts b/frontend/src/constants/errorMessages.ts index 7b8934ca..b8490fa1 100644 --- a/frontend/src/constants/errorMessages.ts +++ b/frontend/src/constants/errorMessages.ts @@ -35,14 +35,6 @@ export const ERROR_CONFIG: Record< message: "GitHub ユーザーが見つかりません", recovery: { label: "ユーザー名を見直す", fn: null }, }, - LLM_TIMEOUT: { - message: "AI 分析がタイムアウトしました", - recovery: { label: "再分析する", fn: null }, - }, - LLM_UNAVAILABLE: { - message: "AI 分析サービスが一時的に利用できません", - recovery: { label: "後で再試行", fn: null }, - }, QIITA_RATE_LIMITED: { message: "Qiita API の制限に達しました", recovery: { label: "1時間後に再試行", fn: null }, diff --git a/frontend/src/hooks/useTaskPolling.test.ts b/frontend/src/hooks/useTaskPolling.test.ts index 844001be..bfe2163d 100644 --- a/frontend/src/hooks/useTaskPolling.test.ts +++ b/frontend/src/hooks/useTaskPolling.test.ts @@ -50,7 +50,7 @@ describe("useTaskPolling", () => { it("dead_letter になったとき onFailed が呼ばれる", async () => { const checkStatus = vi.fn().mockResolvedValue({ status: "dead_letter", - error_message: "LLM タイムアウト", + error_message: "分析処理がタイムアウトしました", }); const { result, onCompleted, onFailed } = setup(checkStatus); @@ -62,7 +62,7 @@ describe("useTaskPolling", () => { expect(onFailed).toHaveBeenCalledWith( expect.objectContaining({ code: "INTERNAL_ERROR", - message: "LLM タイムアウト", + message: "分析処理がタイムアウトしました", }), ); }); diff --git a/frontend/src/test/handlers.ts b/frontend/src/test/handlers.ts index 0e203bfc..1f9c545e 100644 --- a/frontend/src/test/handlers.ts +++ b/frontend/src/test/handlers.ts @@ -73,7 +73,7 @@ export const errorHandlers = { () => HttpResponse.json({ status: "dead_letter", - error_message: "LLM タイムアウト", + error_message: "分析処理がタイムアウトしました", }), ), }; diff --git a/infra/environments/prod/ENV_CHECKLIST.md b/infra/environments/prod/ENV_CHECKLIST.md index 868a8fde..3ebc4d72 100644 --- a/infra/environments/prod/ENV_CHECKLIST.md +++ b/infra/environments/prod/ENV_CHECKLIST.md @@ -21,10 +21,6 @@ |---|---|---| | GITHUB_CLIENT_ID | GitHub OAuth Client ID | prod用アプリを別途登録すること | | GITHUB_CLIENT_SECRET | GitHub OAuth Client Secret | prod用アプリを別途登録すること | -| LLM_PROVIDER | LLMプロバイダ | `vertex` | -| VERTEX_PROJECT_ID | Vertex AI 用 GCP プロジェクトID | `devforge-prod` | -| VERTEX_LOCATION | Vertex AI リージョン | `asia-northeast1` | -| VERTEX_MODEL | 使用モデル | ⚠️ `gemini-2.5-flash-lite` は2026/7/22退役。後継: `gemini-2.0-flash` | ## 手動作業チェックリスト diff --git a/infra/environments/stg/ENV_CHECKLIST.md b/infra/environments/stg/ENV_CHECKLIST.md index f8f17e75..c4334ee3 100644 --- a/infra/environments/stg/ENV_CHECKLIST.md +++ b/infra/environments/stg/ENV_CHECKLIST.md @@ -21,10 +21,6 @@ |---|---|---| | GITHUB_CLIENT_ID | GitHub OAuth Client ID | stg用アプリを別途登録すること | | GITHUB_CLIENT_SECRET | GitHub OAuth Client Secret | stg用アプリを別途登録すること | -| LLM_PROVIDER | LLMプロバイダ | `vertex` | -| VERTEX_PROJECT_ID | Vertex AI 用 GCP プロジェクトID | `devforge-stg` | -| VERTEX_LOCATION | Vertex AI リージョン | `asia-northeast1` | -| VERTEX_MODEL | 使用モデル | ⚠️ `gemini-2.5-flash-lite` は2026/7/22退役。後継: `gemini-2.0-flash` | ## 手動作業チェックリスト diff --git a/infra/modules/cloud_run/main.tf b/infra/modules/cloud_run/main.tf index a59dfba2..1810c857 100644 --- a/infra/modules/cloud_run/main.tf +++ b/infra/modules/cloud_run/main.tf @@ -88,23 +88,6 @@ resource "google_cloud_run_v2_service" "app" { value = var.environment } - env { - name = "LLM_PROVIDER" - value = "vertex" - } - env { - name = "VERTEX_PROJECT_ID" - value = var.project_id - } - env { - name = "VERTEX_LOCATION" - value = var.region - } - env { - name = "VERTEX_MODEL" - value = "gemini-2.5-flash-lite" - } - env { name = "TASK_RUNNER" value = var.task_runner