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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@ docs/adr/XXXX-kebab-case-title.md
| [ADR-0007](docs/adr/0007-openapi-typescript-codegen.md) | OpenAPI → TypeScript 型生成(codegen-drift CI) | Accepted |
| [ADR-0008](docs/adr/0008-remove-llm-to-rule-based-design.md) | LLM プロバイダ抽象化の撤去とルールベース設計への統一 | Superseded by ADR-0010 |
| [ADR-0009](docs/adr/0009-frontend-toast-notification.md) | フロントエンドのトースト通知統一 | Accepted |
| [ADR-0011](docs/adr/0011-frontend-textlint-proofread.md) | フロントエンド完結型文章校正(textlint) | Accepted |
| [ADR-0011](docs/adr/0011-frontend-textlint-proofread.md) | フロントエンド完結型文章校正(textlint) | Deprecated |
| [ADR-0010](docs/adr/0010-devforge-agent.md) | DevForge Agent 機能の導入 | Accepted |
| [ADR-0016](docs/adr/0016-github-skill-inference.md) | GitHub 連携によるスキル推論基盤(3 層モデル) | Accepted |
128 changes: 128 additions & 0 deletions backend/alembic_migrations/versions/0045_add_github_skill_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""GitHub 連携スキル推論の 3 層テーブルを追加する(ADR-0016 D1)

- github_skills : Layer 1 / 正規化スキル(language / package)
- github_skill_evidence : Layer 2 / 技術×リポの根拠(signal_source・量的シグナル)
- github_skill_proficiency: Layer 3 / 習熟度・文脈(本フェーズ未投入)

いずれも新規テーブル作成のみ(op.create_table)で、既存テーブルの再作成は伴わない。
FK は users / github_skills を親に CASCADE 削除。

Revision ID: 0045_add_github_skill_tables
Revises: 0044_add_credit_billing
Create Date: 2026-06-25 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0045_add_github_skill_tables"
down_revision: Union[str, None] = "0044_add_credit_billing"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"github_skills",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("canonical_name", sa.String(length=255), nullable=False),
sa.Column("ecosystem", sa.String(length=20), nullable=False, server_default=""),
sa.Column("parent", sa.String(length=255), nullable=True),
sa.Column("display_name", sa.String(length=255), 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,
),
sa.UniqueConstraint(
"user_id", "kind", "ecosystem", "canonical_name",
name="uq_github_skills_identity",
),
)
op.create_index("ix_github_skills_user_id", "github_skills", ["user_id"])

