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
45 changes: 18 additions & 27 deletions .claude/rules/backend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ backend/app/
├── messages.json # ユーザー向けメッセージ・通知文言の定義
├── core/ # 設定・メッセージ・認証・暗号化などの横断基盤
│ ├── settings.py
│ ├── env_keys.py # 環境変数名の定数定義(正本)
│ ├── messages.py
│ ├── logging_utils.py
│ ├── date_utils.py
Expand All @@ -33,8 +34,7 @@ backend/app/
│ └── seeds/
├── routers/ # FastAPI エンドポイント
│ ├── auth/ # 認証関連(endpoints, github_auth, oauth_flow, token_manager)
│ ├── blog.py
│ ├── career_analysis.py
│ ├── blog/ # ブログ連携(accounts, score, sync)
│ ├── download_utils.py
│ ├── health.py
│ ├── github_link.py
Expand All @@ -43,13 +43,13 @@ backend/app/
│ ├── notifications.py
│ └── resumes.py
├── models/ # SQLAlchemy 2.0 宣言的マッピング
│ ├── user.py / blog.py / cache.py / career_analysis.py
│ ├── user.py / blog.py / cache.py
│ ├── master_data.py / notification.py / resume.py
├── schemas/ # Pydantic リクエスト/レスポンススキーマ
│ ├── auth.py / blog.py / career_analysis.py / github_link.py
│ ├── auth.py / blog.py / github_link.py
│ ├── master_data.py / resume.py / shared.py
├── repositories/ # データアクセス層
│ ├── base.py / user.py / blog.py / career_analysis.py
│ ├── base.py / user.py / blog.py
│ ├── master_data.py / notification.py / resume.py
├── services/
│ ├── blog/ # ブログ収集・技術記事判定・スコア算出
Expand All @@ -58,58 +58,49 @@ backend/app/
│ │ ├── scorer.py
│ │ ├── sync_service.py
│ │ └── tech_keywords.json
│ ├── career_analysis/ # キャリア分析(プロンプト組み立て・テックスタックマージ)
│ │ ├── builder.py
│ │ ├── prompt_builder.py
│ │ └── tech_stack_merger.py
│ ├── intelligence/ # GitHub 連携パイプラインと LLM 連携
│ │ ├── pipeline.py
│ │ ├── github_collector.py
│ │ ├── github_link_service.py
│ │ ├── github/ # GitHub API クライアント・リポジトリ解析
│ │ │ ├── api_client.py
│ │ │ └── repo_analyzer.py
│ │ ├── llm_summarizer.py
│ │ ├── llm_advice_service.py
│ │ ├── response_mapper.py
│ │ ├── position_scorer.py
│ │ ├── position_weights.json
│ │ ├── skill_extractor.py
│ │ ├── skill_taxonomy/ # スキル分類(言語・トピック・所有権マップ
│ │ └── llm/ # LLM クライアント実装
│ │ ├── skill_taxonomy/ # スキル分類(言語・トピック・キーワードマップ
│ │ └── llm/ # LLM クライアント実装(休眠インフラ・温存)
│ │ ├── base.py
│ │ ├── factory.py
│ │ ├── ollama_client.py
│ │ └── vertex_client.py
│ ├── llm/ # LLM 入出力サニタイザ等(intelligence/llm とは別)
│ ├── llm/ # LLM 入出力サニタイザ(intelligence/llm とは別・休眠
│ │ └── sanitizer.py
│ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル)
│ │ ├── base.py # TaskType 定義
│ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ)
│ │ ├── exceptions.py # RetryableError / NonRetryableError
│ │ ├── worker.py # execute_task(状態遷移・通知)
│ │ ├── dispatch_service.py
│ │ ├── dispatch_service.py # AsyncTaskCacheService(状態遷移 + ディスパッチ)
│ │ ├── factory.py
│ │ ├── cloud_tasks.py # Cloud Tasks エンキュー
│ │ ├── local.py # BackgroundTasks 直接実行
│ │ └── handlers/ # タスク種別ごとのハンドラ
│ │ ├── base.py # TaskHandler 抽象基底クラス
│ │ ├── blog_summarize.py
│ │ ├── career_analysis.py
│ │ └── github_link.py
│ ├── markdown/ # Markdown テンプレート生成
│ ├── pdf/ # WeasyPrint による PDF 生成
│ ├── markdown/ # Markdown 生成(generators / templates / utils)
│ ├── pdf/ # WeasyPrint による PDF 生成(generators / utils)
│ ├── progress_service.py # 進捗状態管理
│ └── shared/ # ドメイン横断の service util
│ ├── resume_format.py # レジュメ整形の共通ロジック(md/pdf 共有)
│ └── sort_utils.py
├── prompts/ # LLM プロンプトテンプレート
├── prompts/ # LLM プロンプトテンプレート(MD)
├── fonts/ # PDF 生成用フォント
└── utils/
└── prompt_loader.py # プロンプトファイルローダ
```

## 主要モジュールのポイント

- **routers/auth/**: パッケージ化されており、`endpoints` / `github_auth` / `oauth_flow` / `token_manager` に責務分割
- **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う
- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。Ollama / Vertex AI を `LLMClient` 抽象で切替
- **services/llm/ と services/intelligence/llm/**: 別物。前者は入出力サニタイザ等の横断 util、後者は LLM プロバイダクライアントの実装
- **routers/auth/ と routers/blog/**: いずれもパッケージ化されている。auth は `endpoints` / `github_auth` / `oauth_flow` / `token_manager`、blog は `accounts` / `score` / `sync` に責務分割
- **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う。現在登録されているタスクは `GITHUB_LINK` の 1 種類のみだが、`AsyncTaskCacheService` / `TaskHandler` は新規タスク追加の拡張ポイントとして汎用化してある(インライン化しない)
- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路
- **services/llm/ と services/intelligence/llm/**: 別物。前者は入出力サニタイザ等の横断 util、後者は LLM プロバイダクライアント(Ollama / Vertex AI を `LLMClient` 抽象で切替)の実装。**両者とも現状どの live エンドポイントからも到達しない休眠インフラ**で、将来用に温存している。デッドコードとして削除しないこと
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation
status_code=422,
content=build_app_error_response(
code=ErrorCode.VALIDATION_ERROR,
message="入力内容を確認してください。",
message=get_error("validation.invalid_input"),
action="入力内容を見直して再試行してください",
error_id=error_id,
).model_dump(exclude_none=True),
Expand Down
8 changes: 6 additions & 2 deletions backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@
"github_login_required": "GitHub連携にはGitHubアカウントでのログインが必要です。",
"github_user_not_found": "GitHubユーザーが見つかりません: {username}",
"profile_fetch_failed": "GitHubプロフィールの取得に失敗しました。しばらくしてから再度お試しください。",
"no_link_cache": "連携データがありません。先にGitHub連携を実行してください。"
"no_link_cache": "連携データがありません。先にGitHub連携を実行してください。",
"not_retryable": "このタスクはリトライできない状態です(現在: {status})。"
},
"validation": {
"required_field": "{field}は必須です。",
"invalid_format": "{field}の形式が正しくありません。",
"invalid_input": "入力内容を確認してください。",
"date_range_invalid": "開始日は終了日より前に設定してください。",
"start_date_required": "開始年月を入力してください。",
"end_date_required": "在職中でない場合は終了年月を入力してください。"
Expand All @@ -59,7 +61,9 @@
"task": {
"already_in_progress": "処理が進行中です。完了までお待ちください。",
"dispatch_failed": "タスクの開始に失敗しました。しばらくしてから再度お試しください。",
"internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。"
"internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。",
"unknown_task_type": "不明なタスク種別: {task_type}",
"execution_failed": "タスク実行中に予期しないエラーが発生しました。"
}
},
"notification": {
Expand Down
4 changes: 2 additions & 2 deletions backend/app/routers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async def retry_github_link(
raise_app_error(
status_code=409,
code=ErrorCode.VALIDATION_ERROR,
message=f"このタスクはリトライできない状態です(現在: {cache.status})",
message=get_error("github_link.not_retryable", status=cache.status),
action="タスクの完了または失敗を待ってから再試行してください",
)

Expand All @@ -187,7 +187,7 @@ async def retry_github_link(
raise_app_error(
status_code=409,
code=ErrorCode.VALIDATION_ERROR,
message=f"このタスクはリトライできない状態です(現在: {cache.status})",
message=get_error("github_link.not_retryable", status=cache.status),
action="タスクの完了または失敗を待ってから再試行してください",
)

Expand Down
10 changes: 7 additions & 3 deletions backend/app/routers/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ async def handle_task(task_type: str, request: Request):
try:
task_type_enum = TaskType(task_type)
except ValueError:
raise HTTPException(status_code=400, detail=f"不明なタスク種別: {task_type}")
raise HTTPException(
status_code=400,
detail=get_error("task.unknown_task_type", task_type=task_type),
)

payload = await request.json()

Expand Down Expand Up @@ -91,15 +94,16 @@ async def handle_task(task_type: str, request: Request):
headers["Retry-After"] = str(int(exc.retry_after))
status_code = 429
raise HTTPException(status_code=status_code, detail=str(exc), headers=headers)
except Exception as exc:
except Exception:
# 予期しないエラーは 500 を返し Cloud Tasks のリトライに任せる
logger.exception(
"タスク実行で予期しないエラー",
extra={"task_id": task_type, "retry_count": retry_count},
)
# 例外詳細は上の logger.exception でのみ残し、クライアント応答へは補間しない(info leak 防止)
raise HTTPException(
status_code=500,
detail=f"タスク実行中に予期しないエラーが発生しました: {exc}",
detail=get_error("task.execution_failed"),
)

return {"status": "ok"}
30 changes: 2 additions & 28 deletions backend/app/services/intelligence/github_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

GitHub REST API を介してパブリックリポジトリのデータを取得します。
実際の API 呼び出しは github.api_client、解析処理は github.repo_analyzer に委譲します。

後方互換性のため、このモジュールから直接インポートできるシンボルを再エクスポートします。
"""

import logging
Expand All @@ -30,9 +28,6 @@
from .github.repo_analyzer import (
DEPENDENCY_FILES as _DEPENDENCY_FILES,
)
from .github.repo_analyzer import (
DEPENDENCY_TO_FRAMEWORK,
)
from .github.repo_analyzer import (
detect_devtools_from_root_files as _detect_devtools_from_root_files,
)
Expand Down Expand Up @@ -63,36 +58,15 @@

logger = logging.getLogger(__name__)

# 後方互換性のため公開シンボルとして再エクスポート
# このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が
# ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。
__all__ = [
"RepoData",
"collect_repos",
"GitHubUserNotFoundError",
"DEPENDENCY_TO_FRAMEWORK",
"_detect_from_root_files",
"_detect_devtools_from_root_files",
"_detect_infras_from_root_files",
"_detect_infras_from_dependencies",
"_parse_requirements_txt",
"_parse_pyproject_toml",
"_parse_package_json",
"_parse_pom_xml",
"_parse_go_mod",
"GITHUB_API",
]


def _detect_from_root_files(root_files: List[str]) -> List[str]:
"""ルートファイルからdevtools・インフラを重複なく検出して返す。"""
seen: set[str] = set()
result: List[str] = []
for item in _detect_devtools_from_root_files(root_files) + _detect_infras_from_root_files(root_files):
if item not in seen:
seen.add(item)
result.append(item)
return result


@dataclass
class RepoData:
"""リポジトリデータを保持するデータクラス。"""
Expand Down
26 changes: 11 additions & 15 deletions backend/app/services/markdown/generators/resume_generator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from typing import Any

from ...shared.resume_format import CATEGORY_LABELS
from ...shared.resume_format import (
CATEGORY_LABELS,
group_stacks_by_category,
normalize_clients,
normalize_team,
)
from ...shared.resume_format import attr as _a
from ..templates import resume_template as tpl
from ..utils.markdown_utils import field_line, format_period
Expand Down Expand Up @@ -57,10 +62,8 @@ def build_resume_markdown(payload: dict[str, Any]) -> str:
lines.append(field_line("資本金", f"{cap}千万円"))
lines.append("")

# clients → projects(後方互換: clients がなく projects がある場合)
clients = _a(exp, "clients", [])
if not clients and _a(exp, "projects", None):
clients = [{"name": "", "projects": _a(exp, "projects", [])}]
# clients → projects(後方互換含む正規化は shared に集約)
clients = normalize_clients(exp)
for client in clients:
client_name = _a(client, "name")
if client_name:
Expand Down Expand Up @@ -90,10 +93,8 @@ def build_resume_markdown(payload: dict[str, Any]) -> str:
result = _a(proj, "result")
if result:
lines.append(field_line("成果", result))
# 体制(後方互換: 旧 scale → team に変換)
team = _a(proj, "team", None)
if not team and _a(proj, "scale", None):
team = {"total": _a(proj, "scale"), "members": []}
# 体制(後方互換: 旧 scale → team の正規化は shared に集約)
team = normalize_team(proj)
if team:
total = _a(team, "total")
members = _a(team, "members", [])
Expand All @@ -114,12 +115,7 @@ def build_resume_markdown(payload: dict[str, Any]) -> str:
lines.append(field_line("工程", ", ".join(phases)))
stacks = _a(proj, "technology_stacks", [])
if stacks:
grouped: dict[str, list[str]] = {}
for st in stacks:
cat = _a(st, "category")
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(_a(st, "name"))
grouped = group_stacks_by_category(stacks)
parts = [
f"{CATEGORY_LABELS.get(c, c)}: {', '.join(ns)}"
for c, ns in grouped.items()
Expand Down
29 changes: 13 additions & 16 deletions backend/app/services/pdf/generators/resume_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@

from ....core.date_utils import JST
from ...shared.resume_format import CATEGORY_LABELS as _CATEGORY_LABELS
from ...shared.resume_format import attr as _a
from ...shared.resume_format import (
attr as _a,
)
from ...shared.resume_format import (
group_stacks_by_category,
normalize_clients,
normalize_team,
)

_CSS_PATH = Path(__file__).resolve().parent.parent / "templates" / "resume.css"
_FONT_PATH = (
Expand Down Expand Up @@ -84,13 +91,7 @@ def _build_project_html(project) -> str:

# 右カラム: 開発環境(技術スタック)
stacks = _a(project, "technology_stacks", [])
grouped: dict[str, list[str]] = {}
for st in stacks:
cat = _a(st, "category")
n = _a(st, "name")
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(n)
grouped = group_stacks_by_category(stacks)
right_parts: list[str] = []
for cat, names in grouped.items():
label = _CATEGORY_LABELS.get(cat, cat)
Expand All @@ -99,10 +100,8 @@ def _build_project_html(project) -> str:
)
right_content = "<br/>".join(right_parts) if right_parts else "-"

# 体制(後方互換: 旧 scale → team)
team = _a(project, "team", None)
if not team and _a(project, "scale", None):
team = {"total": _a(project, "scale"), "members": []}
# 体制(後方互換: 旧 scale → team の正規化は shared に集約)
team = normalize_team(project)
team_parts: list[str] = []
if team:
total = _a(team, "total")
Expand Down Expand Up @@ -187,10 +186,8 @@ def _build_html(resume: dict) -> str:
)
parts.append('<div class="company-body">')

# 取引先 → プロジェクト
clients = _a(exp, "clients", [])
if not clients and _a(exp, "projects", None):
clients = [{"name": "", "projects": _a(exp, "projects", [])}]
# 取引先 → プロジェクト(後方互換含む正規化は shared に集約)
clients = normalize_clients(exp)
for client in clients:
client_name = _a(client, "name")
if client_name:
Expand Down
Loading
Loading