diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1640920..6dcee337 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 | diff --git a/backend/alembic_migrations/versions/0045_add_github_skill_tables.py b/backend/alembic_migrations/versions/0045_add_github_skill_tables.py new file mode 100644 index 00000000..1df6f238 --- /dev/null +++ b/backend/alembic_migrations/versions/0045_add_github_skill_tables.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6b89451f..a9112585 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ ResumeProjectTechnologyStack, ResumeQualification, ) +from .skill import GitHubSkill, GitHubSkillEvidence, GitHubSkillProficiency from .user import User __all__ = [ @@ -25,6 +26,9 @@ "BlogArticleTag", "CreditTransaction", "GitHubLinkCache", + "GitHubSkill", + "GitHubSkillEvidence", + "GitHubSkillProficiency", "MQualification", "MTechnologyStack", "Notification", diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py new file mode 100644 index 00000000..0b14da24 --- /dev/null +++ b/backend/app/models/skill.py @@ -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 + ) + + +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") diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index aecbc272..f6023e8f 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -5,6 +5,7 @@ from .blog import BlogAccountRepository, BlogArticleRepository from .master_data import MQualificationRepository, MTechnologyStackRepository from .resume import ResumeRepository +from .skill import GitHubSkillRepository from .user import UserRepository __all__ = [ @@ -12,6 +13,7 @@ "BillingRepository", "BlogAccountRepository", "BlogArticleRepository", + "GitHubSkillRepository", "MQualificationRepository", "MTechnologyStackRepository", "ResumeRepository", diff --git a/backend/app/repositories/skill.py b/backend/app/repositories/skill.py new file mode 100644 index 00000000..c476b6ab --- /dev/null +++ b/backend/app/repositories/skill.py @@ -0,0 +1,72 @@ +"""GitHub 連携スキル(3 層)のデータアクセス(ADR-0016)。 + +連携の実行ごとに Layer 1-2 をユーザー単位で洗い替える。Layer 3(proficiency)は +本フェーズでは投入しないが、CASCADE 削除の対象になる点に留意(保全は後続課題)。 +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from ..models import GitHubSkill, GitHubSkillEvidence +from ..services.intelligence.skills import DetectedSkill + + +class GitHubSkillRepository: + """ユーザーの GitHub 連携スキルの読み書き。""" + + def __init__(self, db: Session, user_id: str): + self.db = db + self.user_id = user_id + + def list_for_user(self) -> list[GitHubSkill]: + """ユーザーのスキルを evidence/proficiency 付きで取得する。""" + statement = ( + select(GitHubSkill) + .where(GitHubSkill.user_id == self.user_id) + .options( + selectinload(GitHubSkill.evidence), + selectinload(GitHubSkill.proficiency), + ) + .order_by(GitHubSkill.kind, GitHubSkill.canonical_name) + ) + return list(self.db.scalars(statement).all()) + + def replace_for_user(self, detected: list[DetectedSkill]) -> None: + """ユーザーの Layer 1-2 を洗い替える(既存削除 → 一括挿入)。 + + 既存行は ORM セッション経由で削除し、``cascade="all, delete-orphan"`` で + evidence/proficiency も確実に消す。Core 一括 DELETE は DB の + ``ON DELETE CASCADE`` に依存するが、SQLite/libSQL は ``PRAGMA foreign_keys`` + が ON でないと FK を強制せず孤児行が残るため、バックエンド非依存の ORM 削除にする。 + """ + existing = self.db.scalars( + select(GitHubSkill).where(GitHubSkill.user_id == self.user_id) + ).all() + for skill in existing: + self.db.delete(skill) + # 同一 identity(user_id+kind+ecosystem+canonical_name)の再挿入前に削除を確定する + self.db.flush() + + for item in detected: + skill = GitHubSkill( + user_id=self.user_id, + kind=item.kind, + canonical_name=item.canonical_name, + ecosystem=item.ecosystem, + parent=item.parent, + display_name=item.display_name, + ) + skill.evidence = [ + GitHubSkillEvidence( + repo_full_name=ev.repo_full_name, + repo_url=ev.repo_url, + signal_source=ev.signal_source, + confidence=ev.confidence, + language_bytes=ev.language_bytes, + dependency_kind=ev.dependency_kind, + ) + for ev in item.evidence + ] + self.db.add(skill) + + self.db.commit() diff --git a/backend/app/routers/github_link.py b/backend/app/routers/github_link.py index 55183401..cd9a335c 100644 --- a/backend/app/routers/github_link.py +++ b/backend/app/routers/github_link.py @@ -17,11 +17,18 @@ from ..core.security.dependencies import limiter from ..db import get_db from ..models import GitHubLinkCache, User +from ..repositories.skill import GitHubSkillRepository from ..schemas.github_link import ( CachedGitHubLinkResponse, GitHubLinkRequest, ProgressResponse, ) +from ..schemas.github_skill import ( + GitHubSkillItem, + GitHubSkillsResponse, + SkillEvidence, + SkillProficiency, +) from ..schemas.shared import TaskAcceptedResponse, TaskStatusResponse from ..services.intelligence.github_link_service import get_or_create_github_link_cache from ..services.tasks import AsyncTaskCacheService, TaskType @@ -88,6 +95,53 @@ async def get_link_progress( return ProgressResponse(**data) +def _to_skill_item(skill) -> GitHubSkillItem: + """ORM の GitHubSkill を API スキーマへ変換する。""" + proficiency = None + if skill.proficiency is not None: + proficiency = SkillProficiency( + self_assessed_level=skill.proficiency.self_assessed_level, + narrative=skill.proficiency.narrative, + duration_months=skill.proficiency.duration_months, + scale=skill.proficiency.scale, + source=skill.proficiency.source, + reviewed=skill.proficiency.reviewed, + ) + return GitHubSkillItem( + kind=skill.kind, + canonical_name=skill.canonical_name, + # 言語は ecosystem を "" で持つので API では null に正規化する + ecosystem=skill.ecosystem or None, + parent=skill.parent, + display_name=skill.display_name, + evidence=[ + SkillEvidence( + repo_full_name=ev.repo_full_name, + repo_url=ev.repo_url, + signal_source=ev.signal_source, + confidence=ev.confidence, + language_bytes=ev.language_bytes, + dependency_kind=ev.dependency_kind, + ) + for ev in skill.evidence + ], + proficiency=proficiency, + ) + + +@router.get("/skills", response_model=GitHubSkillsResponse) +def get_skills( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """GitHub 連携で推論した 3 層スキル(ADR-0016)を取得する。 + + 連携がまだ実行されていない場合は空配列を返す。 + """ + skills = GitHubSkillRepository(db, user.id).list_for_user() + return GitHubSkillsResponse(skills=[_to_skill_item(s) for s in skills]) + + @router.get("/cache/status", response_model=TaskStatusResponse) def get_cache_status( user: User = Depends(get_current_user), diff --git a/backend/app/schemas/github_skill.py b/backend/app/schemas/github_skill.py new file mode 100644 index 00000000..663a8a62 --- /dev/null +++ b/backend/app/schemas/github_skill.py @@ -0,0 +1,56 @@ +"""GitHub 連携スキル推論(3 層)の API スキーマ(ADR-0016)。""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class SkillEvidence(BaseModel): + """Layer 2: 技術×リポの根拠。""" + + repo_full_name: str = Field(description="根拠リポジトリ(owner/name)") + repo_url: str = Field(description="リポジトリ URL(経歴書の証跡用)") + signal_source: str = Field( + description="根拠の出所(language_bytes / manifest_declared / actual_import)" + ) + confidence: float = Field(description="信頼度(0.0–1.0)") + language_bytes: Optional[int] = Field( + default=None, description="言語シグナルのバイト数(package では null)" + ) + dependency_kind: Optional[str] = Field( + default=None, + description="依存の種類(direct/dev/indirect/peer/build。言語では null)", + ) + + +class SkillProficiency(BaseModel): + """Layer 3: 習熟度・文脈(人間/agent が後追いで埋める。本フェーズは未投入)。""" + + self_assessed_level: Optional[str] = Field(default=None, description="自己評価レベル") + narrative: Optional[str] = Field(default=None, description="文脈の説明文") + duration_months: Optional[int] = Field(default=None, description="従事期間(月)") + scale: Optional[str] = Field(default=None, description="規模") + source: Optional[str] = Field(default=None, description="出所(agent / human)") + reviewed: bool = Field(default=False, description="人間レビュー済みか") + + +class GitHubSkillItem(BaseModel): + """Layer 1: 正規化スキルと、その根拠・習熟度。""" + + kind: str = Field(description="スキル種別(language / package)") + canonical_name: str = Field(description="正規名(言語=Linguist 名 / package=package ID)") + ecosystem: Optional[str] = Field( + default=None, description="package のエコシステム(npm/pypi/go/cargo)。言語では null" + ) + parent: Optional[str] = Field(default=None, description="親(Linguist の group)") + display_name: Optional[str] = Field( + default=None, description="表示名(粒度畳みの確定値。未確定は null)" + ) + evidence: List[SkillEvidence] = Field(default_factory=list) + proficiency: Optional[SkillProficiency] = Field(default=None) + + +class GitHubSkillsResponse(BaseModel): + """ユーザーの GitHub 連携スキル一覧(3 層)。""" + + skills: List[GitHubSkillItem] = Field(default_factory=list) diff --git a/backend/app/services/intelligence/github/api_client.py b/backend/app/services/intelligence/github/api_client.py index 7c84c4b0..aa27885d 100644 --- a/backend/app/services/intelligence/github/api_client.py +++ b/backend/app/services/intelligence/github/api_client.py @@ -163,3 +163,53 @@ async def fetch_languages( except httpx.HTTPError: logger.warning("Failed to fetch languages for %s/%s", owner, repo) return {} + + +async def fetch_root_filenames( + client: httpx.AsyncClient, + owner: str, + repo: str, +) -> List[str]: + """リポジトリ直下のファイル名一覧を取得する(declare の manifest 探索用 / D7)。 + + manifest の取得はベストエフォート(1 リポの失敗で連携全体を落とさない)なので、 + エラー時は空リストを返す。サブツリー探索は v1 では行わない(直下のみ)。 + """ + if not _is_valid_owner_repo(owner, repo): + return [] + try: + resp = await client.get(f"/repos/{owner}/{repo}/contents") + if resp.status_code != 200: + return [] + entries = resp.json() + if not isinstance(entries, list): + return [] + return [e["name"] for e in entries if e.get("type") == "file" and e.get("name")] + except httpx.HTTPError: + logger.warning("Failed to list root contents for %s/%s", owner, repo) + return [] + + +async def fetch_repo_file( + client: httpx.AsyncClient, + owner: str, + repo: str, + path: str, +) -> str | None: + """リポジトリ内のテキストファイル内容を取得する(manifest 本文 / D7)。 + + 取得できなければ ``None``。生コードは呼び出し側で parse 後に破棄する想定(D6)。 + """ + if not _is_valid_owner_repo(owner, repo): + return None + try: + resp = await client.get( + f"/repos/{owner}/{repo}/contents/{path}", + headers={"Accept": "application/vnd.github.raw+json"}, + ) + if resp.status_code != 200: + return None + return resp.text + except httpx.HTTPError: + logger.warning("Failed to fetch %s for %s/%s", path, owner, repo) + return None diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py index c3dd50b3..22d49786 100644 --- a/backend/app/services/intelligence/github_collector.py +++ b/backend/app/services/intelligence/github_collector.py @@ -20,8 +20,12 @@ GITHUB_API, GitHubUserNotFoundError, fetch_languages, + fetch_repo_file, fetch_repos_raw, + fetch_root_filenames, ) +from .skills.manifests import MANIFEST_FILENAMES, parse_manifest +from .skills.types import PackageDeclaration logger = logging.getLogger(__name__) @@ -48,6 +52,26 @@ class RepoData: fork: bool stargazers_count: int default_branch: str = field(default="main") + # declare ステージ: 直下 manifest が宣言する依存(D7)。未取得なら空。 + package_declarations: List[PackageDeclaration] = field(default_factory=list) + + +async def _collect_manifests( + client: httpx.AsyncClient, owner: str, repo: str +) -> List[PackageDeclaration]: + """リポジトリ直下の manifest を取得・解析して依存宣言を返す(declare / D7)。 + + 直下のファイル一覧と既知 manifest 名の積集合だけを取得する。取得・解析失敗は + ベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。 + """ + filenames = await fetch_root_filenames(client, owner, repo) + targets = [name for name in filenames if name in MANIFEST_FILENAMES] + declarations: List[PackageDeclaration] = [] + for filename in targets: + content = await fetch_repo_file(client, owner, repo, filename) + if content: + declarations.extend(parse_manifest(filename, content)) + return declarations def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool: @@ -70,6 +94,7 @@ async def collect_repos( include_forks: bool = False, max_pages: int = 5, on_repo_fetched: Optional[Callable[[int, int], Awaitable[None]]] = None, + collect_manifests: bool = False, ) -> List[RepoData]: """ GitHub ユーザーのすべてのパブリックリポジトリを取得する。 @@ -77,6 +102,7 @@ async def collect_repos( 言語の内訳を含む RepoData のリストを返す。 on_repo_fetched が渡された場合、各リポジトリの詳細取得後に on_repo_fetched(done, total) を呼び出す(進捗通知用)。 + collect_manifests=True のとき、直下 manifest を解析して package_declarations を埋める(declare / D7)。 """ headers = { "Accept": "application/vnd.github+json", @@ -109,6 +135,12 @@ async def collect_repos( languages = await fetch_languages(client, owner_login, repo_name) + declarations: List[PackageDeclaration] = [] + if collect_manifests: + declarations = await _collect_manifests( + client, owner_login, repo_name + ) + repos.append( RepoData( name=repo_name, @@ -121,6 +153,7 @@ async def collect_repos( fork=raw.get("fork", False), stargazers_count=raw.get("stargazers_count", 0), default_branch=raw.get("default_branch", "main"), + package_declarations=declarations, ) ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index 52e0992c..ef3219cf 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -16,6 +16,7 @@ from ...core.logging_utils import get_logger from ...core.messages import get_error from ...models import GitHubLinkCache +from ...repositories.skill import GitHubSkillRepository from ..progress_service import set_progress from ..tasks.exceptions import NonRetryableError from ..tasks.handlers.base import SessionFactory @@ -23,6 +24,7 @@ from .github_collector import GitHubUserNotFoundError, collect_repos from .pipeline import aggregate_intelligence from .response_mapper import map_pipeline_result +from .skills import RepoSkillInput, aggregate_skills logger = get_logger(__name__) @@ -95,6 +97,7 @@ async def _on_repo_fetched(done: int, total: int) -> None: token=token, include_forks=payload.get("include_forks", False), on_repo_fetched=_on_repo_fetched, + collect_manifests=True, ) except GitHubUserNotFoundError as exc: with session_factory() as db: @@ -122,6 +125,19 @@ async def _on_repo_fetched(done: int, total: int) -> None: await set_progress(task_id, 3, _TOTAL_STEPS, "スキル集計中...") result = aggregate_intelligence(payload["github_username"], repos) + # 3 層スキル(ADR-0016 / discover + declare)の中間表現を組み立てる(I/O 無し)。 + detected_skills = aggregate_skills( + [ + RepoSkillInput( + full_name=f"{repo.owner}/{repo.name}", + url=f"https://github.com/{repo.owner}/{repo.name}", + languages=repo.languages, + package_declarations=repo.package_declarations, + ) + for repo in repos + ] + ) + response = map_pipeline_result(result) response.contribution_calendars = calendars result_dict = response.model_dump() @@ -137,6 +153,11 @@ async def _on_repo_fetched(done: int, total: int) -> None: extra={"user_id": user_id}, ) return + # 先に 3 層スキルを洗い替えで永続化する(ADR-0016)。 + # ここで失敗した場合は status を completed にしないことで、 + # 「completed なのにスキルが無い」状態を避ける(retry で再永続化される)。 + GitHubSkillRepository(db, user_id).replace_for_user(detected_skills) + cache.result = result_dict cache.status = "completed" cache.error_message = None diff --git a/backend/app/services/intelligence/skills/__init__.py b/backend/app/services/intelligence/skills/__init__.py new file mode 100644 index 00000000..3b89c44b --- /dev/null +++ b/backend/app/services/intelligence/skills/__init__.py @@ -0,0 +1,21 @@ +"""GitHub 連携スキル推論基盤(ADR-0016)。 + +discover(言語 / Linguist)+ declare(manifest 宣言)を合流し、3 層モデルへ投入する +中間表現を組み立てる。verify(import 解析)は後続フェーズ。 +""" + +from .aggregator import ( + DetectedSkill, + EvidenceRecord, + RepoSkillInput, + aggregate_skills, +) +from .types import PackageDeclaration + +__all__ = [ + "DetectedSkill", + "EvidenceRecord", + "PackageDeclaration", + "RepoSkillInput", + "aggregate_skills", +] diff --git a/backend/app/services/intelligence/skills/aggregator.py b/backend/app/services/intelligence/skills/aggregator.py new file mode 100644 index 00000000..c5143b6c --- /dev/null +++ b/backend/app/services/intelligence/skills/aggregator.py @@ -0,0 +1,190 @@ +"""スキル集計(discover + declare の合流 / ADR-0016 D1・D5)。 + +リポジトリ集合(言語バイト数 + manifest 宣言)から Layer 1(正規化スキル)と +Layer 2(技術×リポの根拠)の中間表現を組み立てる純粋関数。I/O は行わない。 + +「保持は細かく」(D8)に従い、検出された根拠は全リポ分そのまま保持し、 +足切り・粒度畳みは後段のビュー変換に委ねる(ここでは非可逆な切り捨てをしない)。 +""" + +import re +from dataclasses import dataclass, field + +from .linguist import resolve_language +from .types import ( + SKILL_KIND_LANGUAGE, + SKILL_KIND_PACKAGE, + PackageDeclaration, +) + +# package の dependency_kind ごとの信頼度(manifest 宣言のみ。実使用は verify で昇格)。 +_DEPENDENCY_CONFIDENCE = { + "direct": 0.6, + "peer": 0.4, + "build": 0.3, + "dev": 0.3, + "indirect": 0.1, +} +_SIGNAL_LANGUAGE_BYTES = "language_bytes" +_SIGNAL_MANIFEST_DECLARED = "manifest_declared" + +# PEP 503 正規化用(連続する -_. を - に畳む)。 +_PYPI_NAME_RE = re.compile(r"[-_.]+") + + +def _canonical_package_name(ecosystem: str, name: str) -> str: + """エコシステム内で一意な canonical 名へ正規化する。 + + pypi のみ PEP 503 正規化(小文字化・区切り統一)し、``Flask``/``flask`` や + ``ruamel.yaml``/``ruamel-yaml`` を同一視する。他エコシステムは package ID を + そのまま canonical とする(D3)。 + """ + if ecosystem == "pypi": + return _PYPI_NAME_RE.sub("-", name).lower() + return name + + +@dataclass(frozen=True) +class EvidenceRecord: + """Layer 2 の 1 根拠。""" + + repo_full_name: str + repo_url: str + signal_source: str + confidence: float + language_bytes: int | None = None + dependency_kind: str | None = None + + +@dataclass +class DetectedSkill: + """Layer 1 のスキルと、それに紐づく Layer 2 根拠の束。""" + + kind: str + canonical_name: str + ecosystem: str + parent: str | None + display_name: str | None + evidence: list[EvidenceRecord] = field(default_factory=list) + + +@dataclass +class RepoSkillInput: + """1 リポジトリ分の集計入力(aggregator が依存する最小形)。""" + + full_name: str # owner/name + url: str + languages: dict[str, int] + package_declarations: list[PackageDeclaration] = field(default_factory=list) + + +def aggregate_skills(repos: list[RepoSkillInput]) -> list[DetectedSkill]: + """リポジトリ集合から ``DetectedSkill`` 列を組み立てる。 + + 同一スキル(kind + ecosystem + canonical)は 1 件に畳み、根拠(evidence)を集約する。 + """ + # キー = (kind, ecosystem, canonical_name) + skills: dict[tuple[str, str, str], DetectedSkill] = {} + + for repo in repos: + _collect_languages(skills, repo) + _collect_packages(skills, repo) + + return list(skills.values()) + + +def _upsert( + skills: dict[tuple[str, str, str], DetectedSkill], + *, + kind: str, + canonical_name: str, + ecosystem: str, + parent: str | None, + display_name: str | None, +) -> DetectedSkill: + key = (kind, ecosystem, canonical_name) + skill = skills.get(key) + if skill is None: + skill = DetectedSkill( + kind=kind, + canonical_name=canonical_name, + ecosystem=ecosystem, + parent=parent, + display_name=display_name, + ) + skills[key] = skill + return skill + + +def _collect_languages( + skills: dict[tuple[str, str, str], DetectedSkill], repo: RepoSkillInput +) -> None: + total_bytes = sum(repo.languages.values()) + if total_bytes <= 0: + return + for lang, byte_count in repo.languages.items(): + resolved = resolve_language(lang) + if resolved is None: + continue + display = ( + resolved.display if resolved.display != resolved.canonical else None + ) + skill = _upsert( + skills, + kind=SKILL_KIND_LANGUAGE, + canonical_name=resolved.canonical, + ecosystem="", + parent=resolved.parent, + display_name=display, + ) + skill.evidence.append( + EvidenceRecord( + repo_full_name=repo.full_name, + repo_url=repo.url, + signal_source=_SIGNAL_LANGUAGE_BYTES, + confidence=round(byte_count / total_bytes, 4), + language_bytes=byte_count, + ) + ) + + +def _collect_packages( + skills: dict[tuple[str, str, str], DetectedSkill], repo: RepoSkillInput +) -> None: + # 同一リポ内で同じ package が複数 kind で宣言された場合は最も強い根拠を採用する + # (例: dependencies と devDependencies の両方に出現)。 + best: dict[tuple[str, str], PackageDeclaration] = {} + for decl in repo.package_declarations: + if not decl.name: + continue + # canonical 名でキーを作る(pypi は PEP 503 正規化で大小文字・区切り差を畳む)。 + name = _canonical_package_name(decl.ecosystem, decl.name) + key = (decl.ecosystem, name) + current = best.get(key) + if current is None or _confidence(decl.dependency_kind) > _confidence( + current.dependency_kind + ): + best[key] = decl + + for (ecosystem, name), decl in best.items(): + skill = _upsert( + skills, + kind=SKILL_KIND_PACKAGE, + canonical_name=name, + ecosystem=ecosystem, + parent=None, + display_name=None, + ) + skill.evidence.append( + EvidenceRecord( + repo_full_name=repo.full_name, + repo_url=repo.url, + signal_source=_SIGNAL_MANIFEST_DECLARED, + confidence=_confidence(decl.dependency_kind), + dependency_kind=decl.dependency_kind, + ) + ) + + +def _confidence(dependency_kind: str | None) -> float: + return _DEPENDENCY_CONFIDENCE.get(dependency_kind or "", 0.2) diff --git a/backend/app/services/intelligence/skills/linguist.py b/backend/app/services/intelligence/skills/linguist.py new file mode 100644 index 00000000..6014f472 --- /dev/null +++ b/backend/app/services/intelligence/skills/linguist.py @@ -0,0 +1,93 @@ +"""GitHub Linguist 由来の言語正規化(ADR-0016 discover / D3・D4)。 + +GitHub の ``/languages`` API が返す言語名は既に Linguist の正規名なので、本モジュールは +**外部を叩かず**内部マスタ(``resources/linguist_master.json``)へ resolve するだけに徹する。 + +責務: + - エイリアス → 正規名の名寄せ + - ``group`` → parent への写像 + - data/prose 型言語の既定除外(``keep_data`` で補正)と明示除外(``exclude``) + - 表示名の補正(例: ``HCL`` → ``Terraform`` / ``Dockerfile`` → ``Docker``) + +マスタは languages.yml の定期バッチ取り込みで再生成する想定の暫定キュレーション。 +マスタ未収録の言語は「programming とみなして採用」にフォールバックし、新言語の取りこぼしを防ぐ +(ノイズは ``exclude`` 側で制御する)。 +""" + +import json +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +_MASTER_PATH = Path(__file__).parent / "resources" / "linguist_master.json" + +# 既定で除外する Linguist の type(経歴書のスキルとして不適なカテゴリ)。 +_EXCLUDED_TYPES = frozenset({"data", "prose"}) + + +@dataclass(frozen=True) +class ResolvedLanguage: + """マスタへ resolve 済みの言語。""" + + canonical: str # Linguist の正規名(例: "TypeScript") + display: str # 表示名の補正後(例: HCL → "Terraform") + parent: str | None # Linguist の group(例: なし) + + +@dataclass(frozen=True) +class _Master: + languages: dict[str, dict] + exclude: frozenset[str] + keep_data: frozenset[str] + alias_index: dict[str, str] # lower(alias|name) → canonical + + +@lru_cache(maxsize=1) +def _load_master() -> _Master: + raw = json.loads(_MASTER_PATH.read_text(encoding="utf-8")) + languages: dict[str, dict] = raw.get("languages", {}) + alias_index: dict[str, str] = {} + for canonical, entry in languages.items(): + alias_index[canonical.lower()] = canonical + for alias in entry.get("aliases", []): + alias_index.setdefault(alias.lower(), canonical) + return _Master( + languages=languages, + exclude=frozenset(raw.get("exclude", [])), + keep_data=frozenset(raw.get("keep_data", [])), + alias_index=alias_index, + ) + + +def resolve_language(name: str) -> ResolvedLanguage | None: + """言語名を内部マスタへ resolve する。除外対象なら ``None`` を返す。 + + Args: + name: GitHub ``/languages`` が返す言語名(Linguist 正規名)またはエイリアス。 + + Returns: + 採用する言語は ``ResolvedLanguage``、除外する言語は ``None``。 + """ + if not name: + return None + master = _load_master() + canonical = master.alias_index.get(name.lower(), name) + + # 明示除外(HTML/CSS 等のノイズ)はマスタ収録の有無に関わらず弾く。 + if canonical in master.exclude: + return None + + entry = master.languages.get(canonical) + if entry is None: + # マスタ未収録は programming とみなして採用(取りこぼし防止)。 + return ResolvedLanguage(canonical=canonical, display=canonical, parent=None) + + # data/prose 型は既定除外。keep_data に挙げたものだけ補正で残す。 + if entry.get("type") in _EXCLUDED_TYPES and canonical not in master.keep_data: + return None + + return ResolvedLanguage( + canonical=canonical, + display=entry.get("display") or canonical, + parent=entry.get("group"), + ) diff --git a/backend/app/services/intelligence/skills/manifests/__init__.py b/backend/app/services/intelligence/skills/manifests/__init__.py new file mode 100644 index 00000000..fbdf757f --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/__init__.py @@ -0,0 +1,6 @@ +"""manifest パーサ群(declare ステージ / ADR-0016 D7)。""" + +from .base import ManifestParser +from .registry import MANIFEST_FILENAMES, parse_manifest + +__all__ = ["MANIFEST_FILENAMES", "ManifestParser", "parse_manifest"] diff --git a/backend/app/services/intelligence/skills/manifests/_pep508.py b/backend/app/services/intelligence/skills/manifests/_pep508.py new file mode 100644 index 00000000..6338430c --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/_pep508.py @@ -0,0 +1,25 @@ +"""PEP 508 依存指定から package 名だけを取り出すヘルパ(pypi 系パーサ共用)。""" + +import re + +# 先頭の package 名(PEP 508)。extras / バージョン / 環境マーカーは名前の後に続く。 +_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") +# URL / VCS 直指定(https://... / git+https://... 等)。先頭の "https" 等を +# package 名と誤認しないよう、名前抽出前に弾く。 +_DIRECT_REF_PREFIX_RE = re.compile(r"^\s*(?:https?://|git\+|ssh://|file:)", re.IGNORECASE) + + +def extract_package_name(spec: str) -> str | None: + """``"requests[security] >=2,<3 ; python_version>'3.8'"`` → ``"requests"``。 + + 名前を取り出せない(URL 直指定・空行など)場合は ``None``。 + """ + if not spec: + return None + # URL / VCS 直指定は名前を持たないため None("https" 等の誤抽出を防ぐ)。 + if _DIRECT_REF_PREFIX_RE.match(spec): + return None + match = _NAME_RE.match(spec) + if not match: + return None + return match.group(1) diff --git a/backend/app/services/intelligence/skills/manifests/base.py b/backend/app/services/intelligence/skills/manifests/base.py new file mode 100644 index 00000000..c4f255fc --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/base.py @@ -0,0 +1,27 @@ +"""manifest パーサのプラグイン基底(ADR-0016 D7)。 + +各エコシステムのパーサは ``ManifestParser`` を実装し、``filenames``(対応する manifest の +ファイル名)と ``parse``(ファイル内容 → ``PackageDeclaration`` 列)だけを提供する。 +新しいエコシステムは ``registry.py`` に 1 行足すだけで差し込める。 + +パーサは I/O を行わない純粋関数として実装する(ファイル取得は呼び出し側の責務)。 +壊れた manifest は例外を投げず空リストを返す(1 リポの解析失敗で連携全体を落とさない)。 +""" + +from typing import Protocol, runtime_checkable + +from ..types import PackageDeclaration + + +@runtime_checkable +class ManifestParser(Protocol): + """manifest パーサのインターフェース。""" + + # このパーサが対応する manifest のファイル名(リポジトリ直下からの相対)。 + filenames: tuple[str, ...] + # エコシステム識別子(npm / pypi / go / cargo)。 + ecosystem: str + + def parse(self, content: str) -> list[PackageDeclaration]: + """manifest の内容を ``PackageDeclaration`` 列へ変換する。""" + ... diff --git a/backend/app/services/intelligence/skills/manifests/cargo_toml.py b/backend/app/services/intelligence/skills/manifests/cargo_toml.py new file mode 100644 index 00000000..da88be2c --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/cargo_toml.py @@ -0,0 +1,55 @@ +"""Cargo.toml パーサ(ecosystem=cargo / D7)。 + +[dependencies] → direct / [dev-dependencies] → dev / [build-dependencies] → build。 +依存値は文字列(バージョン)または table(version/git/path 等)の両方を許容する。 +""" + +import tomllib + +from ..types import PackageDeclaration + +_SECTION_KINDS = { + "dependencies": "direct", + "dev-dependencies": "dev", + "build-dependencies": "build", +} + + +class CargoTomlParser: + filenames = ("Cargo.toml",) + ecosystem = "cargo" + + def parse(self, content: str) -> list[PackageDeclaration]: + try: + data = tomllib.loads(content) + except (tomllib.TOMLDecodeError, ValueError): + return [] + + declarations: list[PackageDeclaration] = [] + for section, kind in _SECTION_KINDS.items(): + self._collect(declarations, data.get(section), kind) + # [target..dependencies] も拾う(プラットフォーム別依存)。 + target = data.get("target") + if isinstance(target, dict): + for cfg in target.values(): + if isinstance(cfg, dict): + self._collect(declarations, cfg.get("dependencies"), "direct") + return declarations + + def _collect(self, out: list[PackageDeclaration], section, kind: str) -> None: + if not isinstance(section, dict): + return + for name, spec in section.items(): + if not name: + continue + version = spec if isinstance(spec, str) else None + if version is None and isinstance(spec, dict): + version = spec.get("version") + out.append( + PackageDeclaration( + ecosystem=self.ecosystem, + name=name, + dependency_kind=kind, + version_spec=version if isinstance(version, str) else None, + ) + ) diff --git a/backend/app/services/intelligence/skills/manifests/go_mod.py b/backend/app/services/intelligence/skills/manifests/go_mod.py new file mode 100644 index 00000000..3fdf4fde --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/go_mod.py @@ -0,0 +1,55 @@ +"""go.mod パーサ(ecosystem=go / D7)。 + +``require`` の単行・ブロック両形式を読む。``// indirect`` 付きは推移的依存として +``dependency_kind="indirect"`` を立てる(実績スキルからは除外する素になる)。 +""" + +import re + +from ..types import PackageDeclaration + +# `require module/path v1.2.3` または ブロック内行 `module/path v1.2.3 // indirect` +_REQUIRE_LINE = re.compile( + r"^(?P[^\s()]+)\s+(?Pv[^\s]+)(?P\s+//.*)?$" +) + + +class GoModParser: + filenames = ("go.mod",) + ecosystem = "go" + + def parse(self, content: str) -> list[PackageDeclaration]: + declarations: list[PackageDeclaration] = [] + in_block = False + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith("//"): + continue + + if in_block: + if line.startswith(")"): + in_block = False + continue + self._append(declarations, line) + continue + + if line.startswith("require ("): + in_block = True + continue + if line.startswith("require "): + self._append(declarations, line[len("require "):].strip()) + return declarations + + def _append(self, out: list[PackageDeclaration], line: str) -> None: + match = _REQUIRE_LINE.match(line) + if not match: + return + is_indirect = match.group("comment") and "indirect" in match.group("comment") + out.append( + PackageDeclaration( + ecosystem=self.ecosystem, + name=match.group("path"), + dependency_kind="indirect" if is_indirect else "direct", + version_spec=match.group("version"), + ) + ) diff --git a/backend/app/services/intelligence/skills/manifests/package_json.py b/backend/app/services/intelligence/skills/manifests/package_json.py new file mode 100644 index 00000000..e8e3290c --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/package_json.py @@ -0,0 +1,49 @@ +"""package.json パーサ(ecosystem=npm / D7)。 + +宣言ブロックごとに dependency_kind を割り当てる: + dependencies → direct / devDependencies → dev / + peerDependencies → peer / optionalDependencies → direct +devDependencies は実績スキルに混ぜない素になる(D7)。 +""" + +import json + +from ..types import PackageDeclaration + +_BLOCK_KINDS = { + "dependencies": "direct", + "devDependencies": "dev", + "peerDependencies": "peer", + "optionalDependencies": "direct", +} + + +class PackageJsonParser: + filenames = ("package.json",) + ecosystem = "npm" + + def parse(self, content: str) -> list[PackageDeclaration]: + try: + data = json.loads(content) + except (json.JSONDecodeError, ValueError): + return [] + if not isinstance(data, dict): + return [] + + declarations: list[PackageDeclaration] = [] + for block, kind in _BLOCK_KINDS.items(): + deps = data.get(block) + if not isinstance(deps, dict): + continue + for name, version in deps.items(): + if not name: + continue + declarations.append( + PackageDeclaration( + ecosystem=self.ecosystem, + name=name, + dependency_kind=kind, + version_spec=version if isinstance(version, str) else None, + ) + ) + return declarations diff --git a/backend/app/services/intelligence/skills/manifests/pyproject.py b/backend/app/services/intelligence/skills/manifests/pyproject.py new file mode 100644 index 00000000..df17bc99 --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/pyproject.py @@ -0,0 +1,87 @@ +"""pyproject.toml パーサ(ecosystem=pypi / D7)。 + +PEP 621([project])と Poetry([tool.poetry])の双方を読む: + - [project].dependencies → direct + - [project.optional-dependencies]. → dev(dev/test/lint/docs/typing 系)/ それ以外は direct + - [build-system].requires → build + - [tool.poetry.dependencies] → direct(python 行は除外) + - [tool.poetry.group..dependencies] / [tool.poetry.dev-dependencies] → dev/direct(group 名で判定) +""" + +import tomllib + +from ..types import PackageDeclaration +from ._pep508 import extract_package_name + +# 開発系とみなす extras / poetry group 名(→ dependency_kind="dev")。 +_DEV_GROUP_NAMES = frozenset({"dev", "test", "tests", "lint", "docs", "typing", "ci"}) + + +class PyprojectParser: + filenames = ("pyproject.toml",) + ecosystem = "pypi" + + def parse(self, content: str) -> list[PackageDeclaration]: + try: + data = tomllib.loads(content) + except (tomllib.TOMLDecodeError, ValueError): + return [] + + declarations: list[PackageDeclaration] = [] + self._parse_pep621(declarations, data) + self._parse_build_system(declarations, data) + self._parse_poetry(declarations, data) + return declarations + + def _add(self, out: list[PackageDeclaration], raw_name: str, kind: str) -> None: + name = extract_package_name(raw_name) + if not name: + return + out.append( + PackageDeclaration( + ecosystem=self.ecosystem, name=name, dependency_kind=kind, version_spec=None + ) + ) + + def _parse_pep621(self, out: list[PackageDeclaration], data: dict) -> None: + project = data.get("project") + if not isinstance(project, dict): + return + for spec in project.get("dependencies") or []: + if isinstance(spec, str): + self._add(out, spec, "direct") + optional = project.get("optional-dependencies") + if isinstance(optional, dict): + for group, specs in optional.items(): + kind = "dev" if group.lower() in _DEV_GROUP_NAMES else "direct" + for spec in specs or []: + if isinstance(spec, str): + self._add(out, spec, kind) + + def _parse_build_system(self, out: list[PackageDeclaration], data: dict) -> None: + build_system = data.get("build-system") + if not isinstance(build_system, dict): + return + for spec in build_system.get("requires") or []: + if isinstance(spec, str): + self._add(out, spec, "build") + + def _parse_poetry(self, out: list[PackageDeclaration], data: dict) -> None: + poetry = (data.get("tool") or {}).get("poetry") + if not isinstance(poetry, dict): + return + for name in (poetry.get("dependencies") or {}): + if name.lower() != "python": + self._add(out, name, "direct") + # 旧形式 [tool.poetry.dev-dependencies] + for name in (poetry.get("dev-dependencies") or {}): + self._add(out, name, "dev") + # 新形式 [tool.poetry.group..dependencies] + groups = poetry.get("group") + if isinstance(groups, dict): + for group_name, group in groups.items(): + if not isinstance(group, dict): + continue + kind = "dev" if group_name.lower() in _DEV_GROUP_NAMES else "direct" + for name in (group.get("dependencies") or {}): + self._add(out, name, kind) diff --git a/backend/app/services/intelligence/skills/manifests/registry.py b/backend/app/services/intelligence/skills/manifests/registry.py new file mode 100644 index 00000000..34d1cb90 --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/registry.py @@ -0,0 +1,41 @@ +"""manifest パーサのレジストリ(ADR-0016 D7)。 + +Tier1(v1 必須)のパーサだけを登録する。Tier2(Java-Kotlin / Ruby / PHP)は +``_PARSERS`` に 1 行足すだけで差し込める(plugin 型)。 +""" + +from ..types import PackageDeclaration +from .base import ManifestParser +from .cargo_toml import CargoTomlParser +from .go_mod import GoModParser +from .package_json import PackageJsonParser +from .pyproject import PyprojectParser +from .requirements_txt import RequirementsTxtParser + +# Tier1 のパーサインスタンス。 +_PARSERS: tuple[ManifestParser, ...] = ( + GoModParser(), + PyprojectParser(), + RequirementsTxtParser(), + PackageJsonParser(), + CargoTomlParser(), +) + +# ファイル名 → パーサ。取得すべき manifest ファイル名の判定にも使う。 +_BY_FILENAME: dict[str, ManifestParser] = { + filename: parser for parser in _PARSERS for filename in parser.filenames +} + +# リポジトリ直下で取得を試みる manifest ファイル名(D7: v1 は直下のみ)。 +MANIFEST_FILENAMES: frozenset[str] = frozenset(_BY_FILENAME) + + +def parse_manifest(filename: str, content: str) -> list[PackageDeclaration]: + """ファイル名に対応するパーサで manifest を解析する。 + + 未対応のファイル名なら空リストを返す。 + """ + parser = _BY_FILENAME.get(filename) + if parser is None: + return [] + return parser.parse(content) diff --git a/backend/app/services/intelligence/skills/manifests/requirements_txt.py b/backend/app/services/intelligence/skills/manifests/requirements_txt.py new file mode 100644 index 00000000..4a6f1c73 --- /dev/null +++ b/backend/app/services/intelligence/skills/manifests/requirements_txt.py @@ -0,0 +1,34 @@ +"""requirements.txt パーサ(ecosystem=pypi / D7)。 + +1 行 1 依存。コメント・オプション行(-r / -e / --flag)・URL 直指定はスキップする。 +requirements.txt は dev/direct を区別しないため全て direct 扱い(dev 変種の取り込みは後続)。 +""" + +from ..types import PackageDeclaration +from ._pep508 import extract_package_name + + +class RequirementsTxtParser: + filenames = ("requirements.txt",) + ecosystem = "pypi" + + def parse(self, content: str) -> list[PackageDeclaration]: + declarations: list[PackageDeclaration] = [] + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + # 環境マーカー(; python_version<...)の前で切る。 + spec = line.split(";", 1)[0].strip() + name = extract_package_name(spec) + if not name: + continue + declarations.append( + PackageDeclaration( + ecosystem=self.ecosystem, + name=name, + dependency_kind="direct", + version_spec=None, + ) + ) + return declarations diff --git a/backend/app/services/intelligence/skills/resources/linguist_master.json b/backend/app/services/intelligence/skills/resources/linguist_master.json new file mode 100644 index 00000000..a9f3cc73 --- /dev/null +++ b/backend/app/services/intelligence/skills/resources/linguist_master.json @@ -0,0 +1,82 @@ +{ + "_comment": "GitHub Linguist languages.yml から派生した内部マスタ(ADR-0016 D3/D4)。連携ホットパスで外部を叩かず、ここへ resolve する。type が data/prose/markup の言語は既定で除外し、keep_data に挙げたものだけ補正で残す。exclude は明示除外(ノイズ)。display は表示名の正規化。本ファイルは languages.yml の定期バッチ取り込みで再生成する想定の暫定キュレーション。", + "exclude": [ + "HTML", + "CSS", + "SCSS", + "Sass", + "Less", + "Markdown", + "TeX", + "Roff", + "Batchfile", + "Makefile", + "CMake", + "Gnuplot", + "Vim Script", + "Rich Text Format", + "CSV", + "INI", + "TOML", + "XML", + "SVG", + "YAML", + "JSON", + "JSON5", + "JSONiq", + "Gettext Catalog" + ], + "keep_data": [ + "SQL", + "PLpgSQL", + "TSQL", + "GraphQL", + "HCL", + "Dockerfile" + ], + "languages": { + "Python": { "type": "programming", "group": null, "aliases": ["python3", "rusthon"] }, + "JavaScript": { "type": "programming", "group": null, "aliases": ["js", "node"] }, + "TypeScript": { "type": "programming", "group": null, "aliases": ["ts"] }, + "Go": { "type": "programming", "group": null, "aliases": ["golang"] }, + "Rust": { "type": "programming", "group": null, "aliases": [] }, + "Java": { "type": "programming", "group": null, "aliases": [] }, + "Kotlin": { "type": "programming", "group": null, "aliases": [] }, + "C": { "type": "programming", "group": null, "aliases": [] }, + "C++": { "type": "programming", "group": null, "aliases": ["cpp"] }, + "C#": { "type": "programming", "group": null, "aliases": ["csharp", "cake"] }, + "Ruby": { "type": "programming", "group": null, "aliases": ["jruby", "rbx"] }, + "PHP": { "type": "programming", "group": null, "aliases": ["inc"] }, + "Swift": { "type": "programming", "group": null, "aliases": [] }, + "Objective-C": { "type": "programming", "group": null, "aliases": ["obj-c", "objc"] }, + "Dart": { "type": "programming", "group": null, "aliases": [] }, + "Scala": { "type": "programming", "group": null, "aliases": [] }, + "Elixir": { "type": "programming", "group": null, "aliases": [] }, + "Erlang": { "type": "programming", "group": null, "aliases": [] }, + "Haskell": { "type": "programming", "group": null, "aliases": [] }, + "Clojure": { "type": "programming", "group": null, "aliases": [] }, + "Lua": { "type": "programming", "group": null, "aliases": [] }, + "Perl": { "type": "programming", "group": null, "aliases": [] }, + "R": { "type": "programming", "group": null, "aliases": ["rscript", "splus"] }, + "Julia": { "type": "programming", "group": null, "aliases": [] }, + "Groovy": { "type": "programming", "group": null, "aliases": [] }, + "PowerShell": { "type": "programming", "group": null, "aliases": ["posh", "pwsh"] }, + "Shell": { "type": "programming", "group": null, "aliases": ["sh", "bash", "zsh"], "display": "Shell" }, + "F#": { "type": "programming", "group": null, "aliases": ["fsharp"] }, + "OCaml": { "type": "programming", "group": null, "aliases": [] }, + "Solidity": { "type": "programming", "group": null, "aliases": [] }, + "Zig": { "type": "programming", "group": null, "aliases": [] }, + "Nim": { "type": "programming", "group": null, "aliases": [] }, + "Crystal": { "type": "programming", "group": null, "aliases": [] }, + "Haxe": { "type": "programming", "group": null, "aliases": [] }, + "Vue": { "type": "markup", "group": null, "aliases": [] }, + "Svelte": { "type": "markup", "group": null, "aliases": [] }, + "Jupyter Notebook": { "type": "markup", "group": null, "aliases": ["ipynb"], "display": "Jupyter" }, + "HCL": { "type": "data", "group": null, "aliases": ["terraform"], "display": "Terraform" }, + "Dockerfile": { "type": "data", "group": null, "aliases": ["containerfile"], "display": "Docker" }, + "SQL": { "type": "data", "group": null, "aliases": [] }, + "PLpgSQL": { "type": "data", "group": null, "aliases": [], "display": "PL/pgSQL" }, + "TSQL": { "type": "data", "group": null, "aliases": [], "display": "T-SQL" }, + "GraphQL": { "type": "data", "group": null, "aliases": [] } + } +} diff --git a/backend/app/services/intelligence/skills/types.py b/backend/app/services/intelligence/skills/types.py new file mode 100644 index 00000000..46cf0dcd --- /dev/null +++ b/backend/app/services/intelligence/skills/types.py @@ -0,0 +1,32 @@ +"""スキル推論基盤の値オブジェクト(ADR-0016)。 + +機械が埋める Layer 1-2 の中間表現。永続化(models/skill.py)や API スキーマとは独立した、 +パイプライン内部のドメイン型。 +""" + +from dataclasses import dataclass + +# 依存の種類(D7)。manifest 由来の宣言区分をそのまま保持する。 +# direct : 本番直接依存 +# dev : 開発依存(package.json devDependencies 等)。実績スキルには混ぜない +# indirect: 推移的依存(go.mod の `// indirect` 等)。除外対象 +# peer : peerDependencies +# build : build-system 依存(pyproject build-system.requires 等) +DEPENDENCY_KINDS = ("direct", "dev", "indirect", "peer", "build") + +# Layer 1 スキルの種別(D2)。 +SKILL_KIND_LANGUAGE = "language" +SKILL_KIND_PACKAGE = "package" + + +@dataclass(frozen=True) +class PackageDeclaration: + """manifest が宣言する 1 依存(declare ステージの出力 / D7)。 + + package ID はエコシステム内で一意 = canonical なので、辞書による正規化は行わない(D3)。 + """ + + ecosystem: str # npm / pypi / go / cargo + name: str # エコシステム内で一意な package ID + dependency_kind: str # DEPENDENCY_KINDS のいずれか + version_spec: str | None = None # バージョン制約(生文字列。解釈はしない) diff --git a/backend/tests/test_github_skills_api.py b/backend/tests/test_github_skills_api.py new file mode 100644 index 00000000..be7ac824 --- /dev/null +++ b/backend/tests/test_github_skills_api.py @@ -0,0 +1,120 @@ +"""GitHub 連携スキル(3 層)の永続化と取得エンドポイントのテスト(ADR-0016)。""" + +from app.models import GitHubSkillEvidence +from app.repositories import UserRepository +from app.repositories.skill import GitHubSkillRepository +from app.services.intelligence.skills import DetectedSkill, EvidenceRecord + +from conftest import auth_header + + +def _user_id(client, username: str) -> str: + return UserRepository(client._db_session).get_by_username(username).id + + +def _sample_detected() -> list[DetectedSkill]: + return [ + DetectedSkill( + kind="language", + canonical_name="Python", + ecosystem="", + parent=None, + display_name=None, + evidence=[ + EvidenceRecord( + repo_full_name="u/a", + repo_url="https://github.com/u/a", + signal_source="language_bytes", + confidence=0.8, + language_bytes=8000, + ) + ], + ), + DetectedSkill( + kind="package", + canonical_name="react", + ecosystem="npm", + parent=None, + display_name=None, + evidence=[ + EvidenceRecord( + repo_full_name="u/a", + repo_url="https://github.com/u/a", + signal_source="manifest_declared", + confidence=0.6, + dependency_kind="direct", + ) + ], + ), + ] + + +def test_skills_requires_auth(client) -> None: + """未認証では 401 になること。""" + resp = client.get("/api/github-link/skills") + assert resp.status_code == 401 + + +def test_skills_empty_when_not_linked(client) -> None: + """連携前は空配列を返すこと。""" + headers = auth_header(client, "skilluser_empty") + resp = client.get("/api/github-link/skills", headers=headers) + assert resp.status_code == 200 + assert resp.json() == {"skills": []} + + +def test_replace_then_get_returns_layers(client) -> None: + """永続化したスキルが evidence 付きで取得でき、言語の ecosystem は null になること。""" + headers = auth_header(client, "skilluser_full") + uid = _user_id(client, "skilluser_full") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_sample_detected()) + + resp = client.get("/api/github-link/skills", headers=headers) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + + python = by_name["Python"] + assert python["kind"] == "language" + assert python["ecosystem"] is None # 言語は "" → null へ正規化 + assert python["evidence"][0]["language_bytes"] == 8000 + assert python["evidence"][0]["signal_source"] == "language_bytes" + assert python["proficiency"] is None # Layer 3 は本フェーズ未投入 + + react = by_name["react"] + assert react["ecosystem"] == "npm" + assert react["evidence"][0]["dependency_kind"] == "direct" + + +def test_replace_is_idempotent(client) -> None: + """洗い替えで前回分が消えること(再連携で古いスキルが残らない)。""" + headers = auth_header(client, "skilluser_idem") + uid = _user_id(client, "skilluser_idem") + repo = GitHubSkillRepository(client._db_session, uid) + repo.replace_for_user(_sample_detected()) + repo.replace_for_user([_sample_detected()[0]]) # 2 回目は Python のみ + + resp = client.get("/api/github-link/skills", headers=headers) + names = {s["canonical_name"] for s in resp.json()["skills"]} + assert names == {"Python"} + + # 削除された react の evidence が孤児として残っていないこと(FK 非依存の ORM 削除)。 + # SQLite テストエンジンは PRAGMA foreign_keys=ON でないため、DB の CASCADE では消えない。 + remaining_evidence = client._db_session.query(GitHubSkillEvidence).count() + assert remaining_evidence == 1 # Python の 1 件のみ + + +def test_skills_are_scoped_per_user(client) -> None: + """他ユーザーのスキルが混ざらないこと。""" + auth_header(client, "skilluser_a") + uid_a = _user_id(client, "skilluser_a") + GitHubSkillRepository(client._db_session, uid_a).replace_for_user(_sample_detected()) + + # auth_header は client の Cookie を差し替えるため、最後に認証したユーザーで動く。 + # user_b で認証 → 空、user_a へ再認証 → 2 件、で分離を検証する。 + headers_b = auth_header(client, "skilluser_b") + resp_b = client.get("/api/github-link/skills", headers=headers_b) + assert resp_b.json() == {"skills": []} + + headers_a = auth_header(client, "skilluser_a") + resp_a = client.get("/api/github-link/skills", headers=headers_a) + assert len(resp_a.json()["skills"]) == 2 diff --git a/backend/tests/test_skill_aggregator.py b/backend/tests/test_skill_aggregator.py new file mode 100644 index 00000000..144f550a --- /dev/null +++ b/backend/tests/test_skill_aggregator.py @@ -0,0 +1,122 @@ +"""スキル集計のテスト(ADR-0016 D1・D8)。""" + +from app.services.intelligence.skills import ( + PackageDeclaration, + RepoSkillInput, + aggregate_skills, +) +from app.services.intelligence.skills.types import ( + SKILL_KIND_LANGUAGE, + SKILL_KIND_PACKAGE, +) + + +def _repo(full_name="testuser/repo", languages=None, declarations=None) -> RepoSkillInput: + return RepoSkillInput( + full_name=full_name, + url=f"https://github.com/{full_name}", + languages=languages or {}, + package_declarations=declarations or [], + ) + + +def _by_name(skills) -> dict: + return {s.canonical_name: s for s in skills} + + +def test_language_confidence_is_byte_share() -> None: + """言語の confidence はリポ内バイト比率になり、除外言語は分母には残る。""" + skills = aggregate_skills( + [_repo(languages={"Python": 8000, "HTML": 2000})] + ) + by_name = _by_name(skills) + assert "HTML" not in by_name # 除外 + python = by_name["Python"] + assert python.kind == SKILL_KIND_LANGUAGE + assert python.ecosystem == "" + assert len(python.evidence) == 1 + assert python.evidence[0].confidence == 0.8 + assert python.evidence[0].language_bytes == 8000 + assert python.evidence[0].signal_source == "language_bytes" + + +def test_package_kinds_get_confidence() -> None: + """direct と dev で confidence が変わること。""" + skills = aggregate_skills( + [ + _repo( + declarations=[ + PackageDeclaration("npm", "react", "direct"), + PackageDeclaration("npm", "jest", "dev"), + ] + ) + ] + ) + by_name = _by_name(skills) + assert by_name["react"].kind == SKILL_KIND_PACKAGE + assert by_name["react"].ecosystem == "npm" + assert by_name["react"].evidence[0].confidence == 0.6 + assert by_name["react"].evidence[0].dependency_kind == "direct" + assert by_name["jest"].evidence[0].confidence == 0.3 + + +def test_same_package_multiple_kinds_keeps_strongest() -> None: + """同一リポで direct と dev の両方に出たら direct を採用すること(D7)。""" + skills = aggregate_skills( + [ + _repo( + declarations=[ + PackageDeclaration("npm", "typescript", "dev"), + PackageDeclaration("npm", "typescript", "direct"), + ] + ) + ] + ) + ts = _by_name(skills)["typescript"] + assert len(ts.evidence) == 1 + assert ts.evidence[0].dependency_kind == "direct" + + +def test_skill_deduped_across_repos_with_multiple_evidence() -> None: + """複数リポに跨る同一スキルは 1 件に畳まれ、evidence が積み上がること(D8 保持)。""" + skills = aggregate_skills( + [ + _repo(full_name="u/a", languages={"Go": 1000}), + _repo(full_name="u/b", languages={"Go": 4000}), + ] + ) + go = _by_name(skills)["Go"] + assert len(go.evidence) == 2 + repos = {e.repo_full_name for e in go.evidence} + assert repos == {"u/a", "u/b"} + + +def test_pypi_names_are_pep503_normalized() -> None: + """pypi は大小文字・区切り差を畳んで同一スキルにすること(PEP 503)。""" + skills = aggregate_skills( + [ + _repo( + full_name="u/a", + declarations=[PackageDeclaration("pypi", "ruamel.yaml", "direct")], + ), + _repo( + full_name="u/b", + declarations=[PackageDeclaration("pypi", "ruamel-yaml", "direct")], + ), + ] + ) + by_name = _by_name(skills) + assert set(by_name) == {"ruamel-yaml"} + assert len(by_name["ruamel-yaml"].evidence) == 2 + + +def test_non_pypi_names_not_normalized() -> None: + """pypi 以外(go 等)は package ID をそのまま canonical にすること。""" + skills = aggregate_skills( + [_repo(declarations=[PackageDeclaration("go", "github.com/Foo/Bar", "direct")])] + ) + assert "github.com/Foo/Bar" in _by_name(skills) + + +def test_empty_repo_yields_no_skills() -> None: + assert aggregate_skills([_repo()]) == [] diff --git a/backend/tests/test_skill_linguist.py b/backend/tests/test_skill_linguist.py new file mode 100644 index 00000000..3dac7944 --- /dev/null +++ b/backend/tests/test_skill_linguist.py @@ -0,0 +1,56 @@ +"""Linguist 言語リゾルバのテスト(ADR-0016 discover / D3・D4)。""" + +from app.services.intelligence.skills.linguist import resolve_language + + +def test_canonical_language() -> None: + resolved = resolve_language("Python") + assert resolved is not None + assert resolved.canonical == "Python" + assert resolved.display == "Python" + + +def test_alias_is_normalized() -> None: + """エイリアスが正規名へ名寄せされること。""" + resolved = resolve_language("golang") + assert resolved is not None + assert resolved.canonical == "Go" + + +def test_display_correction_for_data_language() -> None: + """keep_data の data 言語は表示名補正付きで残ること(HCL → Terraform)。""" + hcl = resolve_language("HCL") + assert hcl is not None + assert hcl.canonical == "HCL" + assert hcl.display == "Terraform" + + docker = resolve_language("Dockerfile") + assert docker is not None + assert docker.display == "Docker" + + +def test_kept_data_language_sql() -> None: + """keep_data に挙げた SQL は除外されないこと。""" + assert resolve_language("SQL") is not None + + +def test_excluded_markup_is_dropped() -> None: + """明示除外(HTML/CSS/YAML)は None を返すこと。""" + assert resolve_language("HTML") is None + assert resolve_language("CSS") is None + assert resolve_language("YAML") is None + + +def test_unknown_language_falls_back_to_included() -> None: + """マスタ未収録かつ非除外の言語は programming とみなして採用されること。""" + # 将来 master に収録され得る実在名ではなく、確実に未収録の合成トークンを使う。 + unknown = "__CR_UNLISTED_LANGUAGE__" + resolved = resolve_language(unknown) + assert resolved is not None + assert resolved.canonical == unknown + assert resolved.display == unknown + assert resolved.parent is None + + +def test_empty_name_returns_none() -> None: + assert resolve_language("") is None diff --git a/backend/tests/test_skill_parsers.py b/backend/tests/test_skill_parsers.py new file mode 100644 index 00000000..29b8cb09 --- /dev/null +++ b/backend/tests/test_skill_parsers.py @@ -0,0 +1,152 @@ +"""manifest パーサ群のテスト(ADR-0016 declare / D7)。""" + +from app.services.intelligence.skills.manifests import parse_manifest +from app.services.intelligence.skills.manifests.cargo_toml import CargoTomlParser +from app.services.intelligence.skills.manifests.go_mod import GoModParser +from app.services.intelligence.skills.manifests.package_json import PackageJsonParser +from app.services.intelligence.skills.manifests.pyproject import PyprojectParser +from app.services.intelligence.skills.manifests.requirements_txt import ( + RequirementsTxtParser, +) + + +def _by_name(declarations) -> dict: + return {d.name: d for d in declarations} + + +def test_go_mod_block_marks_indirect() -> None: + """go.mod の `// indirect` は indirect、それ以外は direct になること。""" + content = ( + "module example.com/app\n\n" + "go 1.21\n\n" + "require (\n" + " github.com/gin-gonic/gin v1.9.1\n" + " github.com/bytedance/sonic v1.10.0 // indirect\n" + ")\n" + ) + result = _by_name(GoModParser().parse(content)) + assert result["github.com/gin-gonic/gin"].dependency_kind == "direct" + assert result["github.com/gin-gonic/gin"].version_spec == "v1.9.1" + assert result["github.com/bytedance/sonic"].dependency_kind == "indirect" + assert all(d.ecosystem == "go" for d in result.values()) + + +def test_go_mod_single_line_require() -> None: + """単行 require も拾えること。""" + result = _by_name(GoModParser().parse("require github.com/foo/bar v1.0.0\n")) + assert result["github.com/foo/bar"].dependency_kind == "direct" + + +def test_package_json_dependency_kinds() -> None: + """dependencies/devDependencies/peerDependencies の kind が分類されること。""" + content = """ + { + "dependencies": {"react": "^18.0.0"}, + "devDependencies": {"jest": "^29.0.0"}, + "peerDependencies": {"react-dom": "^18.0.0"} + } + """ + result = _by_name(PackageJsonParser().parse(content)) + assert result["react"].dependency_kind == "direct" + assert result["react"].ecosystem == "npm" + assert result["jest"].dependency_kind == "dev" + assert result["react-dom"].dependency_kind == "peer" + + +def test_package_json_broken_returns_empty() -> None: + """壊れた JSON は例外を投げず空リストを返すこと。""" + assert PackageJsonParser().parse("{not json") == [] + + +def test_pyproject_pep621_and_build_system() -> None: + """PEP 621 の dependencies/optional/build-system を分類すること。""" + content = """ +[build-system] +requires = ["hatchling>=1.0"] + +[project] +name = "demo" +dependencies = ["requests>=2.0", "httpx[http2]>=0.27"] + +[project.optional-dependencies] +dev = ["pytest>=8"] +postgres = ["psycopg2-binary"] +""" + result = _by_name(PyprojectParser().parse(content)) + assert result["requests"].dependency_kind == "direct" + assert result["httpx"].dependency_kind == "direct" # extras は名前のみ抽出 + assert result["pytest"].dependency_kind == "dev" # dev グループ + assert result["psycopg2-binary"].dependency_kind == "direct" # 非 dev extras + assert result["hatchling"].dependency_kind == "build" + assert all(d.ecosystem == "pypi" for d in result.values()) + + +def test_pyproject_poetry() -> None: + """Poetry 形式の dependencies / group.dev を分類し python を除外すること。""" + content = """ +[tool.poetry.dependencies] +python = "^3.12" +fastapi = "^0.110" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.4" +""" + result = _by_name(PyprojectParser().parse(content)) + assert "python" not in result + assert result["fastapi"].dependency_kind == "direct" + assert result["ruff"].dependency_kind == "dev" + + +def test_requirements_txt_skips_options_and_comments() -> None: + """コメント・オプション行をスキップし、名前を抽出すること。""" + content = ( + "# core deps\n" + "requests>=2.0\n" + "django==4.2 ; python_version>'3.10'\n" + "-r other.txt\n" + "-e .\n" + "\n" + "flask[async]\n" + ) + result = _by_name(RequirementsTxtParser().parse(content)) + assert set(result) == {"requests", "django", "flask"} + assert all(d.dependency_kind == "direct" for d in result.values()) + + +def test_cargo_toml_sections() -> None: + """Cargo.toml の各セクションを分類し、table 値の version を拾うこと。""" + content = """ +[dependencies] +serde = "1.0" +tokio = { version = "1", features = ["full"] } + +[dev-dependencies] +criterion = "0.5" + +[build-dependencies] +cc = "1.0" +""" + result = _by_name(CargoTomlParser().parse(content)) + assert result["serde"].dependency_kind == "direct" + assert result["tokio"].dependency_kind == "direct" + assert result["tokio"].version_spec == "1" + assert result["criterion"].dependency_kind == "dev" + assert result["cc"].dependency_kind == "build" + assert all(d.ecosystem == "cargo" for d in result.values()) + + +def test_requirements_txt_excludes_url_and_vcs_refs() -> None: + """URL / VCS 直指定は "https" / "git" として誤抽出されず除外されること。""" + content = ( + "requests>=2.0\n" + "https://example.com/pkg.tar.gz\n" + "git+https://github.com/org/repo.git@main\n" + ) + result = _by_name(RequirementsTxtParser().parse(content)) + assert set(result) == {"requests"} + + +def test_registry_dispatches_by_filename() -> None: + """parse_manifest がファイル名でパーサを選び、未対応は空を返すこと。""" + assert parse_manifest("go.mod", "require x/y v1.0.0\n")[0].ecosystem == "go" + assert parse_manifest("unknown.txt", "whatever") == [] diff --git a/docs/adr/0011-frontend-textlint-proofread.md b/docs/adr/0011-frontend-textlint-proofread.md index 21d9b689..27c9ba1f 100644 --- a/docs/adr/0011-frontend-textlint-proofread.md +++ b/docs/adr/0011-frontend-textlint-proofread.md @@ -2,7 +2,10 @@ ## ステータス -Accepted +Deprecated + +> **廃止**: 本機能は一度実装したが、運用上不要と判断して撤去した。 +> textlint + kuromoji によるフロントエンド完結型の校正は今後採用しない。 ## コンテキスト diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md new file mode 100644 index 00000000..ffa8797b --- /dev/null +++ b/docs/adr/0016-github-skill-inference.md @@ -0,0 +1,143 @@ +# ADR-0016: GitHub 連携によるスキル推論基盤 + +## ステータス + +Accepted + +(既存の決定論パイプライン `backend/app/services/intelligence/`(`skill_extractor.py` + `skill_taxonomy/` の自前辞書)を、本基盤へ段階移行する。機械と人間の責務分離は ADR-0010 の「制約の責務分離」と同一思想を踏襲する。) + +## コンテキスト + +DevForge には既に GitHub からスキルを推論する決定論的パイプラインがある(`intelligence/pipeline.py` → `skill_extractor.py`、LLM は呼ばない)。現状は **自前の技術タグ辞書**でスキル名へ写像している: + +- `skill_taxonomy/language_map.py`(`LANGUAGE_TO_SKILL`) +- `skill_taxonomy/topic_map.py`(`TOPIC_TO_SKILLS`) +- `skill_taxonomy/keyword_map.py`(`DESCRIPTION_KEYWORDS`) + +この方式には次の課題がある: + +- **辞書のメンテナンスコスト**: 言語・トピック・説明文キーワードの対応表を人手で維持し続ける必要がある。 +- **シグナルが弱い**: リポジトリ言語(byte 比率)・GitHub topics・description のキーワードマッチ止まりで、「実際に何のライブラリ・フレームワークを使ったか」を捉えられない。 +- **証跡性が立てにくい**: 経歴書に載せる GitHub URL と、推論したスキルの裏付けの対応が曖昧になりやすい。 + +また、推論結果には「機械が客観的に検出できるもの(幅)」と「人間にしか書けないもの(深さ:習熟度・文脈・成果規模)」が混在しており、両者を同じ層で扱うと、機械の更新が人間の記述を壊す/人間の主観が機械的シグナルを汚染する、という双方向の事故が起きる。 + +前提として **本基盤は public リポジトリを対象**とする(private は v1 では扱わず、後述の Layer 3 経由で人間が深さを補完する)。 + +## 決定内容 + +### D1. スキルを 3 層に分離する + +スキルを責務の異なる 3 層に分け、各層を埋める主体を固定する。 + +| 層 | 内容 | 埋める主体 | +|---|---|---| +| **Layer 1 Skill** | 正規化エンティティ。技術そのもの。`LanguageSkill` / `PackageSkill` に型分割(D2) | 機械(正規化ソースに resolve) | +| **Layer 2 Evidence** | 技術 × 根拠リポの N:N。`signal_source` / `confidence` / 量的シグナル(byte 数・出現回数等)を持つ | 機械が埋める | +| **Layer 3 Proficiency / Narrative** | 深さ・文脈。自己評価レベル / 本文 narrative / 期間・規模 | 人間 or agent 生成 → 人間レビューが確定 | + +原則: **機械は「幅」(Layer 1-2)、人間は「深さ」(Layer 3)を埋める**。機械が客観検出できるものと人間にしか書けないものを層で分離することで、双方の更新が干渉しない。ADR-0010 の「機械検証可能な制約はコード、機械検証不能な制約はプロンプト」という責務分離と同一思想である。 + +### D2. LanguageSkill と PackageSkill を別型にする + +Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割する。検出方法と信頼度の出方が本質的に異なるためである。 + +- **LanguageSkill**: GitHub Linguist による byte 比率で検出。信頼度は「どれだけ書いたか」の量的シグナル。 +- **PackageSkill**: manifest の宣言 + import の有無で検出。信頼度は「宣言したか」と「実際に import したか」の二段で出る。 + +同型に押し込めると、片方にしか存在しないシグナル(byte 数 / `dependency_kind` 等)の置き場所がなくなるため、別型とする。 + +### D3. 正規化ソースを役割ごとに外部へ委譲する + +スキル名の正規化に自前辞書を持たず、役割ごとに既存の正規ソースへ委譲する。 + +- **言語**: GitHub Linguist の `languages.yml` を正本とする。`aliases` を Layer 1 の alias、`group` を parent へ流用する。data 言語のデフォルト除外だけは経歴書向けに補正する(後述トレードオフ)。 +- **ライブラリ**: manifest の package ID が**エコシステム内で一意 = canonical**。したがって**辞書による正規化は不要**。エコシステムを跨いだ名寄せが必要な場合のみ deps.dev を参照する。 +- **表示名・粒度の畳み込み**(例: `@aws-sdk/client-eventbridge` → 「Amazon EventBridge」): 文脈依存で**機械検証不能**。package ID を入力に agent が提案し、人間が確定する(D8)。 + +### D4. 辞書とマッピング結果を分離し、連携フローから外部依存を排除する + +正規化ソース(辞書)の取得と、それを使ったマッピング結果を分離し、GitHub 連携のホットパスに外部依存を持ち込まない。 + +- **LanguageSkill**: `languages.yml` はビルド時 / 定期バッチで内部マスタ化する。連携時は外部を叩かず**内部マスタへ resolve するだけ**。resolve 結果は保持する。 +- **PackageSkill**: 辞書概念が不要(ID が正規)。連携時に外部解決を要しない。 + +これにより連携ホットパスの速度・信頼性が安定し、GitHub / 外部サービスのレート消費も増やさない。 + +### D5. ステージを discover / declare / verify に分ける + +スキル推論を段階設計とし、安価で確実なものを先に、高コストで精度を上げるものを後追いにする。 + +- **discover**: `/languages`(Linguist の byte 比率)を他ステージと並列で安価取得する。 +- **declare**: manifest をパースし、宣言された依存を取得する(D7)。 +- **verify**: import 解析で「宣言」を「実使用」へ昇格させる(D6)。 + +**declare までで経歴書は出力可能**であり、verify は精度を後追いで上げる位置づけ。verify が未完でも基盤は機能する。 + +### D6. verify は import 解析(C 案)で行う + +宣言依存(declare)のうち **direct 依存に絞って import 解析**し、実際に import されているものを `signal_source = 実使用` へ昇格させる。 + +- 生コードは fetch → parse → **破棄**(永続化しない)。 +- 全量走査せず**サンプリングで打ち切る**(verify コスト抑制、後述トレードオフ)。 + +依存グラフ / SBOM API 起点ではなく import 解析を選ぶ理由は「代替案」を参照。 + +### D7. declare のスコープと出力契約 + +- **対応エコシステム**: + - Tier1(v1 必須): Go(`go.mod`)/ Python(`pyproject.toml`, `requirements.txt`)/ JS-TS(`package.json`)/ Rust(`Cargo.toml`) + - Tier2(後追い): Java-Kotlin / Ruby / PHP + - 上記以外は対象外 +- **parser は plugin 型**: `ManifestParser`(入力 = ファイル内容、出力 = `[]PackageDeclaration`)。v1 では Tier1 のみ実装し、後から差し込めるようにする。 +- **出力に `dependency_kind`(direct / dev / indirect / peer / build)を保持する**: + - `go.mod` の `// indirect` は除外する。 + - `package.json` の `devDependencies` は実績スキルに混ぜない。 + - verify の絞り込み入力 + 経歴書提案の重み付けの素になる。**捨てると復元不能**なため、入力段では落とさず保持する。 + +### D8. 非決定性を human-in-the-loop で封じ込める + +機械検証不能な判断(粒度・足切り)は機械に確定させず、提案に留めて人間が確定する。 + +- **粒度の畳み込み**: agent が提案し、人間が確定する。確定後は固定。 +- **言語の足切り**(上位 N 位 / X% 以上): デフォルト値を設定として保持し、agent は例外提案のみ行う。 +- **原則「保持は細かく、提案は荒目」**: 検出データは全量保持し、畳み込み・絞り込みは後段のビュー変換として行う。**非可逆な切り捨てを入力時に行わない**。 + +### この設計で得られるもの + +- エビデンス系スキルに裏付けが付き、経歴書の GitHub URL との整合(証跡性)が立つ。 +- 自前の技術タグ辞書(`skill_taxonomy/`)のメンテが不要になる(言語 = Linguist、package = エコシステム ID)。 +- 連携ホットパスに外部依存が無く、速度・信頼性・レート消費が安定する(D4)。 +- declare までで出力でき、verify で段階的に精度を上げられる(D5)。 +- 既存のリトライ基盤(`backend/app/services/tasks/exceptions.py`)を流用できる: + - GitHub rate limit(403 / 429)= `RetryableError`(`Retry-After` / reset 時刻まで待機) + - リポジトリ削除 / private 化 = `NonRetryableError`(即 `dead_letter` 終端) + +## 代替案 + +- **package 正規化の自前辞書を持つ**: package ID がエコシステム内で一意のため不要。辞書メンテのコストだけが残るので却下。 +- **monorepo サブツリー(subtree)探索の v1 対応**: full tree 走査コスト / vendoring の除外 / 複数 manifest の主従重み付け、の 3 コストを生む(特に主従判定が本質的に厄介)。v1 は直下 manifest のみとし延期する。 +- **dependency graph / SBOM API を起点にする**: 宣言 only 止まりで「宣言」と「実 import」の `signal_source` を区別できず、D1/D6 の思想と不整合。import 解析(C 案、D6)を採用する。 +- **desktop 版で実装する**: desktop 固有価値(ローカルリポ直読み / PII ローカル完結 / ローカル LLM)は前提が消滅(PII はクラウドの非学習契約で吸収、LLM はクラウド上位モデルへ移行)。stack 推論は本 ADR の GitHub per-repo manifest 解析で代替する。`devforge-desktop` は塩漬けとして保持する。 + +## トレードオフ・既知のリスク + +- **public 限定**: 主戦場が private なユーザーはスキルが過少評価される。v1 では受容し、将来 Layer 3(人間が深さを補完)で吸収する。 +- **import 解析(C 案)の verify コスト**: 言語別 parser が必要で高コスト。サンプリング(D6)と direct 依存への絞り込み(D7)で緩和する。 +- **Linguist の data 言語デフォルト除外**: SQL / YAML / GraphQL 等は Linguist がデフォルトで除外する。経歴書的に必要なものは補正する(D3)。 + +## 将来の移行条件 + +- **verify ステージの詳細設計**: import サンプリング戦略、言語別の import 検出、打ち切り条件。 +- **monorepo 対応**: Trees API + Linguist の除外定義流用、ファイル位置・規模シグナル、複数 manifest の主従重み付け。 +- **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 +- **deps.dev エンリッチ**: 横断名寄せの範囲・実行タイミング。 +- **閾値・粒度のデフォルト**: 言語足切りの初期値、表示名 alias の初期セット。 + +## 関連リンク + +- ADR-0010(DevForge Agent / 制約の責務分離の元思想)/ ADR-0013(マルチプロバイダ LLM)/ ADR-0015(Vertex AI 経由) +- 既存実装(移行対象): `backend/app/services/intelligence/`(`pipeline.py` / `skill_extractor.py` / `skill_taxonomy/{language_map,topic_map,keyword_map}.py` / `github/api_client.py`) +- リトライ基盤: `backend/app/services/tasks/exceptions.py`(`RetryableError` / `NonRetryableError` / `dead_letter`) +- [GitHub Linguist `languages.yml`](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml) +- [deps.dev](https://deps.dev/) diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 54646022..34639272 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -442,6 +442,28 @@ export interface paths { patch?: never; trace?: never; }; + "/api/github-link/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Skills + * @description GitHub 連携で推論した 3 層スキル(ADR-0016)を取得する。 + * + * 連携がまだ実行されていない場合は空配列を返す。 + */ + get: operations["get_skills_api_github_link_skills_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/master-data/qualification": { parameters: { query?: never; @@ -1516,6 +1538,48 @@ export interface components { /** State */ state: string; }; + /** + * GitHubSkillItem + * @description Layer 1: 正規化スキルと、その根拠・習熟度。 + */ + GitHubSkillItem: { + /** + * Canonical Name + * @description 正規名(言語=Linguist 名 / package=package ID) + */ + canonical_name: string; + /** + * Display Name + * @description 表示名(粒度畳みの確定値。未確定は null) + */ + display_name?: string | null; + /** + * Ecosystem + * @description package のエコシステム(npm/pypi/go/cargo)。言語では null + */ + ecosystem?: string | null; + /** Evidence */ + evidence?: components["schemas"]["SkillEvidence"][]; + /** + * Kind + * @description スキル種別(language / package) + */ + kind: string; + /** + * Parent + * @description 親(Linguist の group) + */ + parent?: string | null; + proficiency?: components["schemas"]["SkillProficiency"] | null; + }; + /** + * GitHubSkillsResponse + * @description ユーザーの GitHub 連携スキル一覧(3 層)。 + */ + GitHubSkillsResponse: { + /** Skills */ + skills?: components["schemas"]["GitHubSkillItem"][]; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1795,6 +1859,79 @@ export interface components { /** Self Pr */ self_pr: string; }; + /** + * SkillEvidence + * @description Layer 2: 技術×リポの根拠。 + */ + SkillEvidence: { + /** + * Confidence + * @description 信頼度(0.0–1.0) + */ + confidence: number; + /** + * Dependency Kind + * @description 依存の種類(direct/dev/indirect/peer/build。言語では null) + */ + dependency_kind?: string | null; + /** + * Language Bytes + * @description 言語シグナルのバイト数(package では null) + */ + language_bytes?: number | null; + /** + * Repo Full Name + * @description 根拠リポジトリ(owner/name) + */ + repo_full_name: string; + /** + * Repo Url + * @description リポジトリ URL(経歴書の証跡用) + */ + repo_url: string; + /** + * Signal Source + * @description 根拠の出所(language_bytes / manifest_declared / actual_import) + */ + signal_source: string; + }; + /** + * SkillProficiency + * @description Layer 3: 習熟度・文脈(人間/agent が後追いで埋める。本フェーズは未投入)。 + */ + SkillProficiency: { + /** + * Duration Months + * @description 従事期間(月) + */ + duration_months?: number | null; + /** + * Narrative + * @description 文脈の説明文 + */ + narrative?: string | null; + /** + * Reviewed + * @description 人間レビュー済みか + * @default false + */ + reviewed: boolean; + /** + * Scale + * @description 規模 + */ + scale?: string | null; + /** + * Self Assessed Level + * @description 自己評価レベル + */ + self_assessed_level?: string | null; + /** + * Source + * @description 出所(agent / human) + */ + source?: string | null; + }; /** * SubProgress * @description ステップ内の細粒度な進捗(例: リポジトリ詳細取得ステップ)。 @@ -2486,6 +2623,26 @@ export interface operations { }; }; }; + get_skills_api_github_link_skills_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitHubSkillsResponse"]; + }; + }; + }; + }; list_items_api_master_data_qualification_get: { parameters: { query?: never;