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
2 changes: 1 addition & 1 deletion .claude/rules/infra/opentofu.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ infra/
└── environments/ # dev, stg, prod(各環境で tfvars 管理)
```

CLI: OpenTofu (`tofu`) を使用する。Nix で管理されており `nix develop` シェル内で利用可能。`.tf` の構文は Terraform と同一。
CLI: OpenTofu (`tofu`) を使用する。Nix で管理されており `nix develop` シェル内で利用可能。`.tf` の構文は Terraform と同一。`tofu init` / `plan` / `apply` の具体的な実行手順・GCS backend 認証は `docs/deployment.md`「OpenTofu」セクションを正本として参照(ここでは再掲しない)。
デプロイ: GitHub Actions で `dev` ブランチ push 時に frontend → Cloudflare Pages、backend → Docker → Artifact Registry → Cloud Run。

DB は Turso (libSQL) を使用。**DB 本体は OpenTofu の `infra/modules/turso/` で管理**(jpedroh/turso provider)。`module.turso.database_url` を cloud_run module の `turso_database_url` に渡す構成。auth token のみ state に乗せたくないため `turso CLI` で発行 → Secret Manager `<stack_name>-turso-auth-token` に手動投入する運用。詳細は `docs/data-model.md` の「Turso セットアップ」参照。
Expand Down
8 changes: 7 additions & 1 deletion .jscpd.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@
"**/infra/environments/prod/moved.tf",
"**/infra/environments/dev/outputs.tf",
"**/infra/environments/stg/outputs.tf",
"**/infra/environments/prod/outputs.tf"
"**/infra/environments/prod/outputs.tf",
"**/infra/environments/dev/versions.tf",
"**/infra/environments/stg/versions.tf",
"**/infra/environments/prod/versions.tf",
"**/infra/environments/dev/main.tf",
"**/infra/environments/stg/main.tf",
"**/infra/environments/prod/main.tf"
],
"reporters": ["json", "html", "markdown", "consoleFull"],
"output": "report/dupe",
Expand Down
19 changes: 3 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,9 @@ GitHub活動分析、ブログ連携による発信力を集計

## クイックスタート

詳細手順は [docs/development.md](./docs/development.md) を参照。

```bash
nix develop # devshell に入る(direnv 利用時は自動)
make setup # git hooks + backend (.venv + uv) + frontend (npm ci)
make generate-keys # JWT RS256 鍵ペアを生成
cp backend/.env.example backend/.env

