From 08370948ae71f4b0645b077cf6ba3145e750de69 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 15 May 2026 23:25:45 +0900 Subject: [PATCH] code rabbit RV fix --- .claude/rules/infra/terraform.md | 2 +- .gitignore | 4 -- Makefile | 2 +- README.md | 21 +-------- ...arning_message_to_github_analysis_cache.py | 26 +++++++++++ backend/app/core/errors.py | 4 +- backend/app/core/security/auth.py | 15 ++++++ backend/app/db/bootstrap.py | 37 +-------------- backend/app/db/seeds/technology_stacks.json | 12 ++--- backend/app/models/cache.py | 3 ++ backend/app/repositories/blog.py | 21 +++++++++ backend/app/routers/blog.py | 29 ++++++------ backend/app/routers/career_analysis.py | 9 +++- backend/app/routers/intelligence.py | 16 +++++-- backend/app/schemas/intelligence.py | 2 + .../intelligence/github_analysis_service.py | 14 ++++-- .../intelligence/position_weights.json | 2 +- .../services/intelligence/skill_extractor.py | 2 +- .../app/services/tasks/dispatch_service.py | 13 ++++++ .../services/tasks/handlers/blog_summarize.py | 5 +- .../tasks/handlers/career_analysis.py | 2 +- backend/app/services/tasks/worker.py | 12 +++-- backend/pyproject.toml | 7 +++ backend/tests/test_intelligence.py | 6 +-- backend/tests/test_worker_extended.py | 46 ++++++++++++------- flake.nix | 3 -- frontend/src/api/intelligence.ts | 2 + .../src/components/blog/BlogArticleList.tsx | 1 + .../src/components/blog/BlogPlatformList.tsx | 13 ++++-- .../result/CareerAnalysisResultView.tsx | 3 +- .../src/hooks/useBlogAccountManager.test.ts | 39 ---------------- .../src/hooks/useCareerExperienceMutators.ts | 3 +- pyrightconfig.json | 11 +++++ 33 files changed, 217 insertions(+), 170 deletions(-) create mode 100644 backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py create mode 100644 pyrightconfig.json diff --git a/.claude/rules/infra/terraform.md b/.claude/rules/infra/terraform.md index 149dc937..1b1746d9 100644 --- a/.claude/rules/infra/terraform.md +++ b/.claude/rules/infra/terraform.md @@ -5,7 +5,7 @@ paths: # Infrastructure (Terraform) -``` +```text infra/ ├── modules/ # cloud_run, artifact_registry, cloud_tasks, cloudflare, monitoring, service_account └── environments/ # dev, stg, prod(各環境で tfvars 管理) diff --git a/.gitignore b/.gitignore index d749b834..20e948a9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,6 @@ backend/local.sqlite-wal frontend/.env.local backend/.env -# Firebase(削除済みだが生成物の混入を防ぐために保持) -.firebase/ -firebase-debug.log - # Wrangler .wrangler/ diff --git a/Makefile b/Makefile index 3b65642a..58e30cce 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: help \ setup install-hooks install-backend install-frontend generate-keys \ - dev dev-build dev-down dev-frontend preview-frontend \ + dev dev-build dev-down dev-frontend preview-frontend dev-proxy dev-proxy-only \ test test-backend test-frontend \ lint lint-backend lint-frontend lint-fix \ format format-check \ diff --git a/README.md b/README.md index 6733c3a2..c72bdf39 100644 --- a/README.md +++ b/README.md @@ -135,26 +135,7 @@ docker compose up `docker compose up libsql` だけ起動すれば、ホストの `127.0.0.1:8080` に libSQL サーバーが公開されます。 `backend/.env` で以下を設定すれば、ホストの uvicorn から接続できます。 -``` -TURSO_DATABASE_URL=http://127.0.0.1:8080 -TURSO_AUTH_TOKEN= -``` - -##### TablePlus からローカル libSQL に接続する - -1. TablePlus で **新規接続** → **libSQL** を選択 -2. **URL** に `http://127.0.0.1:8080` を指定(`docker compose up libsql` 経由) -3. **Token** は空のままで OK -4. **テスト** → **接続** - -> **注意**: 旧 SQLite ファイル方式(`data/devforge.sqlite` の bind mount, DBeaver の SQLite 直接接続)は廃止しました。 - -#### Turso (libSQL) ローカル起動 - -`docker compose up libsql` だけ起動すれば、ホストの `127.0.0.1:8080` に libSQL サーバーが公開されます。 -`backend/.env` で以下を設定すれば、ホストの uvicorn から接続できます。 - -``` +```env TURSO_DATABASE_URL=http://127.0.0.1:8080 TURSO_AUTH_TOKEN= ``` diff --git a/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py b/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py new file mode 100644 index 00000000..a377af0f --- /dev/null +++ b/backend/alembic_migrations/versions/0031_add_warning_message_to_github_analysis_cache.py @@ -0,0 +1,26 @@ +"""add warning_message to github_analysis_cache + +Revision ID: 0031_add_warning_message_to_github_analysis_cache +Revises: 0030_add_expires_at_to_blog_summary_cache +Create Date: 2026-05-15 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0031_add_warning_message_to_github_analysis_cache" +down_revision: Union[str, None] = "0030_add_expires_at_to_blog_summary_cache" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("github_analysis_cache") as batch_op: + batch_op.add_column(sa.Column("warning_message", sa.Text(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("github_analysis_cache") as batch_op: + batch_op.drop_column("warning_message") diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 24cd4584..a86f6791 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any +from typing import Any, NoReturn from uuid import uuid4 from fastapi import HTTPException @@ -66,7 +66,7 @@ def raise_app_error( action: str | None = None, retry_after: int | None = None, headers: dict[str, str] | None = None, -) -> None: +) -> NoReturn: raise HTTPException( status_code=status_code, detail=build_app_error_response( diff --git a/backend/app/core/security/auth.py b/backend/app/core/security/auth.py index 7f2bf1fe..696d03c6 100644 --- a/backend/app/core/security/auth.py +++ b/backend/app/core/security/auth.py @@ -30,6 +30,21 @@ _ALGORITHM = "RS256" +def validate_jwt_key_pair() -> None: + """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" + try: + tok = jwt.encode( + {"sub": "__bootstrap_check__"}, + get_jwt_private_key(), + algorithm=_ALGORITHM, + ) + jwt.decode(tok, get_jwt_public_key(), algorithms=[_ALGORITHM]) + except JWTError as e: + raise RuntimeError( + f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" + ) from e + + def create_access_token(username: str) -> str: """短命のアクセストークン(15分)を生成する。""" expire = datetime.now(timezone.utc) + timedelta(minutes=_ACCESS_TOKEN_EXPIRE_MINUTES) diff --git a/backend/app/db/bootstrap.py b/backend/app/db/bootstrap.py index 37e67da0..753b1390 100644 --- a/backend/app/db/bootstrap.py +++ b/backend/app/db/bootstrap.py @@ -2,45 +2,12 @@ from datetime import datetime, timezone from ..core.logging_utils import log_event +from ..core.security.auth import validate_jwt_key_pair from .migrations import run_migrations -def _validate_jwt_keys() -> None: - """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" - from jose import JWTError, jwt - - from ..core.settings import get_jwt_private_key, get_jwt_public_key - - try: - priv = get_jwt_private_key() - pub = get_jwt_public_key() - tok = jwt.encode({"sub": "__bootstrap_check__"}, priv, algorithm="RS256") - jwt.decode(tok, pub, algorithms=["RS256"]) - except JWTError as e: - raise RuntimeError( - f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" - ) from e - - -def _validate_jwt_keys() -> None: - """起動時に JWT 鍵ペアの整合性を検証する。署名 → 検証が通らない場合は RuntimeError を送出する。""" - from jose import JWTError, jwt - - from ..core.settings import get_jwt_private_key, get_jwt_public_key - - try: - priv = get_jwt_private_key() - pub = get_jwt_public_key() - tok = jwt.encode({"sub": "__bootstrap_check__"}, priv, algorithm="RS256") - jwt.decode(tok, pub, algorithms=["RS256"]) - except JWTError as e: - raise RuntimeError( - f"JWT 鍵ペアが不正です(秘密鍵と公開鍵が一致しないか、フォーマットが壊れています): {e}" - ) from e - - def bootstrap() -> None: - _validate_jwt_keys() + validate_jwt_key_pair() # Turso (libSQL) がデータ永続化を担うため、起動時の DB 復元処理は不要 run_migrations() log_event(logging.INFO, "bootstrap_migration_succeeded") diff --git a/backend/app/db/seeds/technology_stacks.json b/backend/app/db/seeds/technology_stacks.json index 7f6e0e1a..b9004195 100644 --- a/backend/app/db/seeds/technology_stacks.json +++ b/backend/app/db/seeds/technology_stacks.json @@ -612,31 +612,31 @@ { "category": "middleware", "name": "Nginx", - "sort_order": 121 + "sort_order": 123 }, { "category": "middleware", "name": "Apache", - "sort_order": 122 + "sort_order": 124 }, { "category": "middleware", "name": "Hulft", - "sort_order": 123 + "sort_order": 125 }, { "category": "ai_agent", "name": "ChatGPT", - "sort_order": 124 + "sort_order": 126 }, { "category": "ai_agent", "name": "Claude", - "sort_order": 125 + "sort_order": 127 }, { "category": "ai_agent", "name": "Gemini", - "sort_order": 126 + "sort_order": 128 } ] \ No newline at end of file diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py index d35c514b..7d992caa 100644 --- a/backend/app/models/cache.py +++ b/backend/app/models/cache.py @@ -21,6 +21,9 @@ class GitHubAnalysisCache(Base): position_advice: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="completed", server_default="completed") error_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) + # LLM 失敗のような「分析自体は完了したが部分的に欠落した」非致命的状況を残す。 + # error_message は真の失敗のみに使う。 + warning_message: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3, server_default="3") next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/backend/app/repositories/blog.py b/backend/app/repositories/blog.py index 0be64551..b8a7c284 100644 --- a/backend/app/repositories/blog.py +++ b/backend/app/repositories/blog.py @@ -2,6 +2,7 @@ from typing import Any from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from ..core.date_utils import parse_iso_date @@ -223,6 +224,26 @@ def get(self) -> BlogSummaryCache | None: return None return cache + def get_or_create(self) -> BlogSummaryCache: + """キャッシュを取得する。存在しない場合は新規作成して返す。 + + user_id の unique 制約を利用し、IntegrityError 時に再 SELECT することで + 同時リクエストによる重複生成を防ぐ。 + """ + cache = self.get() + if cache is not None: + return cache + try: + cache = BlogSummaryCache(user_id=self.user_id) + self.db.add(cache) + self.db.flush() + return cache + except IntegrityError: + self.db.rollback() + return self.db.scalar( + select(BlogSummaryCache).where(BlogSummaryCache.user_id == self.user_id) + ) + def invalidate(self, *, commit: bool = True) -> bool: cache = self.get() if not cache: diff --git a/backend/app/routers/blog.py b/backend/app/routers/blog.py index 41bd1b84..46e73f43 100644 --- a/backend/app/routers/blog.py +++ b/backend/app/routers/blog.py @@ -24,7 +24,7 @@ from ..core.security.auth import get_current_user from ..core.security.dependencies import limiter from ..db import get_db -from ..models import BlogSummaryCache, User +from ..models import User from ..repositories import BlogAccountRepository, BlogArticleRepository, BlogSummaryCacheRepository from ..schemas import ( BlogAccountCreate, @@ -268,27 +268,21 @@ async def summarize_blog( 記事は worker 側で ``BlogArticleRepository`` から取得するため、リクエストボディは不要。 """ - cache = BlogSummaryCacheRepository(db, user.id).get() - if cache is None: - cache = BlogSummaryCache(user_id=user.id) - db.add(cache) - db.flush() + cache = BlogSummaryCacheRepository(db, user.id).get_or_create() service = AsyncTaskCacheService(db, cache) - # 進行中のタスクがあればそのステータスを返す(期限切れキャッシュは None が返るため再生成を許可) - if service.is_in_progress(): + available = await check_llm_available() + if not available: + return BlogSummaryResponse(summary="", available=False) + + # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン + if not service.try_reset_to_pending(): return BlogSummaryResponse( summary=cache.summary or "", available=False, status=cache.status, ) - available = await check_llm_available() - if not available: - return BlogSummaryResponse(summary="", available=False) - - service.reset_to_pending() - try: await service.dispatch( background_tasks, @@ -340,7 +334,12 @@ async def retry_summarize_blog( if not available: return BlogSummaryResponse(summary="", available=False) - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise HTTPException( + status_code=409, + detail=f"このタスクはリトライできない状態です(現在: {cache.status})", + ) try: await service.dispatch( diff --git a/backend/app/routers/career_analysis.py b/backend/app/routers/career_analysis.py index f4512b36..b36a3dde 100644 --- a/backend/app/routers/career_analysis.py +++ b/backend/app/routers/career_analysis.py @@ -147,7 +147,14 @@ async def retry_analysis( action="タスクの完了または失敗を待ってから再試行してください", ) - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=f"このタスクはリトライできない状態です(現在: {analysis.status})", + action="タスクの完了または失敗を待ってから再試行してください", + ) try: await service.dispatch( diff --git a/backend/app/routers/intelligence.py b/backend/app/routers/intelligence.py index 8b5c3125..a2cf7d39 100644 --- a/backend/app/routers/intelligence.py +++ b/backend/app/routers/intelligence.py @@ -58,6 +58,7 @@ def get_cache( status=cache.status, error_message=cache.error_message, error_code=resolve_async_error_code(cache.error_message), + warning_message=cache.warning_message, ) @@ -114,10 +115,10 @@ async def analyze( # 進行中のタスクがあればそのステータスを返す cache = _get_or_create_cache(db, user.id) service = AsyncTaskCacheService(db, cache) - if service.is_in_progress(): - return {"status": cache.status} - service.reset_to_pending() + # DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン + if not service.try_reset_to_pending(): + return {"status": cache.status} try: await service.dispatch( @@ -185,7 +186,14 @@ async def retry_analyze( github_username = user.username.removeprefix("github:") include_forks = payload.include_forks if payload else False - service.reset_to_pending(reset_retry_count=True) + # DB 最新状態を取得しつつアトミック遷移。並列リトライ競合を防ぐ + if not service.try_reset_to_pending(reset_retry_count=True): + raise_app_error( + status_code=409, + code=ErrorCode.VALIDATION_ERROR, + message=f"このタスクはリトライできない状態です(現在: {cache.status})", + action="タスクの完了または失敗を待ってから再試行してください", + ) try: await service.dispatch( diff --git a/backend/app/schemas/intelligence.py b/backend/app/schemas/intelligence.py index c556299c..fab8bc88 100644 --- a/backend/app/schemas/intelligence.py +++ b/backend/app/schemas/intelligence.py @@ -68,6 +68,8 @@ class CachedAnalysisResponse(BaseModel): status: Optional[str] = None error_message: Optional[str] = None error_code: Optional[str] = None + # 分析自体は完了したが LLM など部分的に欠落した場合の警告メッセージ + warning_message: Optional[str] = None class SubProgress(BaseModel): diff --git a/backend/app/services/intelligence/github_analysis_service.py b/backend/app/services/intelligence/github_analysis_service.py index 1dc87535..f4db8ac2 100644 --- a/backend/app/services/intelligence/github_analysis_service.py +++ b/backend/app/services/intelligence/github_analysis_service.py @@ -14,6 +14,7 @@ from ...core.logging_utils import get_logger from ...models import GitHubAnalysisCache from ..progress_service import set_progress +from ..tasks.exceptions import NonRetryableError, RetryableError from .github_collector import GitHubUserNotFoundError, collect_repos from .llm import get_llm_client from .llm_summarizer import generate_learning_advice @@ -37,10 +38,12 @@ async def run_github_analysis(db: Session, payload: dict) -> None: cache = db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() if not cache: logger.error("GitHub 分析キャッシュが見つかりません", extra={"user_id": user_id}) - return + raise RuntimeError(f"GitHub analysis cache not found: user_id={user_id}") cache.status = "processing" cache.started_at = _now() + # 前回実行の警告が残らないようリセット(error_message はディスパッチ時点でリセット済み) + cache.warning_message = None db.commit() token = decrypt_field(payload["github_token"]) if payload.get("github_token") else None @@ -86,7 +89,9 @@ async def _on_repo_fetched(done: int, total: int) -> None: # ステップ 4: DB 保存 await set_progress(task_id, 4, _TOTAL_STEPS, "結果を保存中...") cache.status = "completed" - cache.error_message = "LLM処理が利用できません" if llm_failed else None + cache.error_message = None + # LLM 失敗は分析自体は成功しているため warning_message に分けて記録する + cache.warning_message = "LLM処理が利用できません" if llm_failed else None cache.completed_at = _now() db.commit() @@ -116,6 +121,9 @@ async def _generate_advice_if_available(analysis: dict) -> tuple[str | None, boo logger.warning("LLM が学習アドバイスの生成に失敗しました") return None, True return advice, False - except Exception: + except (RetryableError, NonRetryableError): logger.warning("学習アドバイスの生成に失敗しましたが、分析結果は保存します", exc_info=True) return None, True + except Exception: + logger.exception("学習アドバイスの生成で予期しないエラーが発生しました") + raise diff --git a/backend/app/services/intelligence/position_weights.json b/backend/app/services/intelligence/position_weights.json index 9ce0ebc2..b89a074a 100644 --- a/backend/app/services/intelligence/position_weights.json +++ b/backend/app/services/intelligence/position_weights.json @@ -80,7 +80,7 @@ "HTML/CSS": ["CSS", "HTML"], "Docker": ["Docker"], "CI/CD": ["CI/CD"], - "Git workflow": ["CI/CD"], + "Git workflow": ["Git", "GitHub", "GitLab"], "クラウドサービス(GCP/AWS/Azure)基礎": ["GCP", "AWS", "Azure"] } } diff --git a/backend/app/services/intelligence/skill_extractor.py b/backend/app/services/intelligence/skill_extractor.py index 9b83f747..68c16e62 100644 --- a/backend/app/services/intelligence/skill_extractor.py +++ b/backend/app/services/intelligence/skill_extractor.py @@ -125,6 +125,6 @@ def add(skill_name: str, source: str, *, language_bytes: int = 0) -> None: # 6. 依存関係由来のフレームワーク(merge_frameworks の代替) for framework in repo.detected_frameworks: - add(framework, "root_file") + add(framework, "dependency") return skills diff --git a/backend/app/services/tasks/dispatch_service.py b/backend/app/services/tasks/dispatch_service.py index 4ca5bdef..1e2a2059 100644 --- a/backend/app/services/tasks/dispatch_service.py +++ b/backend/app/services/tasks/dispatch_service.py @@ -73,6 +73,19 @@ def reset_to_pending(self, *, reset_retry_count: bool = False) -> None: self._record.completed_at = None self._db.commit() + def try_reset_to_pending(self, *, reset_retry_count: bool = False) -> bool: + """DB から最新状態を取得してから ``pending`` へアトミックに遷移する。 + + 既に ``pending`` / ``processing`` であれば何もせず False を返す。 + 遷移に成功した場合は True を返す。 + ``reset_to_pending`` の代わりにこちらを使うことで TOCTOU を軽減できる。 + """ + self._db.refresh(self._record) + if is_in_progress(self._record.status): + return False + self.reset_to_pending(reset_retry_count=reset_retry_count) + return True + async def dispatch( self, background_tasks: BackgroundTasks, diff --git a/backend/app/services/tasks/handlers/blog_summarize.py b/backend/app/services/tasks/handlers/blog_summarize.py index 92ef0190..ac099bb3 100644 --- a/backend/app/services/tasks/handlers/blog_summarize.py +++ b/backend/app/services/tasks/handlers/blog_summarize.py @@ -33,7 +33,10 @@ async def run(self, db: Session, payload: dict) -> None: from ...intelligence.llm import get_llm_client from ...intelligence.llm_summarizer import summarize_blog_articles - user_id = payload["user_id"] + user_id = payload.get("user_id") + if not user_id: + logger.error("ペイロードに user_id がありません", extra={"payload_keys": list(payload.keys())}) + return cache = self.get_record(db, payload) if not cache: logger.error("ブログサマリキャッシュが見つかりません", extra={"user_id": user_id}) diff --git a/backend/app/services/tasks/handlers/career_analysis.py b/backend/app/services/tasks/handlers/career_analysis.py index fc693ad0..5d0217a7 100644 --- a/backend/app/services/tasks/handlers/career_analysis.py +++ b/backend/app/services/tasks/handlers/career_analysis.py @@ -56,7 +56,7 @@ async def run(self, db: Session, payload: dict) -> None: analysis.error_message = str(exc) analysis.completed_at = _now() db.commit() - raise exc + raise analysis.result_json = json.dumps(result, ensure_ascii=False) analysis.status = "completed" diff --git a/backend/app/services/tasks/worker.py b/backend/app/services/tasks/worker.py index cb5e9315..3c8dec0d 100644 --- a/backend/app/services/tasks/worker.py +++ b/backend/app/services/tasks/worker.py @@ -19,6 +19,7 @@ from .base import TaskType from .exceptions import NonRetryableError from .handlers import get_handler +import time logger = get_logger(__name__) @@ -29,7 +30,6 @@ def _monotonic_ms_since(start: float) -> int: """``time.monotonic()`` の開始時点からの経過ミリ秒を返す。""" - import time return int((time.monotonic() - start) * 1000) @@ -48,7 +48,6 @@ async def execute_task( ローカル(BackgroundTasks)呼び出しではデフォルトの ``retry_count=0, max_attempts=1`` を使い、 失敗時は即座に ``dead_letter`` へ遷移する(ローカルはネイティブリトライが無いため)。 """ - import time user_id = payload.get("user_id", "unknown") record_id = payload.get("record_id") @@ -177,21 +176,24 @@ def _now() -> datetime: async def _run_github_analysis(db: Session, payload: dict) -> None: """GitHub 分析ハンドラへのシム。""" handler = get_handler(TaskType.GITHUB_ANALYSIS) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.GITHUB_ANALYSIS}") await handler.run(db, payload) async def _run_blog_summarize(db: Session, payload: dict) -> None: """ブログサマリハンドラへのシム。""" handler = get_handler(TaskType.BLOG_SUMMARIZE) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.BLOG_SUMMARIZE}") await handler.run(db, payload) async def _run_career_analysis(db: Session, payload: dict) -> None: """キャリア分析ハンドラへのシム。""" handler = get_handler(TaskType.CAREER_ANALYSIS) - assert handler is not None + if handler is None: + raise ValueError(f"ハンドラが登録されていません: {TaskType.CAREER_ANALYSIS}") await handler.run(db, payload) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 822ba7c6..d6d3bcff 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,3 +14,10 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I"] ignore = ["E501"] + +[tool.pyright] +venvPath = "." +venv = ".venv" +pythonVersion = "3.13" +include = ["app", "tests", "alembic_migrations"] +extraPaths = ["."] diff --git a/backend/tests/test_intelligence.py b/backend/tests/test_intelligence.py index 8afd50b8..3d626fe2 100644 --- a/backend/tests/test_intelligence.py +++ b/backend/tests/test_intelligence.py @@ -7,11 +7,11 @@ import asyncio from unittest.mock import AsyncMock, patch +from app.services.intelligence.github.repo_analyzer import parse_go_mod, parse_pom_xml from app.services.intelligence.github_collector import RepoData from app.services.intelligence.pipeline import IntelligenceResult, run_pipeline from app.services.intelligence.response_mapper import map_pipeline_result from app.services.intelligence.skill_extractor import extract_skills -from fastapi.testclient import TestClient from conftest import auth_header @@ -257,7 +257,6 @@ def test_full_repo_with_all_sources(self): class TestDependencyParsing: def test_parse_pom_xml(self): - from app.services.intelligence.github.repo_analyzer import parse_pom_xml content = ( "" @@ -268,7 +267,6 @@ def test_parse_pom_xml(self): assert "spring-boot-starter-web" in result def test_parse_go_mod(self): - from app.services.intelligence.github.repo_analyzer import parse_go_mod content = ( "module example.com/app\n\nrequire (\n" @@ -281,7 +279,7 @@ def test_parse_go_mod(self): # ── Intelligence Endpoint Tests ──────────────────────────────────────── -def test_analyze_requires_github_user(client: TestClient) -> None: +def test_analyze_requires_github_user(client) -> None: """通常ユーザー(非 GitHub)で analyze を呼ぶと 403 になること。""" headers = auth_header(client, "normal_analyze") resp = client.post( diff --git a/backend/tests/test_worker_extended.py b/backend/tests/test_worker_extended.py index 71da1f4b..295dbd0c 100644 --- a/backend/tests/test_worker_extended.py +++ b/backend/tests/test_worker_extended.py @@ -181,20 +181,20 @@ def test_github_user_not_found_sets_dead_letter(self, db_session: Session): db_session.refresh(cache) assert cache.status == "dead_letter" - def test_no_cache_returns_early(self, db_session: Session): - """キャッシュが見つからない場合、例外なく早期リターンすること。""" - _run( - _run_github_analysis( - db_session, - { - "user_id": "nonexistent-user-id", - "github_username": "nobody", - "github_token": None, - "include_forks": False, - }, + def test_no_cache_raises_runtime_error(self, db_session: Session): + """キャッシュが見つからない場合、RuntimeError が送出されること。""" + with pytest.raises(RuntimeError, match="GitHub analysis cache not found"): + _run( + _run_github_analysis( + db_session, + { + "user_id": "nonexistent-user-id", + "github_username": "nobody", + "github_token": None, + "include_forks": False, + }, + ) ) - ) - # 例外が発生しないことを確認 # ── _run_blog_summarize ─────────────────────────────────────────────────── @@ -503,10 +503,12 @@ def test_llm_available_returns_advice(self): assert result == ("学習アドバイスです", False) - def test_llm_exception_returns_none(self): - """LLM が例外を送出した場合 (None, True) を返し例外が外に漏れないこと。""" + def test_llm_retryable_error_returns_warning(self): + """LLM が想定内の RetryableError を送出した場合 (None, True) を返し例外を握りつぶすこと。""" + from app.services.tasks.exceptions import RetryableError + mock_llm = MagicMock() - mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) + mock_llm.check_available = AsyncMock(side_effect=RetryableError("LLM 一時障害")) with patch( "app.services.intelligence.github_analysis_service.get_llm_client", @@ -516,6 +518,18 @@ def test_llm_exception_returns_none(self): assert result == (None, True) + def test_llm_unexpected_exception_propagates(self): + """LLM が予期しない例外を送出した場合は再送出されること(プログラミングエラー検知のため)。""" + mock_llm = MagicMock() + mock_llm.check_available = AsyncMock(side_effect=Exception("LLM クラッシュ")) + + with patch( + "app.services.intelligence.github_analysis_service.get_llm_client", + return_value=mock_llm, + ): + with pytest.raises(Exception, match="LLM クラッシュ"): + _run(_generate_advice_if_available(self._analysis())) + def test_no_position_scores_returns_none(self): """position_scores が None の場合 (None, False) を返すこと。""" mock_llm = MagicMock() diff --git a/flake.nix b/flake.nix index 2333761e..ee2c286e 100644 --- a/flake.nix +++ b/flake.nix @@ -52,9 +52,6 @@ # --- IaC --- opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) - # --- IaC --- - opentofu # OpenTofu CLI(Terraform 互換 / インフラ管理) - # --- 共通ツール --- git gh # GitHub CLI diff --git a/frontend/src/api/intelligence.ts b/frontend/src/api/intelligence.ts index ff7dbee1..c82ab528 100644 --- a/frontend/src/api/intelligence.ts +++ b/frontend/src/api/intelligence.ts @@ -43,6 +43,8 @@ export interface CachedAnalysisResponse { status?: string; error_message?: string; error_code?: string; + /** LLM 不在など、分析自体は完了したが部分的に欠落した場合の警告 */ + warning_message?: string; } /** diff --git a/frontend/src/components/blog/BlogArticleList.tsx b/frontend/src/components/blog/BlogArticleList.tsx index 1130b491..2babd8cc 100644 --- a/frontend/src/components/blog/BlogArticleList.tsx +++ b/frontend/src/components/blog/BlogArticleList.tsx @@ -47,6 +47,7 @@ export function BlogArticleList({ articles, filter, onFilterChange }: BlogArticl key={f} type="button" className={`${styles.filterTab} ${filter === f ? styles.filterTabActive : ""}`} + aria-pressed={filter === f} onClick={() => handleFilterChange(f)} > {f === "all" ? "All" : f === "zenn" ? "Zenn" : f === "note" ? "note" : "Qiita"} diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx index b7555240..6cb5f3d2 100644 --- a/frontend/src/components/blog/BlogPlatformList.tsx +++ b/frontend/src/components/blog/BlogPlatformList.tsx @@ -33,9 +33,9 @@ type BlogPlatformListProps = { setDraftUsernames: React.Dispatch>>; savingPlatform: string | null; syncingPlatform: string | null; - onSave: (platform: PlatformKey) => void; - onSync: (platform: PlatformKey) => void; - onDelete: (platform: PlatformKey) => void; + onSave: (platform: PlatformKey) => Promise; + onSync: (platform: PlatformKey) => Promise; + onDelete: (platform: PlatformKey) => Promise; }; /** プラットフォーム連携行の一覧を描画するコンポーネント。 */ @@ -93,7 +93,12 @@ export function BlogPlatformList({ setDraftUsernames((prev) => ({ ...prev, [pf.key]: e.target.value })) } onKeyDown={(e) => { - if (e.key === "Enter") onSave(pf.key); + if ( + e.key === "Enter" && + savingPlatform !== pf.key && + draftUsernames[pf.key]?.trim() + ) + onSave(pf.key); }} />