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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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