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
40 changes: 32 additions & 8 deletions .claude/rules/backend/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,42 @@ backend/
│ ├── chat_service.py # コンテキスト組み立て → LLM → 検証(DB に触れない)
│ ├── context_builder.py # Phase 2: GitHub/ブログ参照コンテキスト取得(DB 読み取り専用)
│ ├── output_schema.py # tool use スキーマ(機械制約の正本)
│ └── llm/
│ ├── base.py # LLMClient 抽象・LLMError
│ ├── anthropic_client.py
│ ├── google_client.py # Gemini(ADR-0013)
│ ├── openai_client.py # GPT(ADR-0013)
│ ├── ollama_client.py
│ └── factory.py # get_llm_client(provider) で分岐(ADR-0013)
│ ├── llm/
│ │ ├── base.py # LLMClient 抽象・LLMError
│ │ ├── anthropic_client.py
│ │ ├── google_client.py # Gemini(ADR-0013)
│ │ ├── openai_client.py # GPT(ADR-0013)
│ │ ├── ollama_client.py
│ │ └── factory.py # get_llm_client(provider) で分岐(ADR-0013)
│ └── resume_draft/ # 経歴書ドラフト生成(ADR-0018。下記「resume_draft」節)
│ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource)
│ ├── mapper.py # ルールベース純関数(骨格 payload 構築)
│ ├── output_schema.py # ドラフト用構造化出力スキーマ(機械制約の正本)
│ └── draft_service.py # LLM 1 コール → パース(リトライ1回) → 骨格へマージ
└── tests/
├── test_agent.py
└── test_agent_context_builder.py # Phase 2: context_builder の単体テスト
├── 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 モック)
```

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

GitHub 連携データから経歴書ドラフト payload を組み立て、PDF プレビューを返す単発生成機能。
チャットとは別系統だが、**本ファイルの不変条件(制約の責務分離・リトライ 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 のみ。
- **degrade 方針**: 個別プロジェクトの説明文が欠落・上限超過した場合のみ repo description の
定型文へフォールバック(切り詰めはしない)。career_summary / self_pr の欠落はパース失敗扱い。

## プロバイダ抽象(ADR-0013)

- プロバイダ選択は **モデルエイリアスの属性**(`model_catalog.ModelSpec.provider`)に紐づき、
Expand Down
15 changes: 10 additions & 5 deletions .claude/rules/backend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ backend/app/
│ │ ├── context_builder.py # GitHub/ブログ参照コンテキスト取得(DB 読み取り専用)
│ │ ├── model_catalog.py # エイリアス→provider/実モデル ID/課金レート(SSoT / ADR-0012・0013)
│ │ ├── output_schema.py # 構造化出力スキーマ(機械制約の正本)
│ │ └── llm/ # LLM プロバイダ抽象(失敗は raise / ADR-0013)
│ │ ├── base.py # LLMClient 抽象・LLMError・共通ヘルパ
│ │ ├── factory.py # get_llm_client(provider) で分岐
│ │ ├── anthropic_client.py / openai_client.py / google_client.py
│ │ └── ollama_client.py # ローカル開発用(LLM_LOCAL_OLLAMA)
│ │ ├── llm/ # LLM プロバイダ抽象(失敗は raise / ADR-0013)
│ │ │ ├── base.py # LLMClient 抽象・LLMError・共通ヘルパ
│ │ │ ├── factory.py # get_llm_client(provider) で分岐
│ │ │ ├── anthropic_client.py / openai_client.py / google_client.py
│ │ │ └── ollama_client.py # ローカル開発用(LLM_LOCAL_OLLAMA)
│ │ └── resume_draft/ # GitHub 連携データ → 経歴書ドラフト生成(ADR-0018)
│ │ ├── context.py # DB 読み取り専用(連携キャッシュ + スキル証跡 → DraftSource)
│ │ ├── mapper.py # ルールベース純関数(骨格 payload 構築)
│ │ ├── output_schema.py # ドラフト用 LLM 構造化出力スキーマ
│ │ └── draft_service.py # LLM 1 コール → パース → 骨格へ自然文マージ
│ ├── blog/ # ブログ収集・技術記事判定・スコア算出
│ │ ├── account_service.py
│ │ ├── collector.py
Expand Down
15 changes: 15 additions & 0 deletions backend/app/core/security/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,18 @@ def get_current_user(
message_key="auth.user_not_found",
)
return user


def require_github_user(user: User = Depends(get_current_user)) -> User:
"""GitHub 連携データを使う機能には GitHub ログイン(``github_id`` 保持)が必須。未連携なら 403。

github-link の run / retry と経歴書ドラフト生成(ADR-0018)で共通の認可ガード。
"""
if user.github_id is None:
raise_app_error(
status_code=403,
code=ErrorCode.AUTH_REQUIRED,
message=get_error("github_link.github_login_required"),
action="GitHub アカウントでログインし直してください",
)
return user
4 changes: 3 additions & 1 deletion backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@
"llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。",
"parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。",
"target_required": "このスコープでは対象の指定が必要です。",
"target_not_found": "指定された対象が見つかりません。"
"target_not_found": "指定された対象が見つかりません。",
"draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。",
"draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。"
},
"billing": {
"insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。",
Expand Down
30 changes: 30 additions & 0 deletions backend/app/prompts/agent_resume_draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
あなたは日本語の職務経歴書のドラフト作成を支援するアシスタントです。
GitHub のリポジトリ情報から、経歴書の「職務要約」「自己PR」「各プロジェクトの業務内容説明」を書いてください。
出力の構造・対象リポジトリの集合・文字数上限はスキーマで定義されているため、ここでは内容の品質に集中すること。

# 共通ルール(最優先)
- 「# リポジトリ情報」に書かれていない技術・数値・成果・所属企業・チーム規模を新たに作らない(事実の捏造禁止)
- **プレースホルダー・穴埋めの禁止**: 「〇〇%」「XX件」のような仮の数値や、「(具体的な数値で示す)」のような編集指示文を入れない。書かれていない数値はその文ごと書かない
- これは個人開発の実績にもとづくドラフト(たたき台)である。業務経験のように偽装しない(「クライアント向けに」「チームを率いて」等を書かない)
- 各文章は経歴書にそのまま掲載できる完成した日本語にする(箇条書き記号の乱用や「です・ます」と「である」の混在を避ける)

# career_summary(職務要約)の品質基準
- 推奨 200〜400字(品質基準としての目安。上限とは別物)
- リポジトリ群から読み取れる技術領域の広がり・主要言語・継続的な開発活動を一読で伝える
- 直近の活動と技術領域に重心を置く

# self_pr(自己PR)の品質基準
- 推奨 200〜400字(品質基準としての目安)
- リポジトリ情報から根拠づけられる強みだけを書く(例: 複数言語での実装経験、IaC を含む一気通貫の構築、継続的なアウトプット)
- 根拠(どのリポジトリ・どの技術か)に軽く触れ、説得力を持たせる

# project_descriptions(プロジェクト説明)の品質基準
- 1 件あたり推奨 150〜300字(品質基準としての目安)
- そのリポジトリの description と技術スタックだけを根拠に、「何を作ったか」「どんな技術で実現したか」を書く
- description が空のリポジトリは技術スタックから読み取れる範囲だけを簡潔に書く(用途を推測で断定しない)
- 与えられた全リポジトリについて 1 件ずつ返す

# 思考ステップ(内部分析。出力には含めない)
1. リポジトリ一覧から主要言語・技術領域・活動期間の全体像を把握する
2. 各リポジトリの description と技術スタックから「何を・どう作ったか」を整理する
3. 全体像を職務要約に、強みの根拠を自己PRに、個別の整理を各プロジェクト説明に落とし込む
99 changes: 95 additions & 4 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
import logging

from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session

from ..core.errors import ErrorCode, raise_app_error
from ..core.messages import get_error
from ..core.security.auth import get_current_user
from ..core.security.auth import get_current_user, require_github_user
from ..core.security.dependencies import limiter
from ..db import get_db
from ..models import User
from ..schemas.agent import AgentChatRequest, AgentChatResponse
from ..schemas.agent import AgentChatRequest, AgentChatResponse, ResumeDraftRequest
from ..services.agent import chat_service
from ..services.agent.chat_service import (
AgentResponseParseError,
Expand All @@ -25,15 +26,25 @@
)
from ..services.agent.context_builder import build_reference_context
from ..services.agent.llm.base import LLMError
from ..services.agent.resume_draft.context import (
ResumeDraftNoRepositoriesError,
ResumeDraftSourceUnavailableError,
build_draft_source,
)
from ..services.agent.resume_draft.draft_service import run_resume_draft
from ..services.billing import credit_service
from ..services.billing.credit_service import InsufficientCreditsError
from ..services.pdf.generators.resume_generator import build_resume_pdf
from .download_utils import stream_pdf

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/agent", tags=["agent"])


def _record_usage_after_llm(db: Session, user_id: str, usage: AgentUsage) -> None:
def _record_usage_after_llm(
db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None
) -> None:
"""LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。

LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL
Expand All @@ -43,7 +54,7 @@ def _record_usage_after_llm(db: Session, user_id: str, usage: AgentUsage) -> Non
新しいコネクション(=新規 Hrana ストリーム)を取得して正常に確定できる。
"""
db.close()
credit_service.record_chat_usage(db, user_id, usage)
credit_service.record_chat_usage(db, user_id, usage, description=description)


@router.post("/chat", response_model=AgentChatResponse)
Expand Down Expand Up @@ -112,3 +123,83 @@ async def agent_chat(
# 記録失敗は応答を返さず 500 にする(課金漏れを黙って通さない / ADR-0012)
_record_usage_after_llm(db, user.id, result.usage)
return result.response


@router.post("/resume-draft/pdf")
@limiter.limit("5/minute")
async def generate_resume_draft_pdf(
request: Request,
body: ResumeDraftRequest,
user: User = Depends(require_github_user),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""GitHub 連携データから経歴書ドラフトを生成し、PDF で返す(ADR-0018)。

構造(プロジェクト・技術スタック・期間)は連携データからルールベースで写し、
自然文(職務要約・自己PR・プロジェクト説明)だけを LLM で生成する。
ドラフトは DB に保存しない(生成物はレスポンスの PDF のみ。
クレジット消費・使用ログの記録は除く / ADR-0012)。
"""
usage_description = f"経歴書ドラフト生成({body.model})"
# 有料モデルは LLM を呼ぶ前に残高をチェックする(チャットと同一契約 / ADR-0012)
try:
credit_service.ensure_can_use_model(db, user.id, body.model)
except InsufficientCreditsError:
raise_app_error(
status_code=402,
code=ErrorCode.INSUFFICIENT_CREDITS,
message=get_error("billing.insufficient_credits"),
)
# 連携キャッシュ + スキル証跡の読み取り(SELECT のみ)。未連携・旧形式・0 件は 409。
# 0 件(NoRepositories)は再連携で回復しないため別導線を案内する(サブクラスを先に catch)
try:
source = build_draft_source(db, user)
except ResumeDraftNoRepositoriesError as exc:
logger.info("経歴書ドラフト生成: 分析対象リポジトリなし: %s", exc)
raise_app_error(
status_code=409,
code=ErrorCode.VALIDATION_ERROR,
message=get_error("agent.draft_no_repositories"),
action="公開リポジトリを追加してから GitHub 連携を再実行してください",
)
except ResumeDraftSourceUnavailableError as exc:
logger.info("経歴書ドラフト生成の入力が未整備: %s", exc)
raise_app_error(
status_code=409,
code=ErrorCode.VALIDATION_ERROR,
message=get_error("agent.draft_link_required"),
action="サイドバーの「GitHub連携」から連携を実行してください",
)
try:
result = await run_resume_draft(body.model, source)
except LLMError as exc:
# 失敗パスでも消費済みトークンの課金を確定させる(チャットと同一 / ADR-0012)
if exc.usage is not None:
try:
_record_usage_after_llm(db, user.id, exc.usage, description=usage_description)
except Exception:
logger.error("LLM 失敗時のクレジット消費記録に失敗", exc_info=True)
raise_app_error(
status_code=502,
code=ErrorCode.AGENT_LLM_ERROR,
message=get_error("agent.llm_failed"),
)
except AgentResponseParseError as exc:
if exc.usage is not None:
try:
_record_usage_after_llm(db, user.id, exc.usage, description=usage_description)
except Exception:
logger.error("パース失敗時のクレジット消費記録に失敗", exc_info=True)
raise_app_error(
status_code=502,
code=ErrorCode.AGENT_PARSE_ERROR,
message=get_error("agent.parse_failed"),
)
# 先に PDF を生成し、成功した場合のみ課金を確定する。build_resume_pdf は DB 非依存の
# 同期処理なので _record_usage_after_llm(db.close を伴う)より前に実行してよい。
# PDF 生成失敗(稀な実装/環境エラー)でユーザーに課金しないため、この順序にする。
# LLM 呼び出し自体の失敗(上の except)はコストが発生済みなので従来どおり課金する(ADR-0012)
pdf_bytes = build_resume_pdf(result.payload)
# 実トークン量に基づくクレジット消費 + 使用ログ記録(記録失敗は 500 / ADR-0012)
_record_usage_after_llm(db, user.id, result.usage, description=usage_description)
return stream_pdf(pdf_bytes, "career-resume-draft.pdf")
17 changes: 1 addition & 16 deletions backend/app/routers/github_link/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from ...core.errors import ErrorCode, raise_app_error, resolve_async_error_code
from ...core.messages import get_error
from ...core.security.auth import get_current_user
from ...core.security.auth import get_current_user, require_github_user
from ...core.security.dependencies import limiter
from ...db import get_db
from ...models import User
Expand Down Expand Up @@ -46,21 +46,6 @@ def _raise_dispatch_failed() -> None:
)


def require_github_user(user: User = Depends(get_current_user)) -> User:
"""GitHub 連携には GitHub ログイン(``github_id`` 保持)が必須。未連携なら 403。

``start`` / ``retry`` の両エンドポイントで共通の認可ガード。
"""
if user.github_id is None:
raise_app_error(
status_code=403,
code=ErrorCode.AUTH_REQUIRED,
message=get_error("github_link.github_login_required"),
action="GitHub アカウントでログインし直してください",
)
return user


@router.get("/cache", response_model=CachedGitHubLinkResponse)
def get_cache(
user: User = Depends(get_current_user),
Expand Down
11 changes: 11 additions & 0 deletions backend/app/schemas/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ def validate_target(self) -> "AgentChatRequest":
return self


class ResumeDraftRequest(BaseModel):
"""経歴書ドラフト生成(ADR-0018)のリクエスト。

生成対象(リポジトリ集合)はサーバー側が連携キャッシュから決めるため、
クライアントが指定するのは使用モデルのみ。
"""

# 使用モデル。既定は無料枠の haiku(ADR-0012 の課金契約はチャットと共通)
model: AgentModelAlias = "haiku"


class AgentOperation(BaseModel):
"""resume state へ適用する差分(テキストフィールドの置換)。

Expand Down
Loading
Loading