make dev # docker compose up(FastAPI + Ollama + Redis + libSQL)
```

CI 相当を一括で走らせる:

```bash
make ci # lint + test + build-frontend
```
初回セットアップ(`nix develop` / `make setup` / `make generate-keys` / `make dev`)と
CI 相当の一括実行(`make ci`)の手順は [docs/development.md](./docs/development.md) を正本として参照してください。
README にコマンドを再掲すると docs と二重管理になり同期漏れの原因になるため、リンクのみとしています。

## システム構成図

Expand Down
41 changes: 41 additions & 0 deletions backend/alembic_migrations/versions/0039_add_capital_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""resume_experiences に capital_unit カラムを追加する

資本金の単位(万円 / 百万円 / 千万円 / 億円)を在籍企業ごとに選択できるようにする。
従来は表示時に「千万円」固定だったため、既存行の後方互換として server_default を
「千万円」にする。

libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため、
downgrade の列削除は batch_alter_table(テーブル再作成)で行う。ADD COLUMN は
SQLite が直接サポートするため upgrade は op.add_column をそのまま使う。

Revision ID: 0039_add_capital_unit
Revises: 0038_add_project_periods_table
Create Date: 2026-05-29 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0039_add_capital_unit"
down_revision: Union[str, None] = "0038_add_project_periods_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"resume_experiences",
sa.Column(
"capital_unit",
sa.String(12),
nullable=False,
server_default="千万円",
),
)


def downgrade() -> None:
with op.batch_alter_table("resume_experiences") as batch_op:
batch_op.drop_column("capital_unit")
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""非IT経歴フラグ・詳細と休暇エントリのカラムを追加する

- resume_experiences に is_it_company(IT企業かどうか)と description(非IT時の詳細)を追加。
既存行は IT 企業前提だったため is_it_company の server_default を True(1) にする。
- resume_clients に is_vacation(休暇エントリか)と vacation_* 期間/詳細を追加。
既存行は通常の取引先のため is_vacation の server_default を False(0) にする。

libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を
そのまま使う。downgrade の列削除は ALTER/DROP COLUMN 非対応のため batch_alter_table
(テーブル再作成)で行う。

Revision ID: 0040_add_resume_non_it_and_vacation
Revises: 0039_add_capital_unit
Create Date: 2026-05-29 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0040_add_resume_non_it_and_vacation"
down_revision: Union[str, None] = "0039_add_capital_unit"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"resume_experiences",
sa.Column("is_it_company", sa.Boolean(), nullable=False, server_default=sa.text("1")),
)
op.add_column(
"resume_experiences",
sa.Column("description", sa.Text(), nullable=False, server_default=""),
)
op.add_column(
"resume_clients",
sa.Column("is_vacation", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
op.add_column(
"resume_clients",
sa.Column("vacation_start_date", sa.Date(), nullable=True),
)
op.add_column(
"resume_clients",
sa.Column("vacation_end_date", sa.Date(), nullable=True),
)
op.add_column(
"resume_clients",
sa.Column("vacation_is_current", sa.Boolean(), nullable=False, server_default=sa.text("0")),
)
op.add_column(
"resume_clients",
sa.Column("vacation_description", sa.Text(), nullable=False, server_default=""),
)


def downgrade() -> None:
with op.batch_alter_table("resume_clients") as batch_op:
batch_op.drop_column("vacation_description")
batch_op.drop_column("vacation_is_current")
batch_op.drop_column("vacation_end_date")
batch_op.drop_column("vacation_start_date")
batch_op.drop_column("is_vacation")
with op.batch_alter_table("resume_experiences") as batch_op:
batch_op.drop_column("description")
batch_op.drop_column("is_it_company")
7 changes: 7 additions & 0 deletions backend/app/core/env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
VERTEX_PROJECT_ID = "VERTEX_PROJECT_ID"
VERTEX_LOCATION = "VERTEX_LOCATION"
VERTEX_MODEL = "VERTEX_MODEL"
# Ollama プロバイダ(休眠インフラ・温存)。ローカル開発では docker-compose.yml の
# environment ブロックで注入する。本番(Cloud Run)には注入していない。
# なお `OLLAMA_KEEP_ALIVE`(docker-compose.yml の ollama サービス側)は
# Ollama サーバ自身の設定であり backend は参照しないため、ここには定義しない。
OLLAMA_BASE_URL = "OLLAMA_BASE_URL"
OLLAMA_MODEL = "OLLAMA_MODEL"
OLLAMA_TIMEOUT = "OLLAMA_TIMEOUT"

# --- 非同期タスク(Cloud Tasks / Local BackgroundTasks) ---

Expand Down
3 changes: 2 additions & 1 deletion backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"github_user_not_found": "GitHubユーザーが見つかりません: {username}",
"profile_fetch_failed": "GitHubプロフィールの取得に失敗しました。しばらくしてから再度お試しください。",
"no_link_cache": "連携データがありません。先にGitHub連携を実行してください。",
"not_retryable": "このタスクはリトライできない状態です(現在: {status})。"
"not_retryable": "このタスクはリトライできない状態です(現在: {status})。",
"contribution_fetch_failed": "コントリビューション情報の取得に失敗しました。スキル集計は表示されています。"
},
"validation": {
"required_field": "{field}は必須です。",
Expand Down
25 changes: 25 additions & 0 deletions backend/app/models/resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ class ResumeExperience(Base):
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
employee_count: Mapped[str] = mapped_column(String(60), nullable=False, default="")
capital: Mapped[str] = mapped_column(String(120), nullable=False, default="")
# 資本金の単位(万円 / 百万円 / 千万円 / 億円)。既定は後方互換のため「千万円」。
capital_unit: Mapped[str] = mapped_column(String(12), nullable=False, default="千万円")
# IT 企業かどうか。False(非IT)の場合は取引先/プロジェクトを持たず description を使う。
is_it_company: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 非IT企業の職務内容を記述する自由記述欄(見出し「詳細」)。
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
client_rows: Mapped[list["ResumeClient"]] = relationship(
back_populates="experience",
cascade="all, delete-orphan",
Expand Down Expand Up @@ -138,6 +144,16 @@ class ResumeClient(Base):
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
has_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 休暇エントリ。True のとき取引先ではなく在籍中の休暇を表し vacation_* を使う。
is_vacation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
vacation_start_date_value: Mapped[date | None] = mapped_column(
"vacation_start_date", Date, nullable=True
)
vacation_end_date_value: Mapped[date | None] = mapped_column(
"vacation_end_date", Date, nullable=True
)
vacation_is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
vacation_description: Mapped[str] = mapped_column(Text, nullable=False, default="")
project_rows: Mapped[list["ResumeProject"]] = relationship(
back_populates="client",
cascade="all, delete-orphan",
Expand All @@ -150,6 +166,15 @@ def projects(self) -> list["ResumeProject"]:
"""プロジェクトを期間の降順でソートして返す。"""
return sort_by_period_desc(list(self.project_rows))

@property
def vacation_start_date(self) -> str:
return format_year_month(self.vacation_start_date_value) or ""

@property
def vacation_end_date(self) -> str:
"""DB の vacation_end_date が NULL(継続中)の場合は "" を返す。"""
return format_year_month(self.vacation_end_date_value) or ""


class ResumeProject(Base):
__tablename__ = "resume_projects"
Expand Down
65 changes: 36 additions & 29 deletions backend/app/repositories/blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def list_by_user(self, platform: str | None = None) -> list[BlogArticle]:
return list(self.db.scalars(statement).all())

def upsert_many(self, articles: list[dict]) -> int:
"""複数アカウントにまたがる記事を追加・更新する(既存の削除は行わない)。"""
normalized_articles = [self._normalize_article(article) for article in articles]
if not normalized_articles:
return 0
Expand All @@ -102,29 +103,14 @@ def upsert_many(self, articles: list[dict]) -> int:
.options(selectinload(BlogArticle.tag_rows))
)
existing_articles = list(self.db.scalars(existing_statement).all())
existing_map = {
(article.account_id, article.external_id): article for article in existing_articles
}

added = 0
for article in normalized_articles:
key = (article["account_id"], article["external_id"])
existing = existing_map.get(key)
if existing:
self._apply_article_payload(existing, article)
continue

entity = BlogArticle(account_id=article["account_id"])
self._apply_article_payload(entity, article)
self.db.add(entity)
existing_map[key] = entity
added += 1

self.db.commit()
return added
return self._merge(normalized_articles, existing_articles, delete_missing=False)

def sync_many(self, account_id: str, articles: list[dict]) -> int:
normalized_articles = [self._normalize_article(article) for article in articles]
"""単一アカウントの記事を全置換する(incoming に無い既存記事は削除する)。"""
# 新規エンティティ生成と key 突合のため、各記事へ account_id を確実に付与する。
normalized_articles = [
self._normalize_article({**article, "account_id": account_id}) for article in articles
]

existing_statement = (
select(BlogArticle)
Expand All @@ -134,24 +120,45 @@ def sync_many(self, account_id: str, articles: list[dict]) -> int:
.options(selectinload(BlogArticle.tag_rows))
)
existing_articles = list(self.db.scalars(existing_statement).all())
existing_map = {article.external_id: article for article in existing_articles}
incoming_external_ids = {article["external_id"] for article in normalized_articles}
return self._merge(normalized_articles, existing_articles, delete_missing=True)

def _merge(
self,
normalized_articles: list[dict[str, Any]],
existing_articles: list[BlogArticle],
*,
delete_missing: bool,
) -> int:
"""正規化済み記事を既存レコードへマージし、追加件数を返す。

key は ``(account_id, external_id)``。``delete_missing=True`` のとき、
incoming に存在しない既存記事を削除する(単一アカウントの全置換 sync 用)。
"""
existing_map = {
(article.account_id, article.external_id): article for article in existing_articles
}

for external_id, article in existing_map.items():
if external_id not in incoming_external_ids:
self.db.delete(article)
if delete_missing:
incoming_keys = {
(article["account_id"], article["external_id"])
for article in normalized_articles
}
for key, article in list(existing_map.items()):
if key not in incoming_keys:
self.db.delete(article)

added = 0
for article in normalized_articles:
existing = existing_map.get(article["external_id"])
key = (article["account_id"], article["external_id"])
existing = existing_map.get(key)
if existing:
self._apply_article_payload(existing, article)
continue

entity = BlogArticle(account_id=account_id)
entity = BlogArticle(account_id=article["account_id"])
self._apply_article_payload(entity, article)
self.db.add(entity)
existing_map[article["external_id"]] = entity
existing_map[key] = entity
added += 1

self.db.commit()
Expand Down
10 changes: 10 additions & 0 deletions backend/app/repositories/resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum
is_current=payload.get("is_current", False),
employee_count=payload.get("employee_count", ""),
capital=payload.get("capital", ""),
capital_unit=payload.get("capital_unit", "千万円"),
is_it_company=payload.get("is_it_company", True),
description=payload.get("description", ""),
client_rows=[
self._build_client_row(client_index, client)
for client_index, client in enumerate(payload.get("clients", []))
Expand All @@ -86,10 +89,17 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum
def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient:
projects = list(payload.get("projects", []))
sorted_projects = sorted(projects, key=self._project_sort_key)
vacation_start = payload.get("vacation_start_date") or ""
vacation_end = payload.get("vacation_end_date") or ""
return ResumeClient(
sort_order=index,
name=payload.get("name", ""),
has_client=payload.get("has_client", True),
is_vacation=payload.get("is_vacation", False),
vacation_start_date_value=parse_year_month(vacation_start) if vacation_start else None,
vacation_end_date_value=parse_year_month(vacation_end) if vacation_end else None,
vacation_is_current=payload.get("vacation_is_current", False),
vacation_description=payload.get("vacation_description", ""),
project_rows=[
self._build_project_row(project_index, project)
for project_index, project in enumerate(sorted_projects)
Expand Down
Loading
Loading