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
34 changes: 25 additions & 9 deletions .claude/rules/backend/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,48 @@ backend/
│ │ ├── openai_client.py # GPT(ADR-0013)
│ │ ├── ollama_client.py
│ │ └── factory.py # get_llm_client(provider) で分岐(ADR-0013)
│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018。下記「resume_draft」節)
│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018・0020。下記「resume_draft」節)
│ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource)
│ ├── mapper.py # ルールベース純関数(骨格 payload 構築)
│ ├── output_schema.py # ドラフト用構造化出力スキーマ(機械制約の正本)
│ └── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ
│ ├── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ(DB 非依存)
│ └── run_task.py # 非同期タスク本体(ADR-0020: LLM→PDF検証→課金→結果保存。DB 書き込みはここ)
└── tests/
├── test_agent.py
├── test_agent_context_builder.py # Phase 2: context_builder の単体テスト
├── test_resume_draft_mapper.py # ADR-0018: ルールベースマッピングの単体テスト
└── test_resume_draft_service.py # ADR-0018: draft_service(LLM モック)
├── test_resume_draft_service.py # ADR-0018: draft_service(LLM モック)
├── test_resume_draft_api.py # ADR-0020: enqueue/status/download の統合テスト
└── test_worker/test_resume_draft.py # ADR-0020: run_resume_draft_task(課金順序の不変条件)
```

## resume_draft(経歴書ドラフト生成 / ADR-0018)
## resume_draft(経歴書ドラフト生成 / ADR-0018・0020

GitHub 連携データから経歴書ドラフト payload を組み立て、PDF プレビューを返す単発生成機能。
チャットとは別系統だが、**本ファイルの不変条件(制約の責務分離・リトライ 1 回・エラー契約・
LLMError/usage の課金漏れ防止)を全て継承する**。
GitHub 連携データから経歴書ドラフト payload を組み立てて PDF を生成する機能。**ADR-0020 で
非同期タスク化**した(連携とは別の「ドラフト生成」ボタンで明示実行)。チャットとは別系統だが、
**本ファイルの不変条件(制約の責務分離・リトライ 1 回・エラー契約・LLMError/usage の課金漏れ防止)を
全て継承する**。

- **構造はルールベース、自然文だけ LLM**: repo→プロジェクト骨格・技術スタック・期間は
`mapper.py`(純関数)が決定論で写す。LLM が生成するのは career_summary / self_pr /
各プロジェクト description のみ。
- **出力スキーマは動的**: `repo_full_name` を選定リポジトリの enum で縛る(捏造リポの構造排除)。
チャットの「プロンプトは静的・スキーマも静的」と異なりリクエストごとに構築するが、
プロンプト md(`agent_resume_draft.md`)自体は静的を維持する(動的情報は user メッセージへ)。
- **何も永続化しない**: resumes テーブルへ書かない。生成物はレスポンスの PDF だけ
(クレジット消費・使用ログは例外 / ADR-0012)。DB 読み取りは `context.py` の SELECT のみ。
- **非同期タスク + 最小永続化(ADR-0020)**: `TaskType.RESUME_DRAFT` の独立タスク。生成 payload
だけを連携ドメインの `resume_draft_cache`(1 ユーザー 1 件・最新上書き)に保存し、
`GET /api/agent/resume-draft/pdf` で再レンダリングする。**`resumes` テーブルへは書かない**
(確定した Resume と混同させない)。DB 書き込み(課金・結果保存・状態遷移)は `run_task.py` と
repository に閉じ込め、`draft_service.py` / `mapper.py` / `context.py`(SELECT のみ)の DB 非依存は維持。
- **課金はタスク側(ADR-0020)**: 残高の事前チェック(402)だけ enqueue で行い、実課金は
`run_task.py` が確定する。**PDF レンダリング成功後にのみ課金**(失敗=課金なし)、LLM/パース失敗時は
消費済みトークンを必ず課金、課金記録の失敗は `NonRetryableError` で dead_letter 化(LLM 再実行=再課金を防ぐ)。
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **二重課金を防ぐ原子性・冪等性(ADR-0020)**: 本課金と結果保存(`completed` + `result`)は
**同一セッションの単一トランザクション**で確定する(`record_chat_usage` の commit が staged な
cache 変更も一括 flush する)。「課金済みだが結果未保存」の窓を作らないことで、その状態からの
リトライ・再配信による再課金を構造的に防ぐ。加えてフェーズA に**冪等ガード**を置き、既に
`completed` かつ `result` があるタスク再配信(原子 commit 後・ack 前のクラッシュ)は再実行しない。
手動再実行は router が status を `pending` へ戻すためガードに掛からず、意図どおり再生成する。
- **degrade 方針**: 個別プロジェクトの説明文が欠落・上限超過した場合のみ repo description の
定型文へフォールバック(切り詰めはしない)。career_summary / self_pr の欠落はパース失敗扱い。

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""経歴書ドラフト生成の非同期タスク用キャッシュテーブルを追加する(ADR-0018 / 非同期化)

- resume_draft_cache: ユーザーごとに最新のドラフト生成 1 件(状態 + 生成 payload)を保持する

新規テーブル作成のみ(op.create_table)で、既存テーブルの再作成は伴わない。
FK は users を親に持つ。``resumes`` テーブルとは無関係(確定した職務経歴書とは別ドメイン)。

Revision ID: 0047_add_resume_draft_cache_table
Revises: 0046_add_manifest_path_to_github_skill_evidence
Create Date: 2026-07-05 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0047_add_resume_draft_cache_table"
down_revision: Union[str, None] = "0046_add_manifest_path_to_github_skill_evidence"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"resume_draft_cache",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id"),
nullable=False,
unique=True,
),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column(
"status", sa.String(length=20), nullable=False, server_default="completed"
),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
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,
),
)


def downgrade() -> None:
op.drop_table("resume_draft_cache")
8 changes: 7 additions & 1 deletion backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。",
"draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。",
"draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。"
"draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。",
"draft_pdf_failed": "経歴書ドラフトの PDF 生成に失敗しました。もう一度お試しください。",
"draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。"
},
"billing": {
"insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
Expand All @@ -89,6 +91,10 @@
"github_link": {
"completed": "GitHub連携が完了しました",
"failed": "GitHub連携に失敗しました"
},
"resume_draft": {
"completed": "経歴書ドラフトの生成が完了しました",
"failed": "経歴書ドラフトの生成に失敗しました"
}
},
"success": {
Expand Down
3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from .billing import AgentUsageLog, CreditTransaction
from .blog import BlogAccount, BlogArticle, BlogArticleTag
from .cache import GitHubLinkCache
from .cache import GitHubLinkCache, ResumeDraftCache
from .master_data import MQualification, MTechnologyStack
from .notification import Notification
from .resume import (
Expand Down Expand Up @@ -41,5 +41,6 @@
"ResumeProjectTeamMember",
"ResumeProjectTechnologyStack",
"ResumeQualification",
"ResumeDraftCache",
"User",
]
47 changes: 47 additions & 0 deletions backend/app/models/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,50 @@ class GitHubLinkCache(Base):
onupdate=func.now(),
nullable=False,
)


class ResumeDraftCache(Base):
"""経歴書ドラフト生成タスクの状態と生成結果のキャッシュ(ADR-0018 / 非同期化)。

ユーザーごとに最新のドラフト生成 1 件を保持する(``resumes`` テーブルとは無関係。
確定した職務経歴書は ``Resume`` が正本で、本テーブルは連携ドメイン側の生成キャッシュ)。
``result`` には LLM が生成したドラフト payload(``build_resume_pdf`` の入力 dict)を保存し、
ダウンロード時に PDF を再レンダリングする(DB にバイナリを持たない)。

``status`` / ``error_message`` / ``retry_count`` / ``started_at`` / ``completed_at`` を持ち、
非同期タスク基盤(``AsyncTaskCacheService`` の ``_AsyncTaskRecord`` Protocol)に適合する。
"""

__tablename__ = "resume_draft_cache"

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"), unique=True, nullable=False
)
# LLM 生成のドラフト payload(build_resume_pdf の入力 dict)。完了時のみ非 NULL。
result: Mapped[dict | None] = mapped_column(JSON, 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)
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")
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
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,
)
56 changes: 56 additions & 0 deletions backend/app/repositories/resume_draft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""経歴書ドラフト生成キャッシュ(``ResumeDraftCache``)のデータアクセス。

``ResumeDraftCache`` はユーザーあたり 1 件のレコードで、``user_id`` を一意境界とする。
取得・作成クエリを本リポジトリへ集約し、router / handler / task_runner からの直クエリ散在を防ぐ。
``user_id`` スコープは IDOR 防止の認可境界であり、1 箇所に閉じ込めることで条件追加時の漏れを防ぐ
(``GitHubLinkCacheRepository`` と同形)。
"""

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

from ..models import ResumeDraftCache


class ResumeDraftCacheRepository:
"""ユーザーの経歴書ドラフト生成キャッシュの読み取り・作成。

セッションはコンストラクタで受け取る。ドラフト生成の実行経路では libSQL の
idle stream timeout 対策でフェーズごとにセッションを開閉するため、本リポジトリは
セッションを保持せず呼び出し側が渡したものをそのまま使う。
"""

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

def get_by_user(self, user_id: str) -> ResumeDraftCache | None:
"""ユーザーのキャッシュを取得する。存在しなければ ``None``。"""
return self.db.scalar(
select(ResumeDraftCache).where(ResumeDraftCache.user_id == user_id)
)

def get_or_create(self, user_id: str) -> ResumeDraftCache:
"""ユーザーのキャッシュを取得し、存在しなければ作成して flush する。

並列リクエストが ``user_id`` の一意制約で衝突した場合は rollback して再取得する。
再 SELECT が ``None`` を返したら ``RuntimeError`` を上げて non-Optional な戻り値契約を守る
(.claude/rules/backend/database.md「IntegrityError 後の再 SELECT は None を判定する」)。
"""
cache = self.get_by_user(user_id)
if cache is not None:
return cache

cache = ResumeDraftCache(user_id=user_id)
self.db.add(cache)
try:
self.db.flush()
except IntegrityError:
self.db.rollback()
existing = self.get_by_user(user_id)
if existing is None:
raise RuntimeError(
f"ResumeDraftCache の作成と再取得に失敗しました (user_id={user_id})"
) from None
return existing
return cache
Loading
Loading