Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/infra/terraform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 管理)
Expand Down
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@ backend/local.sqlite-wal
frontend/.env.local
backend/.env

# Firebase(削除済みだが生成物の混入を防ぐために保持)
.firebase/
firebase-debug.log

# Wrangler
.wrangler/

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down
21 changes: 1 addition & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
```
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions backend/app/core/errors.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions backend/app/core/security/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 2 additions & 35 deletions backend/app/db/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 6 additions & 6 deletions backend/app/db/seeds/technology_stacks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
3 changes: 3 additions & 0 deletions backend/app/models/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions backend/app/repositories/blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 14 additions & 15 deletions backend/app/routers/blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion backend/app/routers/career_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 12 additions & 4 deletions backend/app/routers/intelligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/intelligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading