diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md
index 227e47c9..9bae8aaa 100644
--- a/.claude/rules/backend/architecture.md
+++ b/.claude/rules/backend/architecture.md
@@ -11,6 +11,7 @@ backend/app/
├── messages.json # ユーザー向けメッセージ・通知文言の定義
├── core/ # 設定・メッセージ・認証・暗号化などの横断基盤
│ ├── settings.py
+│ ├── env_keys.py # 環境変数名の定数定義(正本)
│ ├── messages.py
│ ├── logging_utils.py
│ ├── date_utils.py
@@ -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
@@ -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/ # ブログ収集・技術記事判定・スコア算出
@@ -58,10 +58,6 @@ 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
@@ -69,39 +65,34 @@ backend/app/
│ │ ├── 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 # プロンプトファイルローダ
@@ -109,7 +100,7 @@ backend/app/
## 主要モジュールのポイント
-- **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 エンドポイントからも到達しない休眠インフラ**で、将来用に温存している。デッドコードとして削除しないこと
diff --git a/backend/app/main.py b/backend/app/main.py
index 44752802..dbcf8172 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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),
diff --git a/backend/app/messages.json b/backend/app/messages.json
index 98f2550c..506dfd36 100644
--- a/backend/app/messages.json
+++ b/backend/app/messages.json
@@ -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": "在職中でない場合は終了年月を入力してください。"
@@ -59,7 +61,9 @@
"task": {
"already_in_progress": "処理が進行中です。完了までお待ちください。",
"dispatch_failed": "タスクの開始に失敗しました。しばらくしてから再度お試しください。",
- "internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。"
+ "internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。",
+ "unknown_task_type": "不明なタスク種別: {task_type}",
+ "execution_failed": "タスク実行中に予期しないエラーが発生しました。"
}
},
"notification": {
diff --git a/backend/app/routers/github_link.py b/backend/app/routers/github_link.py
index c2053f03..d31de433 100644
--- a/backend/app/routers/github_link.py
+++ b/backend/app/routers/github_link.py
@@ -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="タスクの完了または失敗を待ってから再試行してください",
)
@@ -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="タスクの完了または失敗を待ってから再試行してください",
)
diff --git a/backend/app/routers/internal.py b/backend/app/routers/internal.py
index c7d8bdfc..7f1b7a6b 100644
--- a/backend/app/routers/internal.py
+++ b/backend/app/routers/internal.py
@@ -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()
@@ -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"}
diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py
index 9677d73e..5710283c 100644
--- a/backend/app/services/intelligence/github_collector.py
+++ b/backend/app/services/intelligence/github_collector.py
@@ -3,8 +3,6 @@
GitHub REST API を介してパブリックリポジトリのデータを取得します。
実際の API 呼び出しは github.api_client、解析処理は github.repo_analyzer に委譲します。
-
-後方互換性のため、このモジュールから直接インポートできるシンボルを再エクスポートします。
"""
import logging
@@ -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,
)
@@ -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:
"""リポジトリデータを保持するデータクラス。"""
diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py
index 0daa31de..9962da03 100644
--- a/backend/app/services/markdown/generators/resume_generator.py
+++ b/backend/app/services/markdown/generators/resume_generator.py
@@ -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
@@ -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:
@@ -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", [])
@@ -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()
diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py
index 1f2fcc67..8a7e1a42 100644
--- a/backend/app/services/pdf/generators/resume_generator.py
+++ b/backend/app/services/pdf/generators/resume_generator.py
@@ -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 = (
@@ -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)
@@ -99,10 +100,8 @@ def _build_project_html(project) -> str:
)
right_content = "
".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")
@@ -187,10 +186,8 @@ def _build_html(resume: dict) -> str:
)
parts.append('