op.create_table(
"github_skill_evidence",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"skill_id",
sa.String(length=36),
sa.ForeignKey("github_skills.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_full_name", sa.String(length=255), nullable=False),
sa.Column("repo_url", sa.String(length=255), nullable=False, server_default=""),
sa.Column("signal_source", sa.String(length=30), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False, server_default="0"),
sa.Column("language_bytes", sa.Integer(), nullable=True),
sa.Column("dependency_kind", sa.String(length=20), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint(
"skill_id", "repo_full_name", "signal_source",
name="uq_github_skill_evidence_identity",
),
)
op.create_index(
"ix_github_skill_evidence_skill_id", "github_skill_evidence", ["skill_id"]
)

op.create_table(
"github_skill_proficiency",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"skill_id",
sa.String(length=36),
sa.ForeignKey("github_skills.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("self_assessed_level", sa.String(length=20), nullable=True),
sa.Column("narrative", sa.Text(), nullable=True),
sa.Column("duration_months", sa.Integer(), nullable=True),
sa.Column("scale", sa.String(length=100), nullable=True),
sa.Column("source", sa.String(length=20), nullable=True),
sa.Column("reviewed", sa.Boolean(), nullable=False, server_default="0"),
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("github_skill_proficiency")
op.drop_index(
"ix_github_skill_evidence_skill_id", table_name="github_skill_evidence"
)
op.drop_table("github_skill_evidence")
op.drop_index("ix_github_skills_user_id", table_name="github_skills")
op.drop_table("github_skills")
4 changes: 4 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ResumeProjectTechnologyStack,
ResumeQualification,
)
from .skill import GitHubSkill, GitHubSkillEvidence, GitHubSkillProficiency
from .user import User

__all__ = [
Expand All @@ -25,6 +26,9 @@
"BlogArticleTag",
"CreditTransaction",
"GitHubLinkCache",
"GitHubSkill",
"GitHubSkillEvidence",
"GitHubSkillProficiency",
"MQualification",
"MTechnologyStack",
"Notification",
Expand Down
173 changes: 173 additions & 0 deletions backend/app/models/skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""GitHub 連携スキル推論の 3 層モデル(ADR-0016 D1)。

機械は「幅」(Layer 1-2)、人間は「深さ」(Layer 3)を埋める責務分離。

- ``github_skills`` : Layer 1 / 正規化エンティティ(language / package)
- ``github_skill_evidence`` : Layer 2 / 技術×リポの根拠(signal_source・量的シグナル)
- ``github_skill_proficiency``: Layer 3 / 習熟度・文脈(本フェーズでは未投入)

連携の再実行ごとに Layer 1-2 はユーザー単位で洗い替える(CASCADE で evidence も削除)。
Layer 3 は人間/agent が後追いで埋める想定で、洗い替え時の保全は後続フェーズの課題。
"""

import uuid
from datetime import datetime

from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship

from ..db import Base


class GitHubSkill(Base):
"""Layer 1: 正規化されたスキルエンティティ(D1/D2)。

``kind`` で language / package を区別する(検出方法と信頼度の出方が異なるため / D2)。
language の ``ecosystem`` は N/A として空文字で持つ(NULL だと一意制約が効かないため)。
package ID はエコシステム内で一意 = canonical なので辞書正規化はしない(D3)。
"""

__tablename__ = "github_skills"
__table_args__ = (
UniqueConstraint(
"user_id", "kind", "ecosystem", "canonical_name",
name="uq_github_skills_identity",
),
)

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", ondelete="CASCADE"), nullable=False, index=True
)
# "language" / "package"
kind: Mapped[str] = mapped_column(String(20), nullable=False)
# 言語=Linguist 正規名 / package=エコシステム内で一意な package ID
canonical_name: Mapped[str] = mapped_column(String(255), nullable=False)
# package のエコシステム(npm/pypi/go/cargo)。language は "" (N/A)
ecosystem: Mapped[str] = mapped_column(
String(20), nullable=False, default="", server_default=""
)
# Linguist の group(言語の親)。無ければ NULL
parent: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
# 表示名・粒度畳みの確定値(agent 提案→人間確定 / D3)。未確定は NULL
display_name: Mapped[str | None] = mapped_column(
String(255), 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,
)

evidence: Mapped[list["GitHubSkillEvidence"]] = relationship(
back_populates="skill", cascade="all, delete-orphan"
)
proficiency: Mapped["GitHubSkillProficiency | None"] = relationship(
back_populates="skill", cascade="all, delete-orphan", uselist=False
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class GitHubSkillEvidence(Base):
"""Layer 2: 技術×根拠リポの N:N(機械が埋める / D1)。

``signal_source`` で根拠の出所を区別する:
- ``language_bytes`` : Linguist のバイト比率(言語)
- ``manifest_declared`` : manifest の宣言(package / declare ステージ)
- ``actual_import`` : import 解析で実使用へ昇格(verify ステージ / 後続)
"""

__tablename__ = "github_skill_evidence"
__table_args__ = (
UniqueConstraint(
"skill_id", "repo_full_name", "signal_source",
name="uq_github_skill_evidence_identity",
),
)

id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
skill_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("github_skills.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# 根拠リポジトリ(owner/name)と URL(経歴書の証跡用)
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False)
repo_url: Mapped[str] = mapped_column(String(255), nullable=False, default="", server_default="")
signal_source: Mapped[str] = mapped_column(String(30), nullable=False)
# 0.0–1.0 の信頼度
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
# 言語シグナル: このリポでのバイト数
language_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
# package シグナル: direct/dev/indirect/peer/build(D7)
dependency_kind: Mapped[str | None] = mapped_column(
String(20), nullable=True, default=None
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=func.now(), server_default=func.now(), nullable=False
)

skill: Mapped["GitHubSkill"] = relationship(back_populates="evidence")


class GitHubSkillProficiency(Base):
"""Layer 3: 習熟度・文脈(人間/agent が後追いで埋める / D1)。

本フェーズ(基盤+declare)では投入しない。スキーマだけ用意する。
"""

__tablename__ = "github_skill_proficiency"

id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
skill_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("github_skills.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# 自己評価レベル(例: beginner/intermediate/advanced)。確定まで NULL
self_assessed_level: Mapped[str | None] = mapped_column(
String(20), nullable=True, default=None
)
narrative: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
duration_months: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
scale: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
# 出所: "agent"(生成)/ "human"(手入力)
source: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
# 人間レビュー済みか
reviewed: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
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,
)

skill: Mapped["GitHubSkill"] = relationship(back_populates="proficiency")
2 changes: 2 additions & 0 deletions backend/app/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
from .blog import BlogAccountRepository, BlogArticleRepository
from .master_data import MQualificationRepository, MTechnologyStackRepository
from .resume import ResumeRepository
from .skill import GitHubSkillRepository
from .user import UserRepository

__all__ = [
"BaseMasterRepository",
"BillingRepository",
"BlogAccountRepository",
"BlogArticleRepository",
"GitHubSkillRepository",
"MQualificationRepository",
"MTechnologyStackRepository",
"ResumeRepository",
Expand Down
Loading
Loading