diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 5a9ecdfa..6484c592 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -76,8 +76,10 @@ backend/app/ │ │ │ ├── api_client.py │ │ │ └── repo_analyzer.py │ │ ├── response_mapper.py -│ │ ├── skill_extractor.py -│ │ └── skill_taxonomy/ # スキル分類(言語・トピック・キーワードマップ) +│ │ └── skills/ # スキル推論基盤(ADR-0016 / 3層モデル) +│ │ ├── aggregator.py # discover+declare 合流 → DetectedSkill +│ │ ├── linguist.py # 言語正規化(Linguist languages.yml) +│ │ └── manifests/ # エコシステム別 manifest パーサ(plugin 型) │ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル) │ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ) │ │ ├── exceptions.py # RetryableError / NonRetryableError @@ -104,4 +106,4 @@ backend/app/ - **routers/auth/ と routers/blog/**: いずれもパッケージ化されている。auth は `endpoints` / `github_auth` / `oauth_flow` / `token_manager`、blog は `accounts` / `score` / `sync` に責務分割 - **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う。現在登録されているタスクは `GITHUB_LINK` の 1 種類のみだが、`AsyncTaskCacheService` / `TaskHandler` は新規タスク追加の拡張ポイントとして汎用化してある(インライン化しない) - **タスクハンドラの「黙って return」は禁止**: 失敗パスでは `NonRetryableError` / `RetryableError` を `raise` し、`dead_letter` / `retrying` 遷移と通知発行を worker に任せる。早期 return すると呼び出し側に completed として観測されてしまう -- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路。LLM は使わず決定論的(ルールベース)に処理する(intelligence モジュールは LLM を使わない。LLM は services/agent/ のみ / ADR-0010) +- **services/intelligence/**: GitHub 連携 → スキル推論パイプライン。`github_link_service` → `github_collector`(収集)→ `skills/aggregate_skills`(ADR-0016 の 3 層スキル検出)→ `pipeline.aggregate_intelligence`(dashboard 表示用サマリ)が live 経路。旧 `skill_extractor` / `skill_taxonomy`(自前辞書)は ADR-0016 基盤へ移行完了済みで撤去。LLM は使わず決定論的(ルールベース)に処理する(intelligence モジュールは LLM を使わない。LLM は services/agent/ のみ / ADR-0010) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index 1457178f..ec03f6be 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -121,9 +121,8 @@ async def _on_repo_fetched(done: int, total: int) -> None: # 取得失敗のときだけ警告対象にする(貢献年が無いだけなら警告しない) contribution_fetch_failed = not ok - # ステップ 3: スキル抽出(集計関数で一括処理) + # ステップ 3: スキル集計 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( @@ -139,6 +138,9 @@ async def _on_repo_fetched(done: int, total: int) -> None: ] ) + # dashboard 表示用サマリ(件数・言語バイト数)を検出スキルから組む。 + result = aggregate_intelligence(payload["github_username"], repos, detected_skills) + response = map_pipeline_result(result) response.contribution_calendars = calendars result_dict = response.model_dump() diff --git a/backend/app/services/intelligence/pipeline.py b/backend/app/services/intelligence/pipeline.py index cbf7fe38..7d486e65 100644 --- a/backend/app/services/intelligence/pipeline.py +++ b/backend/app/services/intelligence/pipeline.py @@ -1,23 +1,22 @@ """ -キャリアインテリジェンスパイプラインのオーケストレーター。 +キャリアインテリジェンスパイプラインの集計関数。 -GitHub のデータから以下の分析を順次実行します: - GitHub → リポジトリ → 集計 → スキル抽出 +GitHub から収集したリポジトリ集合(``RepoData``)と、スキル推論基盤 +(ADR-0016 / ``skills.aggregate_skills``)が組み立てた ``DetectedSkill`` 列から、 +dashboard 表示用の集計結果(``IntelligenceResult``)を構築する。 -各ステージは決定論的(LLM は呼ばない)。 +集計は決定論的(LLM は呼ばない)。スキルの正規化・検出は ``skills/`` 配下が担い、 +本モジュールは「件数・言語バイト数」の表示用サマリ算出に責務を限定する。 """ from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, List -from ...core.logging_utils import get_logger -from ...core.metrics import measure_time_async -from .github_collector import RepoData, collect_repos -from .skill_extractor import extract_skills - -logger = get_logger(__name__) +from .github_collector import RepoData +from .skills import DetectedSkill +from .skills.types import SKILL_KIND_LANGUAGE @dataclass @@ -31,54 +30,32 @@ class IntelligenceResult: languages: Dict[str, int] = field(default_factory=dict) -def aggregate_intelligence(username: str, repos: List[RepoData]) -> IntelligenceResult: - """リポジトリ集合から ``IntelligenceResult`` を構築する純粋関数。 +def aggregate_intelligence( + username: str, + repos: List[RepoData], + detected_skills: List[DetectedSkill], +) -> IntelligenceResult: + """リポジトリ集合と検出スキルから ``IntelligenceResult`` を構築する集計関数。 - I/O を行わないため、CLI/テスト向けの ``run_pipeline`` と - 進捗通知付きのバックグラウンドワーカーの双方から再利用できる。 + 外部 I/O(GitHub API / DB)は行わない(``analyzed_at`` のみ実行時刻を用いるため、 + 厳密には時刻依存。純粋関数ではない)。``unique_skills`` は ADR-0016 のスキル推論基盤が検出した + Layer 1 スキルのうち **言語スキル(kind=language)の件数**を表す + (dashboard 表示用。package は件数に含めない)。旧辞書ベース抽出は撤去済み。 + ``languages`` は Linguist のバイト数をリポジトリ横断で合算する。 """ lang_totals: Dict[str, int] = defaultdict(int) for repo in repos: for lang, byte_count in repo.languages.items(): lang_totals[lang] += byte_count - extraction = extract_skills(repos) + unique_language_skills = sum( + 1 for skill in detected_skills if skill.kind == SKILL_KIND_LANGUAGE + ) return IntelligenceResult( username=username, - repos_analyzed=extraction.repos_analyzed, - unique_skills=len(extraction.unique_skills), + repos_analyzed=len(repos), + unique_skills=unique_language_skills, analyzed_at=datetime.now().isoformat(), languages=dict(lang_totals), ) - - -@measure_time_async("intelligence.pipeline") -async def run_pipeline( - username: str, - token: Optional[str] = None, - include_forks: bool = False, -) -> IntelligenceResult: - """GitHub ユーザーに対してキャリアインテリジェンスパイプラインを実行する。 - - 1. GitHub API からリポジトリ収集 - 2. ``aggregate_intelligence`` で集計・スキル抽出 - """ - logger.info("%s のインテリジェンスパイプラインを開始します", username) - - repos: List[RepoData] = await collect_repos( - username, - token=token, - include_forks=include_forks, - ) - - result = aggregate_intelligence(username, repos) - - logger.info( - "パイプライン完了 (%s): 分析リポジトリ数=%d, ユニークスキル数=%d", - username, - result.repos_analyzed, - result.unique_skills, - ) - - return result diff --git a/backend/app/services/intelligence/skill_extractor.py b/backend/app/services/intelligence/skill_extractor.py deleted file mode 100644 index fffa1633..00000000 --- a/backend/app/services/intelligence/skill_extractor.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -GitHub リポジトリデータからの決定論的なスキル抽出。 - -以下の方法でスキルを抽出します: - 1. 言語検出 (GitHub の linguist) - 2. リポジトリのトピック - 3. 説明文のキーワードマッチング - -LLMは使用しません。 -""" - -import logging -from dataclasses import dataclass, field -from typing import List, Set - -from .github_collector import RepoData -from .skill_taxonomy import ( - DESCRIPTION_KEYWORDS, - LANGUAGE_TO_SKILL, - TOPIC_TO_SKILLS, - get_skill_category, -) - -logger = logging.getLogger(__name__) - - -@dataclass -class ExtractedSkill: - """単一のリポジトリから抽出されたスキル。""" - - skill_name: str - category: str - source: str # "language", "topic", "description" など - repo_name: str - repo_created_at: str - repo_pushed_at: str - language_bytes: int = 0 # リポジトリ内のこの言語のバイト数 - - -@dataclass -class ExtractionResult: - skills: List[ExtractedSkill] - repos_analyzed: int - unique_skills: Set[str] = field(default_factory=set) - - -def extract_skills(repos: List[RepoData]) -> ExtractionResult: - """ - GitHub リポジトリのリストからスキルを抽出します。 - - すべてのリポジトリにわたるすべてのスキルの観測結果を返します - (1つのスキルが異なるリポジトリから複数回現れる場合があります)。 - """ - all_skills: List[ExtractedSkill] = [] - unique: Set[str] = set() - - for repo in repos: - repo_skills = _extract_from_repo(repo) - all_skills.extend(repo_skills) - for s in repo_skills: - unique.add(s.skill_name) - - logger.info( - "Extracted %d skill observations (%d unique) from %d repos", - len(all_skills), - len(unique), - len(repos), - ) - return ExtractionResult( - skills=all_skills, - repos_analyzed=len(repos), - unique_skills=unique, - ) - - -def _extract_from_repo(repo: RepoData) -> List[ExtractedSkill]: - """単一のリポジトリからスキルを抽出します。""" - skills: List[ExtractedSkill] = [] - seen: Set[str] = set() - - def add(skill_name: str, source: str, *, language_bytes: int = 0) -> None: - """スキルを ``skills`` に追加する。同一スキルは 1 度のみ追加。""" - if not skill_name or skill_name in seen: - return - seen.add(skill_name) - skills.append( - ExtractedSkill( - skill_name=skill_name, - category=get_skill_category(skill_name), - source=source, - repo_name=repo.name, - repo_created_at=repo.created_at, - repo_pushed_at=repo.pushed_at, - language_bytes=language_bytes, - ) - ) - - # 1. 言語 - for lang, byte_count in repo.languages.items(): - add(LANGUAGE_TO_SKILL.get(lang, ""), "language", language_bytes=byte_count) - - # 2. トピック - for topic in repo.topics: - for skill_name in TOPIC_TO_SKILLS.get(topic.lower(), []): - add(skill_name, "topic") - - # 3. 説明文のキーワード - if repo.description: - desc_lower = repo.description.lower() - for keyword, matched_skills in DESCRIPTION_KEYWORDS.items(): - if keyword in desc_lower: - for skill_name in matched_skills: - add(skill_name, "description") - - return skills diff --git a/backend/app/services/intelligence/skill_taxonomy/__init__.py b/backend/app/services/intelligence/skill_taxonomy/__init__.py deleted file mode 100644 index 492448a2..00000000 --- a/backend/app/services/intelligence/skill_taxonomy/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""スキルタクソノミパッケージ。 - -既存のインポートパスとの互換性を保つため、すべての公開シンボルを re-export する。 -""" - -from .classifier import ( - SKILL_CATEGORIES, - get_all_skills, - get_skill_category, -) -from .keyword_map import DESCRIPTION_KEYWORDS -from .language_map import LANGUAGE_TO_SKILL -from .topic_map import TOPIC_TO_SKILLS - -__all__ = [ - "DESCRIPTION_KEYWORDS", - "LANGUAGE_TO_SKILL", - "SKILL_CATEGORIES", - "TOPIC_TO_SKILLS", - "get_all_skills", - "get_skill_category", -] diff --git a/backend/app/services/intelligence/skill_taxonomy/classifier.py b/backend/app/services/intelligence/skill_taxonomy/classifier.py deleted file mode 100644 index c5b6bea7..00000000 --- a/backend/app/services/intelligence/skill_taxonomy/classifier.py +++ /dev/null @@ -1,163 +0,0 @@ -"""スキルカテゴリ分類の定義と分類関数。""" - -from typing import Dict, List, Set - -# スキルカテゴリ定義: カテゴリ名 → スキル一覧 -SKILL_CATEGORIES: Dict[str, List[str]] = { - "language": [ - "Python", - "JavaScript", - "TypeScript", - "Go", - "Rust", - "Java", - "Kotlin", - "C", - "C++", - "C#", - "Ruby", - "PHP", - "Swift", - "Dart", - "Scala", - "Elixir", - "Haskell", - "Lua", - "R", - "Julia", - "Shell", - "SQL", - ], - "frontend_framework": [ - "React", - "Vue", - "Angular", - "Svelte", - "Next.js", - "Nuxt.js", - "Astro", - "Gatsby", - "Remix", - ], - "backend_framework": [ - "FastAPI", - "Django", - "Flask", - "Express", - "NestJS", - "Spring Boot", - "Rails", - "Laravel", - "Gin", - "Echo", - "Actix", - "Phoenix", - "ASP.NET", - "Fiber", - "SQLAlchemy", - "Celery", - ], - "mobile": [ - "React Native", - "Flutter", - "SwiftUI", - "Jetpack Compose", - "Ionic", - ], - "database": [ - "PostgreSQL", - "MySQL", - "MongoDB", - "Redis", - "SQLite", - "DynamoDB", - "Elasticsearch", - "Cassandra", - "Neo4j", - "Firebase", - ], - "infrastructure": [ - "Docker", - "Docker Compose", - "Kubernetes", - "Terraform", - "Pulumi", - "Ansible", - "CloudFormation", - "Helm", - ], - "cloud": [ - "AWS", - "GCP", - "Azure", - ], - "cicd": [ - "GitHub Actions", - "GitLab CI", - "Jenkins", - "CircleCI", - "ArgoCD", - ], - "monitoring": [ - "Prometheus", - "Grafana", - "Datadog", - "Sentry", - "OpenTelemetry", - ], - "ml": [ - "TensorFlow", - "PyTorch", - "scikit-learn", - "Hugging Face", - "LangChain", - "MLflow", - "Pandas", - "NumPy", - "Jupyter", - ], - "data_engineering": [ - "Apache Spark", - "Apache Kafka", - "Airflow", - "dbt", - "BigQuery", - "Redshift", - "Snowflake", - ], - "security": [ - "OAuth", - "JWT", - "OpenID Connect", - "Vault", - "SAST", - "DAST", - ], - "other": [ - "GraphQL", - "gRPC", - "REST API", - "WebSocket", - "Nginx", - "Linux", - "Git", - "Figma", - "Build Automation", - ], -} - -# 逆引き参照: スキル名 → カテゴリ名 -_SKILL_TO_CATEGORY: Dict[str, str] = {} -for _cat, _skills in SKILL_CATEGORIES.items(): - for _s in _skills: - _SKILL_TO_CATEGORY[_s] = _cat - - -def get_skill_category(skill: str) -> str: - """スキル名からカテゴリ名を返す。未知のスキルは "other" を返す。""" - return _SKILL_TO_CATEGORY.get(skill, "other") - - -def get_all_skills() -> Set[str]: - """全スキル名のセットを返す。""" - return set(_SKILL_TO_CATEGORY.keys()) diff --git a/backend/app/services/intelligence/skill_taxonomy/keyword_map.py b/backend/app/services/intelligence/skill_taxonomy/keyword_map.py deleted file mode 100644 index 6c3eb7ed..00000000 --- a/backend/app/services/intelligence/skill_taxonomy/keyword_map.py +++ /dev/null @@ -1,45 +0,0 @@ -"""リポジトリ説明文キーワード → スキルのマッピング定義。 - -大文字小文字を区別しない部分一致でチェックされる。 -""" - -from typing import Dict, List - -# リポジトリ説明文に含まれるキーワード → スキル一覧 -DESCRIPTION_KEYWORDS: Dict[str, List[str]] = { - "fastapi": ["FastAPI"], - "django": ["Django"], - "flask": ["Flask"], - "express": ["Express"], - "nestjs": ["NestJS"], - "spring boot": ["Spring Boot"], - "react native": ["React Native"], - "react": ["React"], - "vue": ["Vue"], - "angular": ["Angular"], - "next.js": ["Next.js"], - "nuxt": ["Nuxt.js"], - "flutter": ["Flutter"], - "docker": ["Docker"], - "kubernetes": ["Kubernetes"], - "terraform": ["Terraform"], - "aws": ["AWS"], - "gcp": ["GCP"], - "google cloud": ["GCP"], - "azure": ["Azure"], - "postgresql": ["PostgreSQL"], - "postgres": ["PostgreSQL"], - "mongodb": ["MongoDB"], - "redis": ["Redis"], - "graphql": ["GraphQL"], - "grpc": ["gRPC"], - "machine learning": ["scikit-learn"], - "deep learning": ["PyTorch"], - "tensorflow": ["TensorFlow"], - "pytorch": ["PyTorch"], - "langchain": ["LangChain"], - "kafka": ["Apache Kafka"], - "airflow": ["Airflow"], - "prometheus": ["Prometheus"], - "grafana": ["Grafana"], -} diff --git a/backend/app/services/intelligence/skill_taxonomy/language_map.py b/backend/app/services/intelligence/skill_taxonomy/language_map.py deleted file mode 100644 index 1f0387c2..00000000 --- a/backend/app/services/intelligence/skill_taxonomy/language_map.py +++ /dev/null @@ -1,31 +0,0 @@ -"""GitHub の言語検出名を正規化されたスキル名にマッピングする静的定義。""" - -from typing import Dict - -# GitHubの言語検出名 → 正規化スキル名 -LANGUAGE_TO_SKILL: Dict[str, str] = { - "Python": "Python", - "JavaScript": "JavaScript", - "TypeScript": "TypeScript", - "Go": "Go", - "Rust": "Rust", - "Java": "Java", - "Kotlin": "Kotlin", - "C": "C", - "C++": "C++", - "C#": "C#", - "Ruby": "Ruby", - "PHP": "PHP", - "Swift": "Swift", - "Dart": "Dart", - "Scala": "Scala", - "Elixir": "Elixir", - "Haskell": "Haskell", - "Lua": "Lua", - "R": "R", - "Julia": "Julia", - "Shell": "Shell", - "HCL": "Terraform", - "Dockerfile": "Docker", - "Jupyter Notebook": "Jupyter", -} diff --git a/backend/app/services/intelligence/skill_taxonomy/topic_map.py b/backend/app/services/intelligence/skill_taxonomy/topic_map.py deleted file mode 100644 index 358bc244..00000000 --- a/backend/app/services/intelligence/skill_taxonomy/topic_map.py +++ /dev/null @@ -1,106 +0,0 @@ -"""GitHub リポジトリトピック → スキルのマッピング定義。""" - -from typing import Dict, List - -# GitHubリポジトリのトピック → スキル一覧 -TOPIC_TO_SKILLS: Dict[str, List[str]] = { - # フレームワーク - "react": ["React"], - "reactjs": ["React"], - "vue": ["Vue"], - "vuejs": ["Vue"], - "angular": ["Angular"], - "svelte": ["Svelte"], - "nextjs": ["Next.js"], - "next-js": ["Next.js"], - "nuxt": ["Nuxt.js"], - "nuxtjs": ["Nuxt.js"], - "astro": ["Astro"], - "gatsby": ["Gatsby"], - "remix": ["Remix"], - "fastapi": ["FastAPI"], - "django": ["Django"], - "flask": ["Flask"], - "express": ["Express"], - "expressjs": ["Express"], - "nestjs": ["NestJS"], - "spring-boot": ["Spring Boot"], - "spring": ["Spring Boot"], - "rails": ["Rails"], - "ruby-on-rails": ["Rails"], - "laravel": ["Laravel"], - "gin": ["Gin"], - "actix": ["Actix"], - "phoenix": ["Phoenix"], - "aspnet": ["ASP.NET"], - # モバイル - "react-native": ["React Native"], - "flutter": ["Flutter"], - "swiftui": ["SwiftUI"], - "jetpack-compose": ["Jetpack Compose"], - "ionic": ["Ionic"], - # データベース - "postgresql": ["PostgreSQL"], - "postgres": ["PostgreSQL"], - "mysql": ["MySQL"], - "mongodb": ["MongoDB"], - "redis": ["Redis"], - "sqlite": ["SQLite"], - "dynamodb": ["DynamoDB"], - "elasticsearch": ["Elasticsearch"], - "neo4j": ["Neo4j"], - "firebase": ["Firebase"], - # インフラ - "docker": ["Docker"], - "kubernetes": ["Kubernetes"], - "k8s": ["Kubernetes"], - "terraform": ["Terraform"], - "pulumi": ["Pulumi"], - "ansible": ["Ansible"], - "helm": ["Helm"], - "cloudformation": ["CloudFormation"], - # クラウド - "aws": ["AWS"], - "gcp": ["GCP"], - "google-cloud": ["GCP"], - "azure": ["Azure"], - # CI/CD - "github-actions": ["GitHub Actions"], - "gitlab-ci": ["GitLab CI"], - "jenkins": ["Jenkins"], - "circleci": ["CircleCI"], - "argocd": ["ArgoCD"], - # モニタリング - "prometheus": ["Prometheus"], - "grafana": ["Grafana"], - "datadog": ["Datadog"], - "sentry": ["Sentry"], - "opentelemetry": ["OpenTelemetry"], - # 機械学習 / データ - "tensorflow": ["TensorFlow"], - "pytorch": ["PyTorch"], - "scikit-learn": ["scikit-learn"], - "sklearn": ["scikit-learn"], - "huggingface": ["Hugging Face"], - "langchain": ["LangChain"], - "mlflow": ["MLflow"], - "pandas": ["Pandas"], - "numpy": ["NumPy"], - "machine-learning": ["scikit-learn"], - "deep-learning": ["PyTorch"], - "spark": ["Apache Spark"], - "apache-spark": ["Apache Spark"], - "kafka": ["Apache Kafka"], - "apache-kafka": ["Apache Kafka"], - "airflow": ["Airflow"], - "dbt": ["dbt"], - "bigquery": ["BigQuery"], - "snowflake": ["Snowflake"], - # その他 - "graphql": ["GraphQL"], - "grpc": ["gRPC"], - "rest-api": ["REST API"], - "websocket": ["WebSocket"], - "nginx": ["Nginx"], - "linux": ["Linux"], -} diff --git a/backend/tests/test_github_link.py b/backend/tests/test_github_link.py index 733e4062..ddf84793 100644 --- a/backend/tests/test_github_link.py +++ b/backend/tests/test_github_link.py @@ -4,13 +4,13 @@ Tests cover deterministic modules only (no GitHub API calls). """ -import asyncio -from unittest.mock import AsyncMock, patch - from app.services.intelligence.github_collector import RepoData -from app.services.intelligence.pipeline import IntelligenceResult, run_pipeline +from app.services.intelligence.pipeline import ( + IntelligenceResult, + aggregate_intelligence, +) from app.services.intelligence.response_mapper import map_pipeline_result -from app.services.intelligence.skill_extractor import extract_skills +from app.services.intelligence.skills import DetectedSkill from conftest import auth_header @@ -39,135 +39,15 @@ def _make_repo( ) -SAMPLE_REPOS = [ - _make_repo( - name="web-api", - languages={"Python": 50000, "Dockerfile": 500}, - topics=["fastapi", "docker", "postgresql"], - description="FastAPI backend with PostgreSQL", - created_at="2021-03-01T00:00:00Z", - pushed_at="2024-01-15T00:00:00Z", - ), - _make_repo( - name="infra", - languages={"HCL": 30000, "Shell": 2000}, - topics=["terraform", "gcp", "kubernetes"], - description="GCP infrastructure with Terraform", - created_at="2023-01-01T00:00:00Z", - pushed_at="2024-06-01T00:00:00Z", - ), - _make_repo( - name="frontend", - languages={"TypeScript": 40000, "JavaScript": 5000}, - topics=["react", "nextjs"], - description="React frontend", - created_at="2022-06-01T00:00:00Z", - pushed_at="2023-12-01T00:00:00Z", - ), - _make_repo( - name="old-java", - languages={"Java": 80000}, - topics=["spring-boot"], - description="Legacy Spring Boot service", - created_at="2019-01-01T00:00:00Z", - pushed_at="2020-06-01T00:00:00Z", - ), - _make_repo( - name="scripts", - languages={"Python": 3000}, - topics=[], - description="Utility scripts", - created_at="2020-01-01T00:00:00Z", - pushed_at="2020-12-01T00:00:00Z", - ), -] - - -# ── Skill Extractor ───────────────────────────────────────────────────── - - -class TestSkillExtractor: - def test_extracts_from_languages(self): - repos = [_make_repo(languages={"Python": 10000, "Go": 5000})] - result = extract_skills(repos) - names = {s.skill_name for s in result.skills} - assert "Python" in names - assert "Go" in names - - def test_extracts_from_topics(self): - repos = [_make_repo(topics=["react", "docker", "postgresql"])] - result = extract_skills(repos) - names = {s.skill_name for s in result.skills} - assert "React" in names - assert "Docker" in names - assert "PostgreSQL" in names - - def test_extracts_from_description(self): - repos = [_make_repo(description="Built with FastAPI and Redis")] - result = extract_skills(repos) - names = {s.skill_name for s in result.skills} - assert "FastAPI" in names - assert "Redis" in names - - def test_deduplicates_within_repo(self): - """Same skill from language + topic should appear once per repo.""" - repos = [ - _make_repo( - languages={"Python": 10000}, - topics=["fastapi"], - description="A FastAPI project", - ) - ] - result = extract_skills(repos) - python_count = sum( - 1 - for s in result.skills - if s.skill_name == "Python" and s.repo_name == "my-repo" - ) - assert python_count == 1 - - def test_hcl_maps_to_terraform(self): - repos = [_make_repo(languages={"HCL": 5000})] - result = extract_skills(repos) - names = {s.skill_name for s in result.skills} - assert "Terraform" in names - - def test_empty_repos(self): - result = extract_skills([]) - assert result.skills == [] - assert result.repos_analyzed == 0 - - def test_unique_skills_count(self): - result = extract_skills(SAMPLE_REPOS) - assert len(result.unique_skills) > 5 - assert result.repos_analyzed == 5 - - def test_source_tracking(self): - repos = [ - _make_repo( - languages={"Python": 10000}, - topics=["docker"], - ) - ] - result = extract_skills(repos) - sources = {s.source for s in result.skills} - assert "language" in sources - assert "topic" in sources - - def test_only_language_topic_description_sources(self): - """フレームワーク/インフラ検出撤去後、source は language/topic/description のみ。""" - repos = [ - _make_repo( - languages={"Python": 10000}, - topics=["docker"], - description="Built with FastAPI", - ) - ] - result = extract_skills(repos) - assert result.skills, "fixture が language/topic/description の各経路を発火させること" - sources = {s.source for s in result.skills} - # 3 経路すべてが発火し、かつそれ以外の source が混ざらないこと(撤去の取りこぼし検知)。 - assert sources == {"language", "topic", "description"} +def _make_skill(kind: str, canonical_name: str, ecosystem: str = "") -> DetectedSkill: + """件数集計用の最小 DetectedSkill(evidence は本テストでは不問)。""" + return DetectedSkill( + kind=kind, + canonical_name=canonical_name, + ecosystem=ecosystem, + parent=None, + display_name=None, + ) # ── Intelligence Endpoint Tests ──────────────────────────────────────── @@ -183,6 +63,7 @@ def test_analyze_requires_github_user(client) -> None: ) assert resp.status_code == 403 + # ── Response Mapper Tests ────────────────────────────────────────────── @@ -204,52 +85,42 @@ def test_map_pipeline_result_includes_languages() -> None: assert response.contribution_calendars == [] -# ── Pipeline Tests ────────────────────────────────────────────────────── - - -def _make_pipeline_repo(name: str, languages: dict | None = None) -> RepoData: - """パイプラインテスト用のリポジトリデータを生成するヘルパー。""" - return RepoData( - name=name, - owner="testuser", - description="", - languages=languages or {}, - topics=[], - created_at="2024-01-01T00:00:00Z", - pushed_at="2024-06-01T00:00:00Z", - fork=False, - stargazers_count=0, - default_branch="main", - ) +# ── aggregate_intelligence Tests(ADR-0016: 新基盤の検出スキルから集計)────── -def test_run_pipeline_aggregates_languages() -> None: - """複数リポジトリの言語バイト数が正しく集計されること。""" +def test_aggregate_intelligence_aggregates_languages() -> None: + """複数リポジトリの言語バイト数が横断合算されること。""" repos = [ - _make_pipeline_repo("repo-a", {"Python": 10000, "JavaScript": 5000}), - _make_pipeline_repo("repo-b", {"Python": 20000, "Go": 8000}), + _make_repo(name="repo-a", languages={"Python": 10000, "JavaScript": 5000}), + _make_repo(name="repo-b", languages={"Python": 20000, "Go": 8000}), ] - - with patch( - "app.services.intelligence.pipeline.collect_repos", - new_callable=AsyncMock, - return_value=repos, - ): - result = asyncio.get_event_loop().run_until_complete(run_pipeline(username="testuser")) + result = aggregate_intelligence("testuser", repos, detected_skills=[]) assert result.languages["Python"] == 30000 assert result.languages["JavaScript"] == 5000 assert result.languages["Go"] == 8000 + assert result.repos_analyzed == 2 + + +def test_aggregate_intelligence_unique_skills_counts_languages_only() -> None: + """unique_skills が言語スキル(kind=language)の件数のみになり、package は除外されること。""" + repos = [_make_repo(languages={"Python": 10000})] + detected = [ + _make_skill("language", "Python"), + _make_skill("language", "Go"), + # package は dashboard の unique_skills 件数に含めない + _make_skill("package", "fastapi", ecosystem="pypi"), + _make_skill("package", "react", ecosystem="npm"), + ] + result = aggregate_intelligence("testuser", repos, detected) + + assert result.unique_skills == 2 + assert result.repos_analyzed == 1 -def test_run_pipeline_empty_repos() -> None: - """リポジトリ 0 件で正常終了すること。""" - with patch( - "app.services.intelligence.pipeline.collect_repos", - new_callable=AsyncMock, - return_value=[], - ): - result = asyncio.get_event_loop().run_until_complete(run_pipeline(username="emptyuser")) +def test_aggregate_intelligence_empty() -> None: + """リポジトリ 0 件・スキル 0 件で空サマリになること。""" + result = aggregate_intelligence("emptyuser", repos=[], detected_skills=[]) assert result.repos_analyzed == 0 assert result.unique_skills == 0 diff --git a/backend/tests/test_worker/test_github_link.py b/backend/tests/test_worker/test_github_link.py index 274739e3..650f5f97 100644 --- a/backend/tests/test_worker/test_github_link.py +++ b/backend/tests/test_worker/test_github_link.py @@ -40,6 +40,68 @@ def _sample_repos(self): ) ] + def test_detected_skills_passed_to_aggregate_intelligence( + self, db_session: Session, session_factory + ): + """配線検証: aggregate_skills の出力が aggregate_intelligence へそのまま渡ること。 + + ADR-0016 の新基盤一本化で、検出スキル(aggregate_skills)→ サマリ集計 + (aggregate_intelligence)の受け渡し順序を変更したため、その配線を固定する。 + """ + user, _cache = self._make_user_and_cache(db_session, "wiring-user") + repos = self._sample_repos() + sentinel_skills = ["SENTINEL_DETECTED_SKILL"] + # cache.result への書き戻しが JSON シリアライズ可能になるよう dict を返させる + mapped = MagicMock() + mapped.model_dump.return_value = {} + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.intelligence.github_link_service.fetch_all_contribution_calendars", + new_callable=AsyncMock, + return_value=(True, []), + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + patch( + "app.services.intelligence.github_link_service.aggregate_skills", + return_value=sentinel_skills, + ), + patch( + "app.services.intelligence.github_link_service.aggregate_intelligence", + return_value=MagicMock(), + ) as mock_aggregate, + # 永続化はセンチネル(非 DetectedSkill)なので no-op 化して配線だけ検証する + patch("app.services.intelligence.github_link_service.GitHubSkillRepository"), + patch( + "app.services.intelligence.github_link_service.map_pipeline_result", + return_value=mapped, + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + mock_aggregate.assert_called_once() + # 第3引数(detected_skills)に aggregate_skills の戻り値がそのまま渡ること + assert mock_aggregate.call_args.args[2] is sentinel_skills + def test_status_transitions_to_completed(self, db_session: Session, session_factory): """正常系: status が completed に遷移すること。""" user, cache = self._make_user_and_cache(db_session) diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index 82865358..261f04ba 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -200,3 +200,4 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **2026-06**: 当初「代替案」で延期していた monorepo サブツリー探索を **D9 として採用**(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all + manifest パス永続化)。3 層モデル・D1〜D8 は不変。当初は別 ADR 案だったが、0016 の核を維持する refine であり 1 箇所の追補に留まるため、本 ADR への統合とした。 - **2026-06**: IaC からのインフラリソース検出(provider+service 粒度・HCL 先行・kind=infra 案・D9 探索流用・D8 同様の human-in-the-loop 正規化)を**将来課題として追記**。決定(D1〜D9)・3 層モデルは不変。 +- **2026-06**: ステータス冒頭の「段階移行」を完了。旧決定論パイプライン(`skill_extractor.py` + `skill_taxonomy/` の自前辞書)を撤去し、live 経路を本基盤(`skills/aggregate_skills`)へ一本化。dashboard の `unique_skills` は検出 Layer 1 のうち**言語スキル(kind=language)の件数**から算出する(package は件数に含めない。API 契約 `GitHubLinkResponse` は不変)。 diff --git a/web/src/components/github-link/GitHubLinkDashboard.tsx b/web/src/components/github-link/GitHubLinkDashboard.tsx index 90cec692..89111452 100644 --- a/web/src/components/github-link/GitHubLinkDashboard.tsx +++ b/web/src/components/github-link/GitHubLinkDashboard.tsx @@ -148,7 +148,7 @@ export function GitHubLinkDashboard() {
{result.unique_skills}
-
スキル
+
言語