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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""create agent_daily_usage table(Agent 日次レート制限 / #521・ADR-0023)

プリペイド課金の残高チェックに代わる abuse 防止として、Agent エンドポイントの
ユーザ×日ごとのリクエスト回数を保持する原子的カウンタテーブルを作成する。

Revision ID: 0050_create_agent_daily_usage
Revises: 0049_drop_blog_tables
Create Date: 2026-07-21 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0050_create_agent_daily_usage"
down_revision: Union[str, None] = "0049_drop_blog_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"agent_daily_usage",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("usage_date", sa.Date(), nullable=False),
sa.Column("request_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.UniqueConstraint("user_id", "usage_date", name="uq_agent_daily_usage_user_date"),
)


def downgrade() -> None:
op.drop_table("agent_daily_usage")
5 changes: 5 additions & 0 deletions backend/app/core/env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@
# ローカル Ollama 呼び出しの HTTP タイムアウト秒数(既定 300。ローカル開発専用)
OLLAMA_TIMEOUT_SECONDS = "OLLAMA_TIMEOUT_SECONDS"

# Agent エンドポイントのユーザ単位日次リクエスト上限(#521 / ADR-0023)。
# プリペイド課金の残高チェックに代わる abuse 防止。未設定時は既定値
# (services/agent/rate_limit.py の DEFAULT_AGENT_DAILY_LIMIT)を使う。
AGENT_DAILY_LIMIT = "AGENT_DAILY_LIMIT"

# --- 決済(Stripe Checkout / ADR-0012 Phase 2) ---

# 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止
Expand Down
2 changes: 2 additions & 0 deletions backend/app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class ErrorCode(str, Enum):
# Agent(LLM チャット / ADR-0010)
AGENT_LLM_ERROR = "AGENT_LLM_ERROR"
AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR"
# Agent の日次利用上限(#521 / ADR-0023)
AGENT_DAILY_LIMIT_EXCEEDED = "AGENT_DAILY_LIMIT_EXCEEDED"
# 課金(プリペイドクレジット / ADR-0012)
INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
# 決済(Stripe Checkout / ADR-0012 Phase 2)
Expand Down
1 change: 1 addition & 0 deletions backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"agent": {
"llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。",
"parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。",
"daily_limit_exceeded": "本日の AI 利用回数の上限に達しました。明日また利用できます。",
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。",
"draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""SQLAlchemy モデル。"""

from .agent_usage import AgentDailyUsage
from .billing import AgentUsageLog, CreditTransaction
from .cache import GitHubLinkCache, ResumeDraftCache
from .master_data import MQualification, MTechnologyStack
Expand All @@ -24,6 +25,7 @@
from .user import User

__all__ = [
"AgentDailyUsage",
"AgentUsageLog",
"CreditTransaction",
"GitHubLinkCache",
Expand Down
46 changes: 46 additions & 0 deletions backend/app/models/agent_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""DevForge Agent の利用状況モデル。

ADR-0023(Haiku 無料一本化)で、プリペイド課金の残高チェックに代わる abuse 防止として
ユーザ単位の日次レート制限を導入する(#521)。本テーブルはユーザ×日ごとのリクエスト回数を
1 行で保持する原子的カウンタ。
"""

import uuid
from datetime import date, datetime

from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column

from ..db import Base


class AgentDailyUsage(Base):
"""Agent エンドポイントのユーザ×日ごとのリクエスト回数(日次レート制限用)。

`usage_date` は JST 基準の日付。ユーザ×日で 1 行に集約し、`request_count` を
原子的 UPDATE で増やす(Cloud Run 単一インスタンス前提 / ADR-0005)。
"""

__tablename__ = "agent_daily_usage"
__table_args__ = (
UniqueConstraint("user_id", "usage_date", name="uq_agent_daily_usage_user_date"),
)

id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id"), nullable=False
)
usage_date: Mapped[date] = mapped_column(Date, nullable=False)
request_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=func.now(), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=func.now(),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
69 changes: 69 additions & 0 deletions backend/app/repositories/agent_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Agent 日次レート制限のカウンタ永続化(#521 / ADR-0023)。

ユーザ×日ごとの `request_count` を原子的に増やす。Cloud Run 単一インスタンス
(ADR-0005)前提のため分散ロックは不要。IntegrityError(同一キーの競合)は
再取得で吸収する(`.claude/rules/backend/database.md`)。
"""

from datetime import date

from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from ..models import AgentDailyUsage


class AgentRateLimitRepository:
"""ユーザ×日の Agent リクエスト回数カウンタ。"""

def __init__(self, db: Session, user_id: str):
self.db = db
self.user_id = user_id

def increment_and_get(self, usage_date: date) -> int:
"""指定日のカウントを 1 増やし、増加後の値を返す(commit はしない)。

行が無ければ 1 で作成し、あれば ``request_count = request_count + 1`` の
原子的 UPDATE で増やす。INSERT 競合時は UPDATE 経路にフォールバックする。
"""
existing = self.db.execute(
select(AgentDailyUsage).where(
AgentDailyUsage.user_id == self.user_id,
AgentDailyUsage.usage_date == usage_date,
)
).scalar_one_or_none()

if existing is None:
row = AgentDailyUsage(
user_id=self.user_id, usage_date=usage_date, request_count=1
)
self.db.add(row)
try:
self.db.flush()
return 1
except IntegrityError:
# 別リクエストが同一キーを先に作成した場合は UPDATE 経路へ
self.db.rollback()

self.db.execute(
update(AgentDailyUsage)
.where(
AgentDailyUsage.user_id == self.user_id,
AgentDailyUsage.usage_date == usage_date,
)
.values(request_count=AgentDailyUsage.request_count + 1)
)
self.db.flush()
count = self.db.execute(
select(AgentDailyUsage.request_count).where(
AgentDailyUsage.user_id == self.user_id,
AgentDailyUsage.usage_date == usage_date,
)
).scalar_one_or_none()
if count is None:
# 直前に増やした行が消えるのは想定外。握りつぶさず明示的に失敗させる。
raise RuntimeError(
f"agent_daily_usage の再取得に失敗: user_id={self.user_id} date={usage_date}"
)
return count
23 changes: 23 additions & 0 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from ..services.agent.context_builder import build_reference_context
from ..services.agent.llm.base import LLMError
from ..services.agent.rate_limit import AgentRateLimitExceededError, enforce_daily_limit
from ..services.agent.resume_draft.context import (
ResumeDraftNoRepositoriesError,
ResumeDraftSourceUnavailableError,
Expand All @@ -44,6 +45,24 @@
router = APIRouter(prefix="/api/agent", tags=["agent"])


def _enforce_agent_daily_limit(db: Session, user_id: str) -> None:
"""Agent の日次利用上限を確認する(#521 / ADR-0023)。

プリペイド課金(残高)に代わる abuse 防止。今日(JST)のカウントを原子的に増やし、
上限超過なら 429 を返す。拒否時もカウントは確定させ、連続試行を含めて数える。
"""
try:
enforce_daily_limit(db, user_id)
db.commit()
except AgentRateLimitExceededError:
db.commit()
raise_app_error(
status_code=429,
code=ErrorCode.AGENT_DAILY_LIMIT_EXCEEDED,
message=get_error("agent.daily_limit_exceeded"),
)


def _record_usage_after_llm(
db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None
) -> None:
Expand All @@ -66,6 +85,8 @@ async def agent_chat(
(クレジット消費・使用ログの記録は除く / ADR-0012)。
ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。
"""
# 日次利用上限(abuse 防止 / #521・ADR-0023)を LLM 呼び出し前に確認する
_enforce_agent_daily_limit(db, user.id)
# 有料モデル(sonnet)は LLM を呼ぶ前に残高をチェックする。実コストは応答後に
# 確定するため事後減算とし、チェック通過後の負残高は許容する(ADR-0012)
try:
Expand Down Expand Up @@ -136,6 +157,8 @@ async def start_resume_draft(
確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。
課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。
"""
# 日次利用上限(abuse 防止 / #521・ADR-0023)を生成開始前に確認する
_enforce_agent_daily_limit(db, user.id)
# 有料モデルは生成を開始する前に残高をチェックする(チャットと同一契約 / ADR-0012)
try:
credit_service.ensure_can_use_model(db, user.id, body.model)
Expand Down
60 changes: 60 additions & 0 deletions backend/app/services/agent/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Agent エンドポイントのユーザ単位日次レート制限(#521 / ADR-0023)。

プリペイド課金(残高チェック)に代わる abuse 防止。ユーザ×日ごとのリクエスト回数を
原子的に増やし、`AGENT_DAILY_LIMIT` を超えたら `AgentRateLimitExceededError` を raise する。
日次リセットは JST 基準。
"""

import os
from datetime import date, datetime, timezone

from sqlalchemy.orm import Session

from ...core import env_keys
from ...core.date_utils import JST
from ...repositories.agent_rate_limit import AgentRateLimitRepository

# 未設定時の既定日次上限。Haiku は安価($1/$5 per 1M tokens)なので緩めに設定する。
DEFAULT_AGENT_DAILY_LIMIT = 50


class AgentRateLimitExceededError(Exception):
"""日次リクエスト上限を超過した。"""

def __init__(self, limit: int):
self.limit = limit
super().__init__(f"Agent の 1 日あたりの利用上限({limit} 回)に達しました")


def resolve_daily_limit() -> int:
"""`AGENT_DAILY_LIMIT` から日次上限を解決する。未設定・不正値は既定値を使う。"""
raw = os.getenv(env_keys.AGENT_DAILY_LIMIT)
if raw is None or not raw.strip():
return DEFAULT_AGENT_DAILY_LIMIT
try:
value = int(raw)
except ValueError:
return DEFAULT_AGENT_DAILY_LIMIT
return value if value > 0 else DEFAULT_AGENT_DAILY_LIMIT


def _today_jst() -> date:
"""現在の JST 日付を返す(日次リセットの境界)。"""
return datetime.now(timezone.utc).astimezone(JST).date()


def enforce_daily_limit(db: Session, user_id: str, limit: int | None = None) -> int:
"""今日(JST)のリクエスト回数を原子的に増やし、上限超過なら raise する。

`limit` 未指定時は `resolve_daily_limit()` で env から解決する。
増加後のカウントが `limit` を超えた場合に `AgentRateLimitExceededError` を raise する
(limit=N なら N 回目までは許可、N+1 回目で拒否)。commit は呼び出し側の責務。
"""
if limit is None:
limit = resolve_daily_limit()

today = _today_jst()
count = AgentRateLimitRepository(db, user_id).increment_and_get(today)
if count > limit:
raise AgentRateLimitExceededError(limit)
return count
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ testpaths = ["tests"]
[tool.mutmut]
source_paths = ["app"]
only_mutate = [
"app/services/agent/rate_limit.py", # Agent 日次レート制限判定(#521)
"app/services/billing/pricing.py", # 課金レート表
"app/services/billing/credit_service.py", # クレジット計算・残高
"app/services/intelligence/skills/*", # スキル推論(aggregator/linguist/パーサ群)
Expand Down
21 changes: 21 additions & 0 deletions backend/tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,27 @@ def _llm_json(field: str, value: str, message: str = "改善案です。") -> st
)


def test_chat_daily_rate_limit_returns_429(client: TestClient, monkeypatch) -> None:
"""日次上限(#521): 上限到達後の /agent/chat は 429 + AGENT_DAILY_LIMIT_EXCEEDED を返す。"""
from app.core import env_keys

monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "1")
_mock_llm(monkeypatch, response=_llm_json("career_summary", "改善された職務要約。"))
headers = auth_header(client, "rate-limited-user")
payload = {
"scope": "career_summary",
"prompt": "職務要約を改善してください",
"resume": _resume_payload(),
}

first = client.post("/api/agent/chat", json=payload, headers=headers)
assert first.status_code == 200

second = client.post("/api/agent/chat", json=payload, headers=headers)
assert second.status_code == 429
assert second.json()["code"] == "AGENT_DAILY_LIMIT_EXCEEDED"


def test_chat_career_summary_success(client: TestClient, monkeypatch) -> None:
"""正常系: career_summary スコープで operations が返る。"""
_mock_llm(monkeypatch, response=_llm_json("career_summary", "改善された職務要約。"))
Expand Down
Loading
Loading