diff --git a/backend/alembic_migrations/versions/0048_add_github_skill_display_decision.py b/backend/alembic_migrations/versions/0048_add_github_skill_display_decision.py new file mode 100644 index 00000000..6bb145ef --- /dev/null +++ b/backend/alembic_migrations/versions/0048_add_github_skill_display_decision.py @@ -0,0 +1,72 @@ +"""スキル表示名の human-in-the-loop 確定テーブルを追加する(ADR-0016 D11) + +- github_skill_display_decisions: Layer 3 / agent 提案 → 人間確定の表示名・畳み込みグループ + +新規テーブル作成のみ(op.create_table)で、既存テーブルの再作成は伴わない。 +Layer 1-2(github_skills / github_skill_evidence)は連携再実行で洗い替えされるが、 +本テーブルは安定 identity(kind + ecosystem + canonical_name)をキーに github_skills から +切り離して持つため洗い替えの影響を受けない。FK は users を親に CASCADE 削除。 + +Revision ID: 0048_add_github_skill_display_decision +Revises: 0047_add_resume_draft_cache_table +Create Date: 2026-07-12 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0048_add_github_skill_display_decision" +down_revision: Union[str, None] = "0047_add_resume_draft_cache_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "github_skill_display_decisions", + 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("ecosystem", sa.String(length=20), nullable=False, server_default=""), + sa.Column("canonical_name", sa.String(length=255), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("group_id", sa.String(length=36), nullable=True), + sa.Column("source", sa.String(length=20), nullable=False, server_default="human"), + sa.Column("reviewed", sa.Boolean(), nullable=False, server_default="1"), + 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_skill_display_decision_identity", + ), + ) + op.create_index( + "ix_github_skill_display_decisions_user_id", + "github_skill_display_decisions", + ["user_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_github_skill_display_decisions_user_id", + table_name="github_skill_display_decisions", + ) + op.drop_table("github_skill_display_decisions") diff --git a/backend/app/messages.json b/backend/app/messages.json index cc1a3e74..34f00fa2 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -76,7 +76,9 @@ "draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。", "draft_no_repositories": "分析対象の公開リポジトリが見つかりませんでした。経歴書ドラフトの生成には公開リポジトリが必要です。", "draft_pdf_failed": "経歴書ドラフトの PDF 生成に失敗しました。もう一度お試しください。", - "draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。" + "draft_not_ready": "経歴書ドラフトの生成が完了していません。生成を実行してからダウンロードしてください。", + "skill_display_no_skills": "表示名を提案できるスキルがありません。先に GitHub 連携を実行してください。", + "skill_display_invalid_identity": "確定対象に連携結果に存在しないスキルが含まれています。" }, "billing": { "insufficient_credits": "クレジット残高が不足しています。Haiku(無料)に切り替えるか、クレジットを追加してください。", diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a47831a1..4d26754d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,7 +16,12 @@ ResumeProjectTechnologyStack, ResumeQualification, ) -from .skill import GitHubSkill, GitHubSkillEvidence, GitHubSkillProficiency +from .skill import ( + GitHubSkill, + GitHubSkillDisplayDecision, + GitHubSkillEvidence, + GitHubSkillProficiency, +) from .user import User __all__ = [ @@ -27,6 +32,7 @@ "CreditTransaction", "GitHubLinkCache", "GitHubSkill", + "GitHubSkillDisplayDecision", "GitHubSkillEvidence", "GitHubSkillProficiency", "MQualification", diff --git a/backend/app/models/skill.py b/backend/app/models/skill.py index 4f43f68f..edd10753 100644 --- a/backend/app/models/skill.py +++ b/backend/app/models/skill.py @@ -137,6 +137,62 @@ class GitHubSkillEvidence(Base): skill: Mapped["GitHubSkill"] = relationship(back_populates="evidence") +class GitHubSkillDisplayDecision(Base): + """Layer 3: 表示名・粒度畳み込みの人間確定(ADR-0016 D11)。 + + agent が提案し人間が確定した「表示名」と「畳み込みグループ」を保持する。 + Layer 1-2(``github_skills`` / ``github_skill_evidence``)は連携再実行のたびに + ``replace_for_user`` で洗い替え(全削除→再挿入)されるため、確定値をそこに置くと + 再連携で消える。本テーブルは **安定 identity**(``kind`` + ``ecosystem`` + + ``canonical_name``)をキーに ``github_skills`` から切り離して持ち、洗い替えに耐える。 + + ``group_id`` が同じ複数行は 1 スキルへ畳んで表示する(N:1 グルーピング)。 + ``group_id`` が NULL の行は 1:1 の表示名確定(単独スキルのリネーム)。 + ``display_name`` は当該 canonical の確定表示名(グループなら共通のグループ表示名)。 + """ + + __tablename__ = "github_skill_display_decisions" + __table_args__ = ( + UniqueConstraint( + "user_id", "kind", "ecosystem", "canonical_name", + name="uq_github_skill_display_decision_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 + ) + # 対象スキルの identity(github_skills の同名カラムと突き合わせる) + kind: Mapped[str] = mapped_column(String(20), nullable=False) + ecosystem: Mapped[str] = mapped_column( + String(20), nullable=False, default="", server_default="" + ) + canonical_name: Mapped[str] = mapped_column(String(255), nullable=False) + # 確定した表示名(グループの場合は共通のグループ表示名) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + # 畳み込みグループ ID。同一 group_id は 1 スキルへ畳む。NULL は 1:1 の単独確定 + group_id: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None) + # 出所: "agent"(提案そのまま採用)/ "human"(人間が編集) + source: Mapped[str] = mapped_column(String(20), nullable=False, default="human") + # 人間レビュー済みか(確定フローを通ったら True) + reviewed: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="1" + ) + 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, + ) + + class GitHubSkillProficiency(Base): """Layer 3: 習熟度・文脈(人間/agent が後追いで埋める / D1)。 diff --git a/backend/app/prompts/agent_skill_display.md b/backend/app/prompts/agent_skill_display.md new file mode 100644 index 00000000..f088a664 --- /dev/null +++ b/backend/app/prompts/agent_skill_display.md @@ -0,0 +1,23 @@ +あなたは GitHub から検出された技術スキルの「表示名」を整える支援アシスタントです。 +機械的に検出された生のスキル名(package ID・言語名・IaC のリソース型)を、職務経歴書やスキル一覧に載せて読みやすい表示名へ整え、必要なら関連するものを 1 つの表示スキルへ畳み込んでください。 +出力の構造・畳み込めるスキルの集合(token)・表示名の文字数上限はスキーマで定義されているため、ここでは判断の品質に集中すること。 + +# 共通ルール(最優先) +- 「# スキル一覧」に含まれない技術・token を新たに作らない(捏造禁止)。members は必ず与えられた token だけを使う +- 与えられていないスキルを勝手に足したり、存在しない上位概念を作らない +- 表示名は一般に通用する正式名称を使う(例: `aws_s3_bucket` →「Amazon S3」、`@aws-sdk/client-eventbridge` →「Amazon EventBridge」、`react` →「React」) +- 確信が持てないものは無理に整えず、生の名前をそのまま表示名にして単独グループにする(誤った畳み込みより素の名前の方が良い) + +# 畳み込み(グルーピング)の品質基準 +- **保守的に畳む**: 明らかに同一の製品・SDK ファミリ・同一技術の別パッケージだけを 1 グループにする + - 良い例: `@aws-sdk/client-s3` と `@aws-sdk/client-eventbridge` は別サービスなので **別グループ**(S3 / EventBridge)。ただし提供元が同じでも別サービスなら畳まない + - 良い例: `pytest` と `pytest-asyncio` のように同一ツールの拡張は「pytest」へ畳んでよい + - 悪い例: 「Web フレームワーク」のような広い括りで無関係な複数技術をまとめる(粒度を潰しすぎる) +- **1 スキルは 1 グループにのみ**属させる(同じ token を複数グループに入れない) +- 畳む必要がないスキルも、表示名を整えて **メンバー 1 件の単独グループ**として返す +- 与えられた全スキルについて、いずれかのグループに 1 回ずつ含める + +# 思考ステップ(内部分析。出力には含めない) +1. スキル一覧を kind(language / package / infra)とエコシステムで俯瞰する +2. 同一製品・同一 SDK ファミリ・同一ツールの拡張だけを畳み込み候補として拾う(迷ったら畳まない) +3. 各グループに一般に通用する正式名称の表示名を付ける(不確実なら生の名前のまま) diff --git a/backend/app/repositories/skill.py b/backend/app/repositories/skill.py index e922734d..2ea09030 100644 --- a/backend/app/repositories/skill.py +++ b/backend/app/repositories/skill.py @@ -4,13 +4,30 @@ 本フェーズでは投入しないが、CASCADE 削除の対象になる点に留意(保全は後続課題)。 """ +from dataclasses import dataclass + from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from ..models import GitHubSkill, GitHubSkillEvidence +from ..models import GitHubSkill, GitHubSkillDisplayDecision, GitHubSkillEvidence from ..services.intelligence.skills import DetectedSkill +@dataclass(frozen=True) +class DisplayDecisionInput: + """1 スキルの表示名確定入力(identity + 確定表示名 + グループ / ADR-0016 D11)。 + + ``group_id`` が同じ複数入力は 1 スキルへ畳む(N:1)。NULL は 1:1 の単独確定。 + """ + + kind: str + ecosystem: str + canonical_name: str + display_name: str + group_id: str | None = None + source: str = "human" + + class GitHubSkillRepository: """ユーザーの GitHub 連携スキルの読み書き。""" @@ -72,3 +89,55 @@ def replace_for_user(self, detected: list[DetectedSkill]) -> None: self.db.add(skill) self.db.commit() + + +class GitHubSkillDisplayDecisionRepository: + """スキル表示名の human-in-the-loop 確定(Layer 3)の読み書き(ADR-0016 D11)。 + + ``github_skills`` とは独立し、安定 identity(kind + ecosystem + canonical_name)を + キーに確定表示名・畳み込みグループを持つ。連携再実行の洗い替え(``replace_for_user``) + の影響を受けないため、一度確定した表示名は再連携後も残る。 + """ + + def __init__(self, db: Session, user_id: str): + self.db = db + self.user_id = user_id + + def get_for_user(self) -> list[GitHubSkillDisplayDecision]: + """ユーザーの確定済み表示名を全件取得する。""" + statement = select(GitHubSkillDisplayDecision).where( + GitHubSkillDisplayDecision.user_id == self.user_id + ) + return list(self.db.scalars(statement).all()) + + def upsert_many(self, decisions: list[DisplayDecisionInput]) -> None: + """確定表示名を identity 単位で upsert する(既存は上書き、無ければ挿入)。 + + 同一 identity(kind + ecosystem + canonical_name)が既にあれば表示名・グループ・ + 出所を更新し、無ければ新規挿入する。バッチ確定(web のレビュー→確定)で使う。 + """ + existing = { + (d.kind, d.ecosystem, d.canonical_name): d for d in self.get_for_user() + } + for item in decisions: + key = (item.kind, item.ecosystem, item.canonical_name) + current = existing.get(key) + if current is not None: + current.display_name = item.display_name + current.group_id = item.group_id + current.source = item.source + current.reviewed = True + else: + self.db.add( + GitHubSkillDisplayDecision( + user_id=self.user_id, + kind=item.kind, + ecosystem=item.ecosystem, + canonical_name=item.canonical_name, + display_name=item.display_name, + group_id=item.group_id, + source=item.source, + reviewed=True, + ) + ) + self.db.commit() diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index 5126fdbd..73427d13 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -47,16 +47,8 @@ def _record_usage_after_llm( db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None ) -> None: - """LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。 - - LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL - (Hrana over HTTP)のストリームが idle timeout で失効する。失効したまま commit - すると `STREAM_EXPIRED` で 400 → 500 になり、課金記録も落ちる。`db.close()` で - 失効ストリームを解放しておけば、record_chat_usage 内の次の SELECT/commit が - 新しいコネクション(=新規 Hrana ストリーム)を取得して正常に確定できる。 - """ - db.close() - credit_service.record_chat_usage(db, user_id, usage, description=description) + """LLM 応答後のクレジット消費・使用ログ記録(billing の共通後処理へ委譲)。""" + credit_service.record_usage_after_llm(db, user_id, usage, description=description) @router.post("/chat", response_model=AgentChatResponse) diff --git a/backend/app/routers/github_link/_responses.py b/backend/app/routers/github_link/_responses.py index 9d5165cf..a846b35c 100644 --- a/backend/app/routers/github_link/_responses.py +++ b/backend/app/routers/github_link/_responses.py @@ -4,6 +4,7 @@ (.claude/rules/common/duplication.md の Backend ヒエラルキー「routers//_responses.py」)。 """ +from ...models import GitHubSkillDisplayDecision from ...schemas.github_skill import ( GitHubSkillItem, SkillEvidence, @@ -11,8 +12,14 @@ ) -def to_skill_item(skill) -> GitHubSkillItem: - """ORM の GitHubSkill を API スキーマへ変換する。""" +def to_skill_item( + skill, decision: GitHubSkillDisplayDecision | None = None +) -> GitHubSkillItem: + """ORM の GitHubSkill を API スキーマへ変換する。 + + ``decision`` は同一 identity の human-in-the-loop 確定(D11)。あれば確定表示名・ + グループ・出所を載せる(serve 時の解決順は web が「確定 > 機械 display_name > canonical」で行う)。 + """ proficiency = None if skill.proficiency is not None: proficiency = SkillProficiency( @@ -30,6 +37,10 @@ def to_skill_item(skill) -> GitHubSkillItem: ecosystem=skill.ecosystem or None, parent=skill.parent, display_name=skill.display_name, + confirmed_display_name=decision.display_name if decision else None, + group_id=decision.group_id if decision else None, + decision_source=decision.source if decision else None, + decision_reviewed=decision.reviewed if decision else False, evidence=[ SkillEvidence( repo_full_name=ev.repo_full_name, diff --git a/backend/app/routers/github_link/endpoints.py b/backend/app/routers/github_link/endpoints.py index 376bd74c..639cb53c 100644 --- a/backend/app/routers/github_link/endpoints.py +++ b/backend/app/routers/github_link/endpoints.py @@ -18,16 +18,37 @@ from ...db import get_db from ...models import User from ...repositories.github_link import GitHubLinkCacheRepository -from ...repositories.skill import GitHubSkillRepository +from ...repositories.skill import ( + DisplayDecisionInput, + GitHubSkillDisplayDecisionRepository, + GitHubSkillRepository, +) from ...schemas.github_link import ( CachedGitHubLinkResponse, GitHubLinkRequest, GitHubLinkResponse, ProgressResponse, ) -from ...schemas.github_skill import GitHubSkillsResponse +from ...schemas.github_skill import ( + GitHubSkillsResponse, + SkillDisplayConfirmRequest, + SkillDisplayProposedGroup, + SkillDisplayProposeRequest, + SkillDisplayProposeResponse, + SkillIdentityRef, +) from ...schemas.shared import TaskAcceptedResponse, TaskStatusResponse +from ...services.agent.chat_service import AgentResponseParseError +from ...services.agent.llm.base import LLMError +from ...services.agent.skill_display import ( + MAX_SKILLS_PER_PROPOSAL, + SkillForProposal, + propose_skill_display_names, +) +from ...services.billing import credit_service +from ...services.billing.credit_service import InsufficientCreditsError from ...services.intelligence.github_link_service import get_or_create_github_link_cache +from ...services.intelligence.skills.types import SKILL_KIND_LANGUAGE from ...services.tasks import AsyncTaskCacheService, TaskType from ._responses import to_skill_item @@ -83,6 +104,25 @@ async def get_link_progress( return ProgressResponse(**data) +def _build_skills_response(db: Session, user_id: str) -> GitHubSkillsResponse: + """スキル(Layer 1-2)に表示名確定(Layer 3 / D11)を突き合わせてレスポンスを組む。 + + 確定は安定 identity(kind + ecosystem + canonical_name)で紐づける。連携の洗い替えで + スキルが消えれば当該確定はレスポンスに現れないが、DB からは消えないため再連携で復活する。 + """ + skills = GitHubSkillRepository(db, user_id).list_for_user() + decisions = { + (d.kind, d.ecosystem, d.canonical_name): d + for d in GitHubSkillDisplayDecisionRepository(db, user_id).get_for_user() + } + return GitHubSkillsResponse( + skills=[ + to_skill_item(s, decisions.get((s.kind, s.ecosystem, s.canonical_name))) + for s in skills + ] + ) + + @router.get("/skills", response_model=GitHubSkillsResponse) def get_skills( user: User = Depends(get_current_user), @@ -90,10 +130,146 @@ def get_skills( ): """GitHub 連携で推論した 3 層スキル(ADR-0016)を取得する。 - 連携がまだ実行されていない場合は空配列を返す。 + 表示名の human-in-the-loop 確定(D11)があれば ``confirmed_display_name`` / ``group_id`` + として載せる。連携がまだ実行されていない場合は空配列を返す。 + """ + return _build_skills_response(db, user.id) + + +@router.post( + "/skills/display-names/propose", response_model=SkillDisplayProposeResponse +) +@limiter.limit("5/minute") +async def propose_skill_display_names_endpoint( + request: Request, + body: SkillDisplayProposeRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> SkillDisplayProposeResponse: + """検出済みスキルの表示名・畳み込みグループを agent に提案させる(ADR-0016 D11)。 + + agent は提案するだけで確定・DB 更新はしない(D8 / P4)。提案結果はレスポンスとして返し、 + ユーザーがレビュー・編集して ``PUT /skills/display-decisions`` で確定する。 + 外部 LLM を呼ぶ高コスト endpoint のため rate limit を付与し、課金はチャットと同一契約。 """ + # 有料モデルは LLM を呼ぶ前に残高をチェックする(事後減算 / ADR-0012) + try: + credit_service.ensure_can_use_model(db, user.id, body.model) + except InsufficientCreditsError: + raise_app_error( + status_code=402, + code=ErrorCode.INSUFFICIENT_CREDITS, + message=get_error("billing.insufficient_credits"), + ) + skills = GitHubSkillRepository(db, user.id).list_for_user() - return GitHubSkillsResponse(skills=[to_skill_item(s) for s in skills]) + # language は Linguist の canonical / 表示補正で十分に読めるため提案対象外とする。 + # 畳み込み・表示名整形の価値は package(例: @aws-sdk/*)と infra(例: aws_s3_bucket)に + # 集中しており、language を混ぜると入力が肥大してモデルが構造化出力を壊しやすい(ADR-0016 D11)。 + candidates = [s for s in skills if s.kind != SKILL_KIND_LANGUAGE] + if not candidates: + raise_app_error( + status_code=404, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.skill_display_no_skills"), + action="サイドバーの「GitHub連携」から連携を実行してください", + ) + # 送信トークン・enum サイズを抑えるため evidence の多い順に上限で絞る(ロングテールは対象外) + sorted_skills = sorted(candidates, key=lambda s: len(s.evidence), reverse=True) + proposal_inputs = [ + SkillForProposal( + kind=s.kind, + ecosystem=s.ecosystem, + canonical_name=s.canonical_name, + machine_display_name=s.display_name, + parent=s.parent, + ) + for s in sorted_skills[:MAX_SKILLS_PER_PROPOSAL] + ] + + try: + result = await propose_skill_display_names(body.model, proposal_inputs) + except LLMError as exc: + # 消費済みトークンがあれば課金してから 502(課金漏れ防止 / ADR-0012) + if exc.usage is not None: + try: + credit_service.record_usage_after_llm(db, user.id, exc.usage) + except Exception: + logger.error("表示名提案の LLM 失敗時のクレジット記録に失敗", exc_info=True) + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_LLM_ERROR, + message=get_error("agent.llm_failed"), + ) + except AgentResponseParseError as exc: + if exc.usage is not None: + try: + credit_service.record_usage_after_llm(db, user.id, exc.usage) + except Exception: + logger.error("表示名提案のパース失敗時のクレジット記録に失敗", exc_info=True) + raise_app_error( + status_code=502, + code=ErrorCode.AGENT_PARSE_ERROR, + message=get_error("agent.parse_failed"), + ) + + credit_service.record_usage_after_llm( + db, user.id, result.usage, description=f"スキル表示名提案({body.model})" + ) + return SkillDisplayProposeResponse( + groups=[ + SkillDisplayProposedGroup( + display_name=group.display_name, + members=[ + SkillIdentityRef( + kind=m.kind, + ecosystem=m.ecosystem, + canonical_name=m.canonical_name, + ) + for m in group.members + ], + ) + for group in result.groups + ] + ) + + +@router.put("/skills/display-decisions", response_model=GitHubSkillsResponse) +def confirm_skill_display_decisions( + body: SkillDisplayConfirmRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> GitHubSkillsResponse: + """レビュー済みの表示名・畳み込みを確定・永続化する(ADR-0016 D11)。 + + 確定対象の identity は当該ユーザーの検出済みスキルに属していなければならない + (他者・非実在 identity の混入を拒否)。確定は独立 Layer 3 テーブルへ upsert され、 + 連携の洗い替えに耐える。確定後の最新スキル一覧を返す。 + """ + skills = GitHubSkillRepository(db, user.id).list_for_user() + valid_identities = {(s.kind, s.ecosystem, s.canonical_name) for s in skills} + for decision in body.decisions: + if (decision.kind, decision.ecosystem, decision.canonical_name) not in valid_identities: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.skill_display_invalid_identity"), + ) + + GitHubSkillDisplayDecisionRepository(db, user.id).upsert_many( + [ + DisplayDecisionInput( + kind=d.kind, + ecosystem=d.ecosystem, + canonical_name=d.canonical_name, + display_name=d.display_name, + group_id=d.group_id, + source=d.source, + ) + for d in body.decisions + ] + ) + return _build_skills_response(db, user.id) @router.get("/cache/status", response_model=TaskStatusResponse) diff --git a/backend/app/schemas/github_skill.py b/backend/app/schemas/github_skill.py index 3797645d..ed879db7 100644 --- a/backend/app/schemas/github_skill.py +++ b/backend/app/schemas/github_skill.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field +from .agent import AgentModelAlias + class SkillEvidence(BaseModel): """Layer 2: 技術×リポの根拠。""" @@ -65,7 +67,21 @@ class GitHubSkillItem(BaseModel): ) parent: Optional[str] = Field(default=None, description="親(Linguist の group)") display_name: Optional[str] = Field( - default=None, description="表示名(粒度畳みの確定値。未確定は null)" + default=None, description="機械(Linguist)由来の表示補正。未補正は null" + ) + # D11: human-in-the-loop の確定表示名・畳み込みグループ(github_skill_display_decisions 由来) + confirmed_display_name: Optional[str] = Field( + default=None, description="人間が確定した表示名(未確定は null / D11)" + ) + group_id: Optional[str] = Field( + default=None, + description="畳み込みグループ ID。同一 group_id のスキルは 1 表示へ畳む(D11)", + ) + decision_source: Optional[str] = Field( + default=None, description="確定の出所(agent / human。未確定は null / D11)" + ) + decision_reviewed: bool = Field( + default=False, description="人間レビュー済みか(D11)" ) evidence: List[SkillEvidence] = Field(default_factory=list) proficiency: Optional[SkillProficiency] = Field(default=None) @@ -75,3 +91,55 @@ class GitHubSkillsResponse(BaseModel): """ユーザーの GitHub 連携スキル一覧(3 層)。""" skills: List[GitHubSkillItem] = Field(default_factory=list) + + +class SkillDisplayProposeRequest(BaseModel): + """表示名提案(agent)のリクエスト(ADR-0016 D11)。 + + 提案対象スキルはサーバーが連携結果から決めるため、クライアントは使用モデルのみ指定する。 + """ + + # 使用モデル。既定は無料枠の haiku(課金契約はチャットと共通 / ADR-0012) + model: AgentModelAlias = "haiku" + + +class SkillIdentityRef(BaseModel): + """スキルの安定 identity(github_skills と一致 / D11)。""" + + kind: str = Field(description="スキル種別(language / package / infra)") + ecosystem: str = Field(default="", description="エコシステム(language は空文字)") + canonical_name: str = Field(description="正規名(package ID / 言語名 / raw resource type)") + + +class SkillDisplayProposedGroup(BaseModel): + """agent が提案した 1 表示スキル(表示名 + 畳むメンバー群 / D11)。""" + + display_name: str = Field(description="提案する表示名") + members: List[SkillIdentityRef] = Field( + default_factory=list, description="このグループに畳むスキルの identity" + ) + + +class SkillDisplayProposeResponse(BaseModel): + """表示名提案の結果(永続化されない。人間がレビュー・確定する / D11)。""" + + groups: List[SkillDisplayProposedGroup] = Field(default_factory=list) + + +class SkillDisplayDecisionInput(BaseModel): + """人間が確定する 1 スキルの表示名(identity + 確定表示名 + グループ / D11)。""" + + kind: str = Field(description="スキル種別") + ecosystem: str = Field(default="", description="エコシステム(language は空文字)") + canonical_name: str = Field(description="正規名") + display_name: str = Field(min_length=1, max_length=255, description="確定した表示名") + group_id: Optional[str] = Field( + default=None, description="畳み込みグループ ID(単独確定は null)" + ) + source: str = Field(default="human", description="出所(agent / human)") + + +class SkillDisplayConfirmRequest(BaseModel): + """表示名確定(人間)のバッチリクエスト(ADR-0016 D11)。""" + + decisions: List[SkillDisplayDecisionInput] = Field(default_factory=list) diff --git a/backend/app/services/agent/skill_display/__init__.py b/backend/app/services/agent/skill_display/__init__.py new file mode 100644 index 00000000..9de0f2e7 --- /dev/null +++ b/backend/app/services/agent/skill_display/__init__.py @@ -0,0 +1,24 @@ +"""スキル表示名の human-in-the-loop 畳み込み提案(ADR-0016 D11)。 + +agent は実在スキル群から表示名・畳み込みグループを **提案するだけ**(D8 / P4)で、 +確定・永続化は人間 → repository が担う。Agent の不変条件(制約の責務分離・リトライ 1 回・ +エラー契約・DB 非更新)を継承する。 +""" + +from .proposer import ( + MAX_SKILLS_PER_PROPOSAL, + ProposedGroup, + SkillDisplayProposalResult, + SkillForProposal, + SkillIdentity, + propose_skill_display_names, +) + +__all__ = [ + "MAX_SKILLS_PER_PROPOSAL", + "ProposedGroup", + "SkillDisplayProposalResult", + "SkillForProposal", + "SkillIdentity", + "propose_skill_display_names", +] diff --git a/backend/app/services/agent/skill_display/output_schema.py b/backend/app/services/agent/skill_display/output_schema.py new file mode 100644 index 00000000..6296cd35 --- /dev/null +++ b/backend/app/services/agent/skill_display/output_schema.py @@ -0,0 +1,50 @@ +"""スキル表示名提案の LLM 構造化出力スキーマ(機械制約の正本 / ADR-0016 D11)。 + +チャット・ドラフトのスキーマと同じ責務分離に従う: +機械検証可能な制約(グループ構造・表示名の文字数上限・メンバーの許可集合)はここに置き、 +品質制約(畳み込みの粒度・保守的判断・捏造禁止)は ``prompts/agent_skill_display.md`` に置く。 + +ドラフト(``../resume_draft/output_schema.py``)と同様、メンバーを実在スキルの **token 集合** +の enum で縛るためスキーマはリクエストごとに動的構築する(存在しないスキルへの言及を +構造的に排除する)。プロンプト md は静的を維持し、動的情報は user メッセージの JSON に載せる。 +""" + +# 表示名の文字数上限(DB は 255 だが、スキル表示名は短いラベルなので実用上の上限を絞る)。 +# 上限の実強制は proposer のパース側が担う(maxLength は API では強制されないため / 二重防衛)。 +MAX_DISPLAY_NAME_LENGTH = 80 + + +def build_skill_display_output_schema(member_tokens: list[str]) -> dict: + """実在スキルの token 集合から、表示名提案の出力 JSON Schema を構築する。 + + 各グループは「表示名 + メンバー token 群」。メンバーは ``member_tokens`` の enum に + 縛られ、存在しないスキルを指せない。単独スキルのリネームはメンバー 1 件のグループで表す。 + """ + return { + "type": "object", + "properties": { + "groups": { + "type": "array", + "description": "表示名の確定候補。1 グループ = 1 表示スキル(複数メンバーは畳み込み)", + "items": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "maxLength": MAX_DISPLAY_NAME_LENGTH, + "description": "人間が読みやすい表示名(例: 「Amazon S3」)", + }, + "members": { + "type": "array", + "description": "このグループに畳むスキルの token(与えた token のみ)", + "items": {"type": "string", "enum": member_tokens}, + }, + }, + "required": ["display_name", "members"], + "additionalProperties": False, + }, + }, + }, + "required": ["groups"], + "additionalProperties": False, + } diff --git a/backend/app/services/agent/skill_display/proposer.py b/backend/app/services/agent/skill_display/proposer.py new file mode 100644 index 00000000..ea3ab009 --- /dev/null +++ b/backend/app/services/agent/skill_display/proposer.py @@ -0,0 +1,251 @@ +"""スキル表示名の提案ロジック(実在スキル → LLM → 提案グループ / ADR-0016 D11)。 + +agent は表示名・畳み込みグループを **提案するだけ**で、確定・永続化はしない(D8 / P4)。 +本モジュールは DB に触れない(DB 読み取りは router → repository 経由)。LLM 呼び出しの +失敗契約(LLMError / AgentResponseParseError に usage を載せ課金漏れを防ぐ・リトライ 1 回)は +チャット / ドラフトと同一。入出力(スキル一覧 / グループ提案)が異なるため共通化しない +(Rule of Three / .claude/rules/common/duplication.md)。 +""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from pydantic import BaseModel, Field, ValidationError + +from ....schemas.agent import AgentModelAlias +from ..chat_service import AgentResponseParseError, AgentUsage +from ..llm.base import LLMError +from ..llm.factory import get_llm_client +from ..model_catalog import get_model_spec +from .output_schema import MAX_DISPLAY_NAME_LENGTH, build_skill_display_output_schema + +logger = logging.getLogger(__name__) + +# システムプロンプトの正本は app/prompts/(チャット / ドラフトと同じ分離)。動的情報 +# (スキル一覧・許可 token)は user メッセージの JSON とスキーマの enum に載せ、md は静的に保つ +_PROMPTS_DIR = Path(__file__).resolve().parents[3] / "prompts" +_SYSTEM_PROMPT = (_PROMPTS_DIR / "agent_skill_display.md").read_text(encoding="utf-8") + +# リトライ時に LLM へフィードバックするエラー文の上限(chat_service / draft_service と同じ趣旨) +_MAX_RETRY_ERROR_LENGTH = 500 + +# 1 回の提案で LLM に渡すスキルの上限(送信トークン・構造化出力 enum サイズを抑える)。 +# 大量スキル(実測 180 件超)を 1 ショットで投げると入力が肥大し、モデルが巨大 enum の +# 構造化 JSON を壊しやすい。evidence の多い順で切り、ロングテールは提案対象外とする +# (呼び出し側で language 除外・並べ替えをしてから渡す想定)。 +MAX_SKILLS_PER_PROPOSAL = 50 + + +@dataclass(frozen=True) +class SkillIdentity: + """提案対象・確定のキーになるスキルの安定 identity(github_skills と一致)。""" + + kind: str + ecosystem: str + canonical_name: str + + +@dataclass(frozen=True) +class SkillForProposal: + """提案に渡す 1 スキル(identity + 機械が持つ表示ヒント)。""" + + kind: str + ecosystem: str + canonical_name: str + # 機械(Linguist)由来の表示補正。無ければ None(package/infra は基本 None) + machine_display_name: str | None = None + parent: str | None = None + + @property + def identity(self) -> SkillIdentity: + return SkillIdentity(self.kind, self.ecosystem, self.canonical_name) + + +@dataclass(frozen=True) +class ProposedGroup: + """LLM が提案した 1 表示スキル(表示名 + 畳むメンバー群)。""" + + display_name: str + members: list[SkillIdentity] + + +@dataclass(frozen=True) +class SkillDisplayProposalResult: + """propose_skill_display_names の戻り値(提案 + 課金用の使用量)。""" + + groups: list[ProposedGroup] + usage: AgentUsage + + +class _Group(BaseModel): + """LLM 出力の 1 グループ(構造の二重防衛)。""" + + display_name: str = Field(min_length=1) + members: list[str] = Field(default_factory=list) + + +class _ProposalOutput(BaseModel): + """LLM 出力全体の検証用モデル。""" + + groups: list[_Group] = Field(default_factory=list) + + +def _token(skill: SkillForProposal) -> str: + """スキル identity を LLM が参照する一意 token に符号化する。 + + ``(kind, ecosystem, canonical_name)`` は一意なので、ecosystem(無ければ kind)を + 接頭辞にした ``prefix:canonical`` で衝突しない読みやすい token になる。 + """ + prefix = skill.ecosystem or skill.kind + return f"{prefix}:{skill.canonical_name}" + + +def _build_context(tokened_skills: list[tuple[str, SkillForProposal]]) -> str: + """LLM に渡すスキル一覧の JSON コンテキストを組み立てる。 + + 各スキルの token・種別・現在の表示(機械補正 or canonical)を渡す。捏造判定の根拠に + なる「与えた情報」の全量であり、members はこの token 集合からしか選べない。 + """ + entries = [ + { + "token": token, + "kind": skill.kind, + "ecosystem": skill.ecosystem or None, + "name": skill.canonical_name, + "current": skill.machine_display_name or skill.canonical_name, + } + for token, skill in tokened_skills + ] + return json.dumps({"skills": entries}, ensure_ascii=False) + + +def _parse_proposal( + raw: str, identity_by_token: dict[str, SkillIdentity] +) -> list[ProposedGroup]: + """LLM 応答をパースし、許可外 token・重複メンバー・空グループを破棄して返す。 + + - members の token は許可集合(identity_by_token)に無ければ破棄(捏造排除の二重防衛)。 + - 1 スキルは 1 グループにのみ属する(既に別グループへ割当済みの token は破棄)。 + - display_name の空・上限超過は当該グループを破棄(切り詰めない / ADR-0010 踏襲)。 + - メンバーが 1 件も残らないグループは破棄。 + """ + text = raw.strip() + # Ollama 等 tool use ではないローカル実装のコードフェンス耐性(chat_service と同じ) + if text.startswith("```"): + text = text.strip("`") + text = text.removeprefix("json").strip() + try: + data = json.loads(text) + parsed = _ProposalOutput.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + logger.warning("表示名提案 LLM 応答のパースに失敗: %s", type(exc).__name__) + raise AgentResponseParseError(str(exc)) from exc + + groups: list[ProposedGroup] = [] + assigned: set[str] = set() + for group in parsed.groups: + name = group.display_name.strip() + if not name or len(name) > MAX_DISPLAY_NAME_LENGTH: + logger.warning("不正な表示名のグループを破棄: len=%d", len(name)) + continue + members: list[SkillIdentity] = [] + for token in group.members: + identity = identity_by_token.get(token) + if identity is None: + logger.warning("許可外の member token を破棄: %s", token) + continue + if token in assigned: + logger.warning("重複割当の member token を破棄: %s", token) + continue + assigned.add(token) + members.append(identity) + if not members: + logger.warning("メンバーが残らないグループを破棄: %s", name) + continue + groups.append(ProposedGroup(display_name=name, members=members)) + return groups + + +async def propose_skill_display_names( + model: AgentModelAlias, skills: list[SkillForProposal] +) -> SkillDisplayProposalResult: + """スキル一覧から表示名・畳み込みグループの提案を生成し、使用量とともに返す。 + + Args: + model: モデルエイリアス(router のスキーマで検証済み)。 + skills: 提案対象スキル(MAX_SKILLS_PER_PROPOSAL 件まで。超過分は呼び出し側で切る)。 + + Raises: + AgentResponseParseError: LLM 応答が不正(リトライ後も失敗)。 + LLMError: LLM 呼び出しの失敗。 + """ + # token ↔ identity の対応を作る。token は _token で一意(衝突しない / 上記 docstring) + tokened_skills: list[tuple[str, SkillForProposal]] = [(_token(s), s) for s in skills] + identity_by_token: dict[str, SkillIdentity] = { + token: skill.identity for token, skill in tokened_skills + } + + spec = get_model_spec(model) + client = get_llm_client(spec.provider) + output_schema = build_skill_display_output_schema(list(identity_by_token.keys())) + user_prompt = f"# スキル一覧\n{_build_context(tokened_skills)}" + messages: list[dict[str, str]] = [{"role": "user", "content": user_prompt}] + + logger.debug( + "表示名提案 LLM 入力: model=%s skills=%d prompt_len=%d", + model, + len(skills), + len(user_prompt), + ) + + # リトライしても 1 回目の API 原価は発生しているため、使用量は合算で課金する(ADR-0012) + input_tokens = 0 + output_tokens = 0 + + def _usage() -> AgentUsage: + return AgentUsage(model=model, input_tokens=input_tokens, output_tokens=output_tokens) + + async def _generate_and_account(call_messages: list[dict[str, str]], *, label: str): + nonlocal input_tokens, output_tokens + call_result = await client.generate( + _SYSTEM_PROMPT, call_messages, output_schema, spec.model_id + ) + input_tokens += call_result.input_tokens + output_tokens += call_result.output_tokens + logger.debug("表示名提案 LLM %s応答(パース前): len=%d", label, len(call_result.text)) + return call_result + + result = await _generate_and_account(messages, label="生") + try: + groups = _parse_proposal(result.text, identity_by_token) + return SkillDisplayProposalResult(groups=groups, usage=_usage()) + except AgentResponseParseError as exc: + # 出力契約違反は 1 回だけリトライ(違反内容をフィードバックして再生成 / ADR-0010) + logger.warning("表示名提案 LLM 応答が出力契約に違反したためリトライ: %s", type(exc).__name__) + retry_messages = [ + *messages, + {"role": "assistant", "content": result.text}, + { + "role": "user", + "content": ( + "直前の応答は出力契約に違反しています。" + f"違反内容: {str(exc)[:_MAX_RETRY_ERROR_LENGTH]}\n" + "契約に従って同じ依頼への応答を再生成してください。" + ), + }, + ] + + try: + result = await _generate_and_account(retry_messages, label="リトライ") + except LLMError as retry_exc: + # 1 回目の API 原価は発生済み。使用量を載せて router 側で課金を確定させる(ADR-0012) + retry_exc.usage = _usage() + raise + try: + groups = _parse_proposal(result.text, identity_by_token) + except AgentResponseParseError as retry_exc: + # 2 回目も失敗。合算使用量を載せて伝播する(課金漏れ防止 / ADR-0012) + raise AgentResponseParseError(str(retry_exc), usage=_usage()) from retry_exc + return SkillDisplayProposalResult(groups=groups, usage=_usage()) diff --git a/backend/app/services/billing/credit_service.py b/backend/app/services/billing/credit_service.py index d1dcc1e1..59783dfe 100644 --- a/backend/app/services/billing/credit_service.py +++ b/backend/app/services/billing/credit_service.py @@ -81,6 +81,21 @@ def record_chat_usage( return balance_after +def record_usage_after_llm( + db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None +) -> None: + """LLM 応答後のクレジット消費・使用ログ記録を、ストリームを開き直してから行う。 + + LLM 呼び出しの await 中にリクエストの DB セッションがアイドルになり、libSQL + (Hrana over HTTP)のストリームが idle timeout で失効する。失効したまま commit + すると ``STREAM_EXPIRED`` で 400 → 500 になり課金記録も落ちるため、``db.close()`` で + 失効ストリームを解放してから記録する(次の SELECT/commit が新規コネクション=新規 + Hrana ストリームを取得して正常に確定できる)。LLM を await する各 router 共通の後処理。 + """ + db.close() + record_chat_usage(db, user_id, usage, description=description) + + def grant_credits( db: Session, user_id: str, diff --git a/backend/tests/test_skill_display_api.py b/backend/tests/test_skill_display_api.py new file mode 100644 index 00000000..c9dcb1f4 --- /dev/null +++ b/backend/tests/test_skill_display_api.py @@ -0,0 +1,269 @@ +"""スキル表示名の human-in-the-loop 提案・確定 API のテスト(ADR-0016 D11)。 + +DB はモックせず実 SQLite セッションに当てる。LLM のみモックする。propose の 402/502、 +confirm の authz、確定の反映、連携の洗い替えに対する確定の耐性を検証する。 +""" + +import json + +from app.repositories import UserRepository +from app.repositories.skill import ( + DisplayDecisionInput, + GitHubSkillDisplayDecisionRepository, + GitHubSkillRepository, +) +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult +from app.services.agent.skill_display import proposer +from app.services.intelligence.skills import DetectedSkill, EvidenceRecord + +from conftest import auth_header + + +def _user_id(client, username: str) -> str: + user = UserRepository(client._db_session).get_by_username(username) + assert user is not None + return user.id + + +def _detected() -> list[DetectedSkill]: + return [ + DetectedSkill( + kind="package", + canonical_name="@aws-sdk/client-s3", + 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", + ) + ], + ), + 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, + ) + ], + ), + ] + + +class _FakeLLM(LLMClient): + """固定応答を返す LLM クライアント(または例外を送出)。""" + + def __init__(self, response): + self._response = response + + async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult: + if isinstance(self._response, Exception): + raise self._response + return LLMResult(text=self._response, input_tokens=5, output_tokens=7) + + +def _mock_llm(monkeypatch, response) -> None: + monkeypatch.setattr(proposer, "get_llm_client", lambda provider: _FakeLLM(response)) + + +# ---- propose ------------------------------------------------------------- + + +def test_propose_requires_auth(client) -> None: + """未認証は 401。""" + resp = client.post("/api/github-link/skills/display-names/propose", json={}) + assert resp.status_code == 401 + + +def test_propose_404_when_no_skills(client) -> None: + """スキルが無ければ 404(先に連携が必要)。""" + headers = auth_header(client, "disp_none") + resp = client.post( + "/api/github-link/skills/display-names/propose", json={"model": "haiku"}, headers=headers + ) + assert resp.status_code == 404 + + +def test_propose_happy_returns_groups(client, monkeypatch) -> None: + """package/infra に対し agent 提案(グループ + メンバー identity)が返ること。 + + language(Python)は提案対象外に絞られるため、LLM が誤って language token を返しても + 許可集合に無く破棄される(A: language 除外 / ADR-0016 D11)。 + """ + headers = auth_header(client, "disp_happy") + uid = _user_id(client, "disp_happy") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + _mock_llm( + monkeypatch, + json.dumps( + { + "groups": [ + {"display_name": "Amazon S3", "members": ["npm:@aws-sdk/client-s3"]}, + # language は候補から外れており token も許可外。破棄されることを検証する + {"display_name": "Python", "members": ["language:Python"]}, + ] + } + ), + ) + + resp = client.post( + "/api/github-link/skills/display-names/propose", json={"model": "haiku"}, headers=headers + ) + assert resp.status_code == 200 + groups = {g["display_name"]: g for g in resp.json()["groups"]} + # language グループは許可外 token のみで空になり破棄される + assert set(groups) == {"Amazon S3"} + s3_member = groups["Amazon S3"]["members"][0] + assert s3_member["kind"] == "package" + assert s3_member["ecosystem"] == "npm" + assert s3_member["canonical_name"] == "@aws-sdk/client-s3" + + +def test_propose_404_when_only_languages(client) -> None: + """language しか無い場合は提案対象なしで 404(A: language 除外 / D11)。""" + headers = auth_header(client, "disp_langonly") + uid = _user_id(client, "disp_langonly") + # _detected() の language 1 件だけを投入する + GitHubSkillRepository(client._db_session, uid).replace_for_user([_detected()[1]]) + resp = client.post( + "/api/github-link/skills/display-names/propose", json={"model": "haiku"}, headers=headers + ) + assert resp.status_code == 404 + + +def test_propose_402_for_paid_model_without_credits(client) -> None: + """有料モデルは残高 0 だと LLM を呼ぶ前に 402。""" + headers = auth_header(client, "disp_paid") + uid = _user_id(client, "disp_paid") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + resp = client.post( + "/api/github-link/skills/display-names/propose", json={"model": "sonnet"}, headers=headers + ) + assert resp.status_code == 402 + + +def test_propose_502_on_llm_error(client, monkeypatch) -> None: + """LLM 失敗は 502(AGENT_LLM_ERROR)。""" + headers = auth_header(client, "disp_llmerr") + uid = _user_id(client, "disp_llmerr") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + _mock_llm(monkeypatch, LLMError("down")) + + resp = client.post( + "/api/github-link/skills/display-names/propose", json={"model": "haiku"}, headers=headers + ) + assert resp.status_code == 502 + + +# ---- confirm ------------------------------------------------------------- + + +def test_confirm_rejects_unknown_identity(client) -> None: + """当該ユーザーの検出済みスキルに無い identity の確定は 422。""" + headers = auth_header(client, "disp_badid") + uid = _user_id(client, "disp_badid") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + + resp = client.put( + "/api/github-link/skills/display-decisions", + json={ + "decisions": [ + { + "kind": "package", + "ecosystem": "npm", + "canonical_name": "does-not-exist", + "display_name": "偽物", + } + ] + }, + headers=headers, + ) + assert resp.status_code == 422 + + +def test_confirm_persists_and_get_reflects(client) -> None: + """確定した表示名・グループが GET /skills に反映されること。""" + headers = auth_header(client, "disp_confirm") + uid = _user_id(client, "disp_confirm") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + + resp = client.put( + "/api/github-link/skills/display-decisions", + json={ + "decisions": [ + { + "kind": "package", + "ecosystem": "npm", + "canonical_name": "@aws-sdk/client-s3", + "display_name": "Amazon S3", + "group_id": "grp-aws", + "source": "human", + } + ] + }, + headers=headers, + ) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + s3 = by_name["@aws-sdk/client-s3"] + assert s3["confirmed_display_name"] == "Amazon S3" + assert s3["group_id"] == "grp-aws" + assert s3["decision_source"] == "human" + assert s3["decision_reviewed"] is True + # 未確定スキルは確定フィールドが null + assert by_name["Python"]["confirmed_display_name"] is None + + # GET でも同じ確定が返ること + get_resp = client.get("/api/github-link/skills", headers=headers) + got = {s["canonical_name"]: s for s in get_resp.json()["skills"]} + assert got["@aws-sdk/client-s3"]["confirmed_display_name"] == "Amazon S3" + + +def test_confirm_upsert_overwrites_existing(client) -> None: + """同一 identity の再確定は上書き(重複行を作らない)。""" + auth_header(client, "disp_upsert") + uid = _user_id(client, "disp_upsert") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + repo = GitHubSkillDisplayDecisionRepository(client._db_session, uid) + repo.upsert_many( + [DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "旧S3")] + ) + repo.upsert_many( + [DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "Amazon S3")] + ) + + decisions = repo.get_for_user() + assert len(decisions) == 1 + assert decisions[0].display_name == "Amazon S3" + + +def test_decision_survives_relink_wipe(client) -> None: + """連携再実行(Layer 1-2 洗い替え)後も確定表示名が残ること(D11 の核心)。""" + headers = auth_header(client, "disp_relink") + uid = _user_id(client, "disp_relink") + skill_repo = GitHubSkillRepository(client._db_session, uid) + skill_repo.replace_for_user(_detected()) + GitHubSkillDisplayDecisionRepository(client._db_session, uid).upsert_many( + [DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "Amazon S3", "grp-aws")] + ) + + # 再連携相当の洗い替え(同じ identity のスキルを作り直す) + skill_repo.replace_for_user(_detected()) + + resp = client.get("/api/github-link/skills", headers=headers) + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + # スキルは作り直されたが、確定表示名は独立テーブルに残っているので復活する + assert by_name["@aws-sdk/client-s3"]["confirmed_display_name"] == "Amazon S3" + assert by_name["@aws-sdk/client-s3"]["group_id"] == "grp-aws" diff --git a/backend/tests/test_skill_display_service.py b/backend/tests/test_skill_display_service.py new file mode 100644 index 00000000..e0f9f763 --- /dev/null +++ b/backend/tests/test_skill_display_service.py @@ -0,0 +1,197 @@ +"""スキル表示名提案サービス(proposer)の単体テスト(ADR-0016 D11)。 + +LLM のみモックし、パース・捏造メンバー破棄・重複破棄・リトライ・課金用 usage の合算は +実コードを通す。async 実行はグローバル event loop を触らない分離パターンで行う +(mutmut の clean test 対策 / .claude/rules/backend/test.md)。 +""" + +import asyncio +import json + +import pytest +from app.services.agent.chat_service import AgentResponseParseError +from app.services.agent.llm.base import LLMClient, LLMError, LLMResult +from app.services.agent.skill_display import proposer +from app.services.agent.skill_display.output_schema import MAX_DISPLAY_NAME_LENGTH +from app.services.agent.skill_display.proposer import ( + SkillForProposal, + propose_skill_display_names, +) + + +class _SequentialFakeLLM(LLMClient): + """呼び出しごとに応答(または例外)を順に返す LLM クライアント。""" + + def __init__(self, responses: list, input_tokens: int = 10, output_tokens: int = 20): + self._responses = list(responses) + self._input_tokens = input_tokens + self._output_tokens = output_tokens + self.calls: list[list[dict[str, str]]] = [] + self.received_output_schema: dict | None = None + + async def generate(self, system_prompt, messages, output_schema, model_id) -> LLMResult: + self.calls.append(messages) + self.received_output_schema = output_schema + item = self._responses[len(self.calls) - 1] + if isinstance(item, Exception): + raise item + return LLMResult( + text=item, input_tokens=self._input_tokens, output_tokens=self._output_tokens + ) + + +def _mock_llm(monkeypatch, responses: list) -> _SequentialFakeLLM: + fake = _SequentialFakeLLM(responses) + monkeypatch.setattr(proposer, "get_llm_client", lambda provider: fake) + return fake + + +def _run(coro): + """グローバル event loop を汚さずにコルーチンを実行する。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _skills() -> list[SkillForProposal]: + return [ + SkillForProposal(kind="language", ecosystem="", canonical_name="Python"), + SkillForProposal(kind="package", ecosystem="npm", canonical_name="@aws-sdk/client-s3"), + SkillForProposal( + kind="package", ecosystem="npm", canonical_name="@aws-sdk/client-eventbridge" + ), + SkillForProposal(kind="infra", ecosystem="terraform", canonical_name="aws_s3_bucket"), + ] + + +def test_proposal_resolves_members_to_identities(monkeypatch) -> None: + """提案グループの member token が実在スキルの identity へ解決されること。""" + response = json.dumps( + { + "groups": [ + {"display_name": "Amazon S3", "members": ["npm:@aws-sdk/client-s3"]}, + { + "display_name": "Amazon EventBridge", + "members": ["npm:@aws-sdk/client-eventbridge"], + }, + {"display_name": "Python", "members": ["language:Python"]}, + ] + } + ) + _mock_llm(monkeypatch, [response]) + + result = _run(propose_skill_display_names("haiku", _skills())) + + by_name = {g.display_name: g for g in result.groups} + assert set(by_name) == {"Amazon S3", "Amazon EventBridge", "Python"} + s3 = by_name["Amazon S3"].members + assert len(s3) == 1 + assert s3[0].kind == "package" + assert s3[0].ecosystem == "npm" + assert s3[0].canonical_name == "@aws-sdk/client-s3" + # 使用量が載ること(無料/有料に関わらず router が課金判断に使う) + assert result.usage.input_tokens == 10 + assert result.usage.output_tokens == 20 + assert result.usage.model == "haiku" + + +def test_fabricated_member_token_is_dropped(monkeypatch) -> None: + """許可集合に無い token(捏造)は破棄されること(動的 enum の二重防衛)。""" + response = json.dumps( + { + "groups": [ + { + "display_name": "React", + "members": ["npm:react", "npm:@aws-sdk/client-s3"], + } + ] + } + ) + _mock_llm(monkeypatch, [response]) + + result = _run(propose_skill_display_names("haiku", _skills())) + + # npm:react は与えていないので破棄され、実在の client-s3 のみ残る + assert len(result.groups) == 1 + members = result.groups[0].members + assert [m.canonical_name for m in members] == ["@aws-sdk/client-s3"] + + +def test_duplicate_member_assigned_once(monkeypatch) -> None: + """同じ token を複数グループに入れても最初の 1 グループにのみ割り当てられること。""" + response = json.dumps( + { + "groups": [ + {"display_name": "AWS SDK", "members": ["npm:@aws-sdk/client-s3"]}, + {"display_name": "Amazon S3", "members": ["npm:@aws-sdk/client-s3"]}, + ] + } + ) + _mock_llm(monkeypatch, [response]) + + result = _run(propose_skill_display_names("haiku", _skills())) + + # 2 グループ目は重複割当で member が残らず破棄される + assert [g.display_name for g in result.groups] == ["AWS SDK"] + + +def test_empty_and_overlong_groups_dropped(monkeypatch) -> None: + """メンバー 0 件・表示名が上限超過のグループは破棄されること(切り詰めない)。""" + response = json.dumps( + { + "groups": [ + {"display_name": "空グループ", "members": []}, + {"display_name": "X" * (MAX_DISPLAY_NAME_LENGTH + 1), "members": ["language:Python"]}, + {"display_name": "Python", "members": ["language:Python"]}, + ] + } + ) + _mock_llm(monkeypatch, [response]) + + result = _run(propose_skill_display_names("haiku", _skills())) + + # 空グループと上限超過グループは破棄。language:Python は生き残るグループへ 1 回だけ割当 + assert [g.display_name for g in result.groups] == ["Python"] + + +def test_retry_recovers_after_invalid_json(monkeypatch) -> None: + """1 回目が不正 JSON でもリトライで回復し、使用量は 2 回分合算されること。""" + good = json.dumps( + {"groups": [{"display_name": "Python", "members": ["language:Python"]}]} + ) + fake = _mock_llm(monkeypatch, ["not json", good]) + + result = _run(propose_skill_display_names("haiku", _skills())) + + assert len(fake.calls) == 2 + assert [g.display_name for g in result.groups] == ["Python"] + # 1 回目 + 2 回目の API 原価を合算課金(ADR-0012) + assert result.usage.input_tokens == 20 + assert result.usage.output_tokens == 40 + + +def test_retry_failure_propagates_usage(monkeypatch) -> None: + """2 回とも失敗したら合算 usage 付きの AgentResponseParseError を送出すること。""" + fake = _mock_llm(monkeypatch, ["not json", "still not json"]) + + with pytest.raises(AgentResponseParseError) as excinfo: + _run(propose_skill_display_names("haiku", _skills())) + + assert len(fake.calls) == 2 + assert excinfo.value.usage is not None + assert excinfo.value.usage.input_tokens == 20 + assert excinfo.value.usage.output_tokens == 40 + + +def test_llm_error_on_retry_carries_usage(monkeypatch) -> None: + """リトライ呼び出しが LLMError なら 1 回目分の usage を載せて伝播すること。""" + fake = _mock_llm(monkeypatch, ["not json", LLMError("boom")]) + + with pytest.raises(LLMError) as excinfo: + _run(propose_skill_display_names("haiku", _skills())) + + assert len(fake.calls) == 2 + assert excinfo.value.usage is not None + assert excinfo.value.usage.input_tokens == 10 # 1 回目のみ確定 diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index f210962a..cd5091f9 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -119,7 +119,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ### D10. IaC(Terraform)からインフラリソースを検出する(2026-07 改訂で追加) -当初「将来課題」としていた IaC からのインフラリソース検出を、**機械検出(幅)に限定して採用**する。Terraform/HCL は Linguist の*言語*スキル「Terraform」(kind=language)としては捕捉済みだが、「どのクラウドの何のサービスを IaC で構築したか」(provider / service 粒度)が取れていない。この検出軸を Layer 1-2 に足す。表示名の畳み込み(`aws_s3_bucket` → 「Amazon S3」)を伴う human-in-the-loop(D8)は package 層含め未実装のため、本 D10 のスコープからは外し別途とする(canonical は raw type を保持するだけ)。 +当初「将来課題」としていた IaC からのインフラリソース検出を、**機械検出(幅)に限定して採用**する。Terraform/HCL は Linguist の*言語*スキル「Terraform」(kind=language)としては捕捉済みだが、「どのクラウドの何のサービスを IaC で構築したか」(provider / service 粒度)が取れていない。この検出軸を Layer 1-2 に足す。表示名の畳み込み(`aws_s3_bucket` → 「Amazon S3」)を伴う human-in-the-loop(D8)は本 D10 のスコープからは外し別途とした(canonical は raw type を保持するだけ)。**→ その human-in-the-loop 畳み込みは package / infra / language 全 kind を対象に D11 で採用・実装済み。** - **(a) 対象 = Terraform / OpenTofu(`.tf`)**: parser は plugin 型(`InfraParser`)とし、CloudFormation / Pulumi / k8s manifest 等は後追いで差し込める設計にとどめる(v1 は Terraform のみ実装)。 - **(b) 抽出粒度 = provider + service 両方**: `provider ""` / `required_providers` → クラウドプロバイダ、`resource "" ""` → 具体サービス(type 接頭辞で provider を導出。`aws_s3_bucket` → provider `aws`)。**static な `resource` ブロックの type 抽出に限定**し、`module` / `count` / `for_each` / `dynamic` による動的生成は静的列挙できないため対象外(将来課題)。 @@ -133,6 +133,21 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 実装で触った箇所: `skills/types.py`(`SKILL_KIND_INFRA` / `InfraResourceDeclaration`)/ 新規 `skills/infra/`(`InfraParser` Protocol・`terraform.py`・registry)/ `github_collector.py`(`.tf` 探索・`.terraform` 除外・partial 伝播)/ `skills/aggregator.py`(`_collect_infra`・`infra_declared`)/ `github_link_service.py`(`RepoSkillInput` へ伝播)/ `schemas/github_skill.py`(docstring 拡張 + `make codegen-types`)。migration・新規依存はなし。 +### D11. 表示名・粒度の畳み込みを human-in-the-loop で確定する(2026-07 改訂で追加) + +D3・D8 で「表示名・粒度の畳み込み(`@aws-sdk/client-eventbridge` →「Amazon EventBridge」・`aws_s3_bucket` →「Amazon S3」)は文脈依存で機械検証不能なので agent 提案 → 人間確定」と方針だけ定め、実装は未着手だった(D10 に「package 層含め未実装」と明記)。本 D11 で **agent 提案 → 人間確定 → 永続化 → スキルビュー反映**の一連フローを採用する。package / infra / language の全 kind を対象とする。 + +- **(a) 確定値は Layer 1-2 から切り離した独立の Layer 3 テーブルに、安定 identity をキーに保存する**: 新テーブル `github_skill_display_decision`(`user_id` + `kind` + `ecosystem` + `canonical_name` を一意キー)に、確定した `display_name` と畳み込みグループ(`group_id`)を持つ。Layer 1-2(`github_skills` / `github_skill_evidence`)は連携再実行のたびに `replace_for_user` で**洗い替え(全削除→再挿入)**されるため、確定値をそこに置くと再連携で消える。identity キーの独立テーブルにすることで洗い替えに自然に耐え(skill.id ではなく安定 identity で紐づく)、かつ N:1 グルーピングを表現できる。これは D1「機械=幅(Layer 1-2)/ 人間=深さ(Layer 3)」の責務分離に沿う(確定は人間の判断=深さ)。 +- **(b) 既存 `github_skills.display_name` は機械フォールバックとして残す**: このカラムは Linguist 由来の**機械的**表示補正(`HCL` → `Terraform` / `Dockerfile` → `Docker`)を持ち、HITL 確定値ではない(package/infra は常に NULL)。D11 の人間確定は (a) の別テーブルが正本とし、本カラムは解決順の中間フォールバックとして温存する(削除しない)。 +- **(c) 表示名の解決順(serve 時)**: **人間確定(group 表示名 or 1:1 表示名 / Layer 3)> 機械 `display_name`(Linguist / Layer 1)> `canonical_name`**。`GET /skills` が `github_skill_display_decision` を join し、各スキルに確定値と `group_id` を載せる。同一 `group_id` のスキルは 1 スキルへ畳んで表示する(畳み込みは後段ビュー変換 / D8「保持は細かく、畳み込みは後段」を踏襲。Layer 1-2 の生データは畳まず保持)。 +- **(d) agent は提案のみ(D8 / 設計原則 P4 の責務分離を維持)**: 提案エンドポイントが実在スキル群を LLM に渡し、グループ(表示名 + メンバー canonical 群)を提案させる。**メンバーはリクエストごとの動的 enum で実在スキルに制約**し、存在しないスキルの捏造を構造的に排除する(ADR-0018 の `repo_full_name` 動的 enum と同手法)。LLM は確定しない・DB を書かない。提案は永続化せずレスポンスとして返すだけ。 +- **(e) 人間が確定・永続化**: web でレビュー/編集した確定内容をバッチ upsert する確定エンドポイントを設ける。identity が当該ユーザーのスキルに属することを検証し(他者 identity の混入を拒否)、`source`("agent"=提案そのまま / "human"=編集)と `reviewed` を記録する。 +- **(f) 機械制約はスキーマ・品質はプロンプト(ADR-0010 責務分離を厳守)**: 提案の JSON 構造・許可メンバー enum・表示名の文字数上限は構造化出力スキーマ(`output_schema.py`)に、畳み込みの品質基準(同一 SDK / 製品ファミリだけを保守的に畳む・不確実なら raw 維持・技術の捏造禁止)はプロンプト(`agent_skill_display.md`)に置く。提案 LLM は課金・プロバイダ抽象(ADR-0012・0013)を既存のまま流用する。 + +**スコープ外(残課題)**: 確定値のバージョニング / 監査履歴、group をまたぐ evidence の重み再計算、agent による足切り(言語の上位 N 位)提案(D8 で別項)。動的 module 解決・Tier2 IaC は D10 の残課題のまま。 + +実装で触る箇所: 新規 `models/skill.py`(`GitHubSkillDisplayDecision`)+ migration(`op.create_table`)/ `repositories/skill.py`(decision repo・serve join)/ 新規 `services/agent/skill_display/`(`output_schema.py`・`proposer.py`)/ 新規 `prompts/agent_skill_display.md` / `routers/github_link/endpoints.py`(propose / confirm / GET 拡張)/ `schemas/github_skill.py`(新規スキーマ + `GitHubSkillItem` 拡張、`make codegen-types`)/ web(スキル一覧ビュー + 提案レビュー UI 新規)。 + ### この設計で得られるもの - エビデンス系スキルに裏付けが付き、経歴書の GitHub URL との整合(証跡性)が立つ。 @@ -163,14 +178,15 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 - **import 名乖離の補正(#477 / 2026-07)**: 機械変換で当たらない配布名≠import名の乖離(PyYAML→yaml・Pillow→PIL・beautifulsoup4→bs4 等)を、pypi のみ内部マスタ(`skills/resources/pypi_import_aliases.json`)経由で補正する。**D3(辞書を持たない)とのテンション**は `linguist_master.json` と同じモデルで解消: マスタは wheel の `top_level.txt` 由来の機械的事実であり taxonomy 判断を含まない/連携ホットパスで外部を叩かず実行時は読むだけ(D4)/`top_level.txt` からのオフライン再生成を想定した暫定キュレーションで、既知の乖離 package を初期集合とする。`google.*` のような汎用名前空間へ畳まれる package(protobuf 等)は false positive を避け意図的に除外。マスタ未収録の乖離は引き続き false negative として受容(昇格漏れのみ・過剰昇格なし)。残課題は初期集合の実データ拡張。 - **サンプリング閾値の実データチューニング(#478 / 2026-07)**: 連携実データで計測した結果、グローバル 30 件キャップは monorepo(npm+pypi 同居・候補 514 ファイル)で辞書順先頭のサブツリーが枠を使い切り、全量なら昇格する direct 依存 26 件中 17 件を取りこぼしていた。順序戦略の変更(ディレクトリ分散・ランダム)は浅い順に勝てず(import が深い数ファイルに局在するため)、**キャップをエコシステム別 50 件に変更**(単一エコシステムのリポは全量走査に収まり、monorepo の押し出しが解消。50 超はロングテールで頭打ち)。深さ上限 6 は深さ 7 以上のソースが昇格に寄与しなかったため据え置き。残る昇格漏れ(26 件中 9 件・全量走査には約 500 ファイル必要)はコスト対効果からロングテールとして受容(false negative のみ・declare 証跡は残る)。 - **monorepo 対応**: D9 で採用済み(recursive Trees API + パスセグメント除外 + 深さ/件数キャップ + keep-all)。キャップ閾値の実データチューニングは #478(2026-07)で実施: manifest キャップ(深さ 4 / 件数 20)は実データ最大が 4 件・深さ 3 で打ち切りゼロのため据え置き。IaC キャップは 30 件だと `infra/modules/` 配下の resource を取りこぼした(検出 20 種中 11 種が漏れ。50 件で全量一致)ため **60 件へ引き上げ**(深さ 6 は据え置き)。残課題は除外定義の高度化(Linguist `vendor.yml` 流用)・規模シグナルの導入。 -- **IaC からのインフラリソース検出**: 機械検出は **D10 で採用・実装済み**(2026-07。Terraform/OpenTofu・provider+service 粒度・kind=infra・signal_source=infra_declared・D9 探索流用・正規表現 parser)。残課題は表示名の HITL 畳み込み・動的 module 解決・Tier2 IaC・resource 出現回数の量的シグナル。 +- **IaC からのインフラリソース検出**: 機械検出は **D10 で採用・実装済み**(2026-07。Terraform/OpenTofu・provider+service 粒度・kind=infra・signal_source=infra_declared・D9 探索流用・正規表現 parser)。残課題は動的 module 解決・Tier2 IaC・resource 出現回数の量的シグナル。 +- **表示名の human-in-the-loop 畳み込み**: **D11 で採用・実装済み**(2026-07。全 kind 対象・独立 Layer 3 テーブル `github_skill_display_decision`・agent 提案は動的 enum で捏造防止・人間確定を永続化・解決順は確定値 > 機械 display_name > canonical)。残課題は確定値のバージョニング / 監査履歴・group をまたぐ evidence 重み再計算・agent による足切り提案。 - **private リポジトリの扱い**: Layer 3 経由で人間が深さを補完する。生データは持ち込まない前提を維持する。 - **deps.dev エンリッチ**: 横断名寄せの範囲・実行タイミング。 - **閾値・粒度のデフォルト**: 言語足切りの初期値、表示名 alias の初期セット。 ## 将来課題: IaC からのインフラリソース検出(機械検出は D10 で採用済み / 2026-07) -> **更新(2026-07)**: 本項の「機械検出(provider+service・static resource・kind=infra)」は **D10 として採用・実装済み**。以下は当初の課題提起の記録であり、**残る未採用部分**は「表示名の human-in-the-loop 畳み込み」「動的 module 解決」「Tier2 IaC(CloudFormation / Pulumi / k8s / Helm)」「resource 出現回数の量的シグナル(ADD COLUMN 案)」。実装済みの決定は D10 を正とする。 +> **更新(2026-07)**: 本項の「機械検出(provider+service・static resource・kind=infra)」は **D10 として採用・実装済み**。**「表示名の human-in-the-loop 畳み込み」も D11 として採用・実装済み**(package / infra / language 全 kind 対象)。以下は当初の課題提起の記録であり、**残る未採用部分**は「動的 module 解決」「Tier2 IaC(CloudFormation / Pulumi / k8s / Helm)」「resource 出現回数の量的シグナル(ADD COLUMN 案)」。実装済みの決定は D10 / D11 を正とする。 **背景**: Terraform/HCL は Linguist により*言語*スキル「Terraform」として検出済み(D3・D9(g))。一方「どのクラウドの何のサービスを IaC で構築・運用したか」は捉えられていない。インフラが **IaC で記述されている場合に限り**、宣言から具体的なインフラリソースを抽出すれば、インフラ系スキルの幅と証跡性が上がる。 @@ -218,6 +234,7 @@ Layer 1 を単一型にせず、`LanguageSkill` と `PackageSkill` に型分割 ## 改訂履歴 +- **2026-07**: 「将来課題」「D10 スコープ外」だった**表示名・粒度の human-in-the-loop 畳み込みを D11 として採用・実装(#476)**。全 kind(package / infra / language)対象。人間の確定を Layer 1-2(機械・洗い替え対象)から切り離した独立 Layer 3 テーブル `github_skill_display_decision`(安定 identity キー)に保存し再連携の洗い替えに耐性を持たせた。agent は実在スキルの動的 enum で提案のみ行い(捏造防止・D8/P4 の提案のみ責務を維持)、人間が確定・永続化する。serve 時の解決順は確定値 > 機械 display_name(Linguist)> canonical。既存 `github_skills.display_name` は機械フォールバックとして温存。3 層モデル・D1〜D10 は不変。 - **2026-07**: D6/D9/D10 の**キャップ・サンプリング閾値を実データでチューニング(#478)**。連携本人の実データに収集コードを当てて打ち切り発生状況とキャップ反実仮想(値を変えた場合の検出数推移)を計測した。verify のソースキャップをグローバル 30 件 → **エコシステム別 50 件**へ変更(monorepo で先頭エコシステムが枠を使い切る押し出しを解消。順序戦略の変更は浅い順に勝てないことを確認)。IaC キャップを 30 → **60 件**へ引き上げ(30 では resource 20 種中 11 種を取りこぼし、50 で全量一致)。manifest キャップ(深さ 4 / 件数 20)と各深さ上限は実データで打ち切りゼロ・寄与ゼロを確認し据え置き。スキーマ・API 契約は不変。3 層モデル・D1〜D10 は不変。 - **2026-07**: verify(D6)の **import 名乖離の取りこぼしを低減(#477)**。pypi のみ、機械変換で当たらない配布名≠import名の乖離を内部マスタ(`skills/resources/pypi_import_aliases.json`)で補正。D3 テンションは `linguist_master.json` と同じ「ホットパス外で生成する暫定キュレーション・実行時は読むだけ」モデルで解消。既知の乖離 package を初期集合とし、`google.*` 等の汎用名前空間は false positive 回避のため除外。未収録は引き続き false negative 受容(過剰昇格なし)。schema / API 契約は不変(migration 不要)。3 層モデル・D1〜D10 は不変。 - **2026-07**: 「将来課題」だった IaC からのインフラリソース検出を **D10 として採用・実装**(Terraform/OpenTofu の `.tf` を対象に provider+service を抽出、kind=`infra` / signal_source=`infra_declared`、static resource ブロック限定、正規表現 parser で依存なし、D9 探索流用で `.terraform` 除外を追加、canonical=raw type で keep-all)。表示名の human-in-the-loop 畳み込み・動的 module 解決・Tier2 IaC・出現回数の量的シグナルは残課題。kind / signal_source / ecosystem は既存カラムの値域内のため migration 不要。3 層モデル・D1〜D9 は不変。 diff --git a/web/e2e/github-link.spec.ts b/web/e2e/github-link.spec.ts index 27bf2fba..e0b968f5 100644 --- a/web/e2e/github-link.spec.ts +++ b/web/e2e/github-link.spec.ts @@ -42,6 +42,14 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ" }), }), ); + // スキルセクション(D11)がマウント時に叩く一覧 API。空で返す + await page.route("**/api/github-link/skills", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ skills: [] }), + }), + ); await page.goto("/github_link"); await waitForAuthenticatedLayout(page); @@ -205,6 +213,13 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ" body: "%PDF-1.4\n%mock\n", }), ); + await page.route("**/api/github-link/skills", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ skills: [] }), + }), + ); await page.goto("/github_link"); await waitForAuthenticatedLayout(page); @@ -258,4 +273,108 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ" page.getByText("GitHubプロフィールを取得中..."), ).toBeVisible(); }); + + test("スキル表示名を AI 提案 → 確定するとチップに確定表示名が反映される(D11)", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "completed", + result: { + username: "e2e-test-user", + repos_analyzed: 1, + unique_skills: 1, + analyzed_at: "2026-04-24T00:00:00Z", + languages: { TypeScript: 100 }, + }, + }), + }), + ); + // 初期一覧: 未確定の package 1 件(canonical 名で表示される) + await page.route("**/api/github-link/skills", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + skills: [ + { + kind: "package", + canonical_name: "@aws-sdk/client-s3", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: null, + group_id: null, + decision_source: null, + decision_reviewed: false, + evidence: [], + proficiency: null, + }, + ], + }), + }), + ); + await page.route("**/api/github-link/skills/display-names/propose", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + groups: [ + { + display_name: "Amazon S3", + members: [ + { kind: "package", ecosystem: "npm", canonical_name: "@aws-sdk/client-s3" }, + ], + }, + ], + }), + }), + ); + // 確定後の一覧: confirmed_display_name が入った状態を返す + await page.route("**/api/github-link/skills/display-decisions", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + skills: [ + { + kind: "package", + canonical_name: "@aws-sdk/client-s3", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: "Amazon S3", + group_id: null, + decision_source: "agent", + decision_reviewed: true, + evidence: [], + proficiency: null, + }, + ], + }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + // 未確定なので canonical 名がチップに出る + await expect(page.getByText("@aws-sdk/client-s3")).toBeVisible(); + + // 提案 → レビューパネルが開く + await page.getByRole("button", { name: "表示名をAIに提案してもらう" }).click(); + await expect( + page.getByText("表示名の提案(確認して確定してください)"), + ).toBeVisible(); + await expect(page.getByRole("textbox", { name: "表示名" })).toHaveValue( + "Amazon S3", + ); + + // 確定 → チップに確定表示名が反映される + await page.getByRole("button", { name: "この内容で確定" }).click(); + await expect(page.getByText("Amazon S3")).toBeVisible(); + }); }); diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 4a40c813..1e1446d8 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -522,7 +522,8 @@ export interface paths { * Get Skills * @description GitHub 連携で推論した 3 層スキル(ADR-0016)を取得する。 * - * 連携がまだ実行されていない場合は空配列を返す。 + * 表示名の human-in-the-loop 確定(D11)があれば ``confirmed_display_name`` / ``group_id`` + * として載せる。連携がまだ実行されていない場合は空配列を返す。 */ get: operations["get_skills_api_github_link_skills_get"]; put?: never; @@ -533,6 +534,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/github-link/skills/display-decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Confirm Skill Display Decisions + * @description レビュー済みの表示名・畳み込みを確定・永続化する(ADR-0016 D11)。 + * + * 確定対象の identity は当該ユーザーの検出済みスキルに属していなければならない + * (他者・非実在 identity の混入を拒否)。確定は独立 Layer 3 テーブルへ upsert され、 + * 連携の洗い替えに耐える。確定後の最新スキル一覧を返す。 + */ + put: operations["confirm_skill_display_decisions_api_github_link_skills_display_decisions_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/github-link/skills/display-names/propose": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Propose Skill Display Names Endpoint + * @description 検出済みスキルの表示名・畳み込みグループを agent に提案させる(ADR-0016 D11)。 + * + * agent は提案するだけで確定・DB 更新はしない(D8 / P4)。提案結果はレスポンスとして返し、 + * ユーザーがレビュー・編集して ``PUT /skills/display-decisions`` で確定する。 + * 外部 LLM を呼ぶ高コスト endpoint のため rate limit を付与し、課金はチャットと同一契約。 + */ + post: operations["propose_skill_display_names_endpoint_api_github_link_skills_display_names_propose_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/master-data/qualification": { parameters: { query?: never; @@ -1630,9 +1679,25 @@ export interface components { * @description 正規名(言語=Linguist 名 / package=package ID / infra=provider 名または raw resource type) */ canonical_name: string; + /** + * Confirmed Display Name + * @description 人間が確定した表示名(未確定は null / D11) + */ + confirmed_display_name?: string | null; + /** + * Decision Reviewed + * @description 人間レビュー済みか(D11) + * @default false + */ + decision_reviewed: boolean; + /** + * Decision Source + * @description 確定の出所(agent / human。未確定は null / D11) + */ + decision_source?: string | null; /** * Display Name - * @description 表示名(粒度畳みの確定値。未確定は null) + * @description 機械(Linguist)由来の表示補正。未補正は null */ display_name?: string | null; /** @@ -1642,6 +1707,11 @@ export interface components { ecosystem?: string | null; /** Evidence */ evidence?: components["schemas"]["SkillEvidence"][]; + /** + * Group Id + * @description 畳み込みグループ ID。同一 group_id のスキルは 1 表示へ畳む(D11) + */ + group_id?: string | null; /** * Kind * @description スキル種別(language / package / infra) @@ -1942,6 +2012,90 @@ export interface components { /** Self Pr */ self_pr: string; }; + /** + * SkillDisplayConfirmRequest + * @description 表示名確定(人間)のバッチリクエスト(ADR-0016 D11)。 + */ + SkillDisplayConfirmRequest: { + /** Decisions */ + decisions?: components["schemas"]["SkillDisplayDecisionInput"][]; + }; + /** + * SkillDisplayDecisionInput + * @description 人間が確定する 1 スキルの表示名(identity + 確定表示名 + グループ / D11)。 + */ + SkillDisplayDecisionInput: { + /** + * Canonical Name + * @description 正規名 + */ + canonical_name: string; + /** + * Display Name + * @description 確定した表示名 + */ + display_name: string; + /** + * Ecosystem + * @description エコシステム(language は空文字) + * @default + */ + ecosystem: string; + /** + * Group Id + * @description 畳み込みグループ ID(単独確定は null) + */ + group_id?: string | null; + /** + * Kind + * @description スキル種別 + */ + kind: string; + /** + * Source + * @description 出所(agent / human) + * @default human + */ + source: string; + }; + /** + * SkillDisplayProposeRequest + * @description 表示名提案(agent)のリクエスト(ADR-0016 D11)。 + * + * 提案対象スキルはサーバーが連携結果から決めるため、クライアントは使用モデルのみ指定する。 + */ + SkillDisplayProposeRequest: { + /** + * Model + * @default haiku + * @enum {string} + */ + model: "haiku" | "sonnet" | "gemini-flash" | "gemini-pro" | "gpt-mini" | "gpt"; + }; + /** + * SkillDisplayProposeResponse + * @description 表示名提案の結果(永続化されない。人間がレビュー・確定する / D11)。 + */ + SkillDisplayProposeResponse: { + /** Groups */ + groups?: components["schemas"]["SkillDisplayProposedGroup"][]; + }; + /** + * SkillDisplayProposedGroup + * @description agent が提案した 1 表示スキル(表示名 + 畳むメンバー群 / D11)。 + */ + SkillDisplayProposedGroup: { + /** + * Display Name + * @description 提案する表示名 + */ + display_name: string; + /** + * Members + * @description このグループに畳むスキルの identity + */ + members?: components["schemas"]["SkillIdentityRef"][]; + }; /** * SkillEvidence * @description Layer 2: 技術×リポの根拠。 @@ -1989,6 +2143,28 @@ export interface components { */ signal_source: string; }; + /** + * SkillIdentityRef + * @description スキルの安定 identity(github_skills と一致 / D11)。 + */ + SkillIdentityRef: { + /** + * Canonical Name + * @description 正規名(package ID / 言語名 / raw resource type) + */ + canonical_name: string; + /** + * Ecosystem + * @description エコシステム(language は空文字) + * @default + */ + ecosystem: string; + /** + * Kind + * @description スキル種別(language / package / infra) + */ + kind: string; + }; /** * SkillProficiency * @description Layer 3: 習熟度・文脈(人間/agent が後追いで埋める。本フェーズは未投入)。 @@ -2810,6 +2986,72 @@ export interface operations { }; }; }; + confirm_skill_display_decisions_api_github_link_skills_display_decisions_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillDisplayConfirmRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitHubSkillsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + propose_skill_display_names_endpoint_api_github_link_skills_display_names_propose_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillDisplayProposeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillDisplayProposeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_items_api_master_data_qualification_get: { parameters: { query?: never; diff --git a/web/src/api/githubLink.ts b/web/src/api/githubLink.ts index 99d190c6..ac66debb 100644 --- a/web/src/api/githubLink.ts +++ b/web/src/api/githubLink.ts @@ -2,7 +2,11 @@ import { request } from "./client"; import { PATHS } from "./paths"; import type { CachedGitHubLinkResponse, + GitHubSkillsResponse, ProgressResponse, + SkillDisplayConfirmRequest, + SkillDisplayProposeRequest, + SkillDisplayProposeResponse, TaskAcceptedResponse, TaskStatusResponse, } from "./types"; @@ -59,3 +63,36 @@ export function retryGitHubLink( body: JSON.stringify(payload), }); } + +/** + * 連携で推論した 3 層スキル(表示名の確定込み)を取得します(ADR-0016 D11)。 + */ +export function getGitHubSkills(): Promise { + return request(PATHS.githubLink.skills); +} + +/** + * スキル表示名・畳み込みグループの提案を agent に依頼します(D11)。 + * agent は提案するだけで確定はしません(ユーザーがレビュー・確定する)。 + */ +export function proposeSkillDisplayNames( + payload: SkillDisplayProposeRequest, +): Promise { + return request(PATHS.githubLink.skillsDisplayPropose, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +/** + * レビュー済みの表示名・畳み込みを確定・永続化します(D11)。 + * 確定後の最新スキル一覧を返します。 + */ +export function confirmSkillDisplayDecisions( + payload: SkillDisplayConfirmRequest, +): Promise { + return request(PATHS.githubLink.skillsDisplayConfirm, { + method: "PUT", + body: JSON.stringify(payload), + }); +} diff --git a/web/src/api/paths.ts b/web/src/api/paths.ts index adf56355..03b23418 100644 --- a/web/src/api/paths.ts +++ b/web/src/api/paths.ts @@ -54,6 +54,9 @@ export const PATHS = { cache: "/api/github-link/cache", cacheStatus: "/api/github-link/cache/status", progress: "/api/github-link/progress", + skills: "/api/github-link/skills", + skillsDisplayPropose: "/api/github-link/skills/display-names/propose", + skillsDisplayConfirm: "/api/github-link/skills/display-decisions", }, masterData: { qualification: "/api/master-data/qualification", diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 90a991b4..f3c30bbe 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -40,6 +40,32 @@ export type GitHubLinkResponse = Schemas["GitHubLinkResponse"]; /** DB に保存された連携結果。backend `schemas/github_link.py:CachedGitHubLinkResponse`。 */ export type CachedGitHubLinkResponse = Schemas["CachedGitHubLinkResponse"]; +// ── GitHub 連携スキル 3 層 + 表示名 HITL(github_skill.py / ADR-0016)───────── + +/** ユーザーのスキル一覧(3 層)。backend `schemas/github_skill.py:GitHubSkillsResponse`。 */ +export type GitHubSkillsResponse = Schemas["GitHubSkillsResponse"]; + +/** スキル 1 件(Layer 1 + evidence + 確定表示名)。backend `GitHubSkillItem`。 */ +export type GitHubSkillItem = Schemas["GitHubSkillItem"]; + +/** スキルの安定 identity。backend `schemas/github_skill.py:SkillIdentityRef`(D11)。 */ +export type SkillIdentityRef = Schemas["SkillIdentityRef"]; + +/** 表示名提案リクエスト(使用モデル)。backend `SkillDisplayProposeRequest`(D11)。 */ +export type SkillDisplayProposeRequest = Schemas["SkillDisplayProposeRequest"]; + +/** agent が提案した 1 表示スキル。backend `SkillDisplayProposedGroup`(D11)。 */ +export type SkillDisplayProposedGroup = Schemas["SkillDisplayProposedGroup"]; + +/** 表示名提案の結果。backend `schemas/github_skill.py:SkillDisplayProposeResponse`(D11)。 */ +export type SkillDisplayProposeResponse = Schemas["SkillDisplayProposeResponse"]; + +/** 人間が確定する 1 スキルの表示名。backend `SkillDisplayDecisionInput`(D11)。 */ +export type SkillDisplayDecisionInput = Schemas["SkillDisplayDecisionInput"]; + +/** 表示名確定のバッチリクエスト。backend `SkillDisplayConfirmRequest`(D11)。 */ +export type SkillDisplayConfirmRequest = Schemas["SkillDisplayConfirmRequest"]; + // ── 認証(auth.py)──────────────────────────────────────────────────────── /** GitHub OAuth 認可 URL と CSRF 検証用 state。backend `schemas/auth.py:GitHubLoginUrlResponse`。 */ diff --git a/web/src/components/github-link/GitHubLinkDashboard.tsx b/web/src/components/github-link/GitHubLinkDashboard.tsx index f342fc4d..b5ece186 100644 --- a/web/src/components/github-link/GitHubLinkDashboard.tsx +++ b/web/src/components/github-link/GitHubLinkDashboard.tsx @@ -25,6 +25,7 @@ import { useAppSelector } from "../../store"; import { PdfPreviewModal } from "../forms/PdfPreviewModal"; import { ContributionHeatmap } from "./ContributionHeatmap"; import { LanguageBar } from "./LanguageBar"; +import { SkillDisplaySection } from "./SkillDisplaySection"; import shared from "../../styles/shared.module.css"; import styles from "./GitHubLinkDashboard.module.css"; @@ -170,6 +171,9 @@ export function GitHubLinkDashboard() { )} + {/* スキル一覧 + 表示名の human-in-the-loop 確定(ADR-0016 D11) */} + + {/* 経歴書ドラフト PDF 生成(ADR-0018) */}

{RESUME_DRAFT_MESSAGES.HEADING}

diff --git a/web/src/components/github-link/SkillDisplaySection.module.css b/web/src/components/github-link/SkillDisplaySection.module.css new file mode 100644 index 00000000..fea33c4c --- /dev/null +++ b/web/src/components/github-link/SkillDisplaySection.module.css @@ -0,0 +1,100 @@ +.skillList { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.skillChip { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-section); + color: var(--text-primary); + font-size: 0.85rem; +} + +.skillLabel { + font-weight: 600; +} + +.skillSub { + color: var(--text-muted); + font-size: 0.75rem; +} + +.kindBadge { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 4px; +} + +.reviewPanel { + margin-top: 16px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-section); +} + +.reviewHeading { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.groupRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 0; + border-top: 1px solid var(--border); +} + +.groupRow:first-of-type { + border-top: none; +} + +.nameInput { + flex: 0 0 220px; + max-width: 100%; + padding: 6px 8px; + border: 1px solid var(--border-input); + border-radius: 6px; + background: var(--bg-card); + color: var(--text-primary); + font-size: 0.9rem; +} + +.groupMembers { + flex: 1 1 240px; + color: var(--text-muted); + font-size: 0.8rem; +} + +.reviewActions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.secondaryButton { + background: var(--ghost-bg); + color: var(--ghost-text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; +} + +.secondaryButton:hover { + background: var(--ghost-hover); +} diff --git a/web/src/components/github-link/SkillDisplaySection.tsx b/web/src/components/github-link/SkillDisplaySection.tsx new file mode 100644 index 00000000..21763be8 --- /dev/null +++ b/web/src/components/github-link/SkillDisplaySection.tsx @@ -0,0 +1,128 @@ +import { useGitHubSkills } from "../../hooks/useGitHubSkills"; +import { useAppErrorToast } from "../ui/toast"; +import { InlineSpinner } from "../ui/InlineSpinner"; +import { SKILL_DISPLAY_MESSAGES } from "../../constants/messages"; +import type { AgentModelAlias } from "../../api/types"; +import dash from "./GitHubLinkDashboard.module.css"; +import styles from "./SkillDisplaySection.module.css"; + +/** + * 検出済みスキルの一覧と、表示名・畳み込みの human-in-the-loop 確定フロー(ADR-0016 D11)。 + * + * 一覧はグループ化して実効表示名(確定 > 機械 > canonical)で表示する。「AIに提案してもらう」で + * agent の提案を受け取り、表示名を編集して確定する。確定は独立 Layer 3 に保存され再連携でも残る。 + * + * @param model 提案に使う LLM モデル(ユーザーメニューのグローバル設定) + */ +export function SkillDisplaySection({ model }: { model: AgentModelAlias }) { + const { + groups, + loading, + error, + proposal, + proposing, + confirming, + propose, + updateProposalName, + discardProposal, + confirm, + } = useGitHubSkills(model); + + useAppErrorToast(error); + + return ( +
+

{SKILL_DISPLAY_MESSAGES.HEADING}

+

{SKILL_DISPLAY_MESSAGES.HINT}

+ + {loading ? ( + + ) : groups.length === 0 ? ( +

{SKILL_DISPLAY_MESSAGES.EMPTY}

+ ) : ( + <> +
+ {groups.map((group) => ( + + {group.skills[0].kind} + {group.label} + {group.skills.length > 1 && ( + + {SKILL_DISPLAY_MESSAGES.memberCountLabel(group.skills.length)} + + )} + + ))} +
+ + + + )} + + {proposal !== null && ( +
+
+ {proposal.length === 0 + ? SKILL_DISPLAY_MESSAGES.PROPOSE_EMPTY + : SKILL_DISPLAY_MESSAGES.REVIEW_HEADING} +
+ + {proposal.map((group, index) => ( +
+ m.canonical_name) + .join(", ")}`} + onChange={(e) => updateProposalName(index, e.target.value)} + /> + + {group.members.map((m) => m.canonical_name).join(", ")} + +
+ ))} + +
+ {proposal.length > 0 && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts index 5a0b56db..377f56b9 100644 --- a/web/src/constants/messages.ts +++ b/web/src/constants/messages.ts @@ -71,6 +71,9 @@ export const FALLBACK_MESSAGES = { CREDIT_BALANCE: "クレジット残高の取得に失敗しました", USAGE_SUMMARY: "利用状況の取得に失敗しました", CHECKOUT: "決済ページへの遷移に失敗しました", + SKILL_FETCH: "スキルの取得に失敗しました", + SKILL_DISPLAY_PROPOSE: "表示名の提案に失敗しました", + SKILL_DISPLAY_CONFIRM: "表示名の確定に失敗しました", } as const; /** @@ -208,6 +211,34 @@ export const RESUME_DRAFT_MESSAGES = { NOT_SAVED_NOTE: "生成した内容は職務経歴書として保存されません。必要な部分は職務経歴書フォームへ転記してください。", } as const; +/** スキル表示名の human-in-the-loop 確定(ADR-0016 D11)の UI 文言。 */ +export const SKILL_DISPLAY_MESSAGES = { + /** セクション見出し。 */ + HEADING: "スキル", + /** 機能説明。 */ + HINT: "連携から検出した技術スキルです。AI に読みやすい表示名・まとめ方を提案させ、確認・編集して確定できます。確定した表示名は再連携しても保持されます。", + /** 提案ボタンのラベル。 */ + PROPOSE: "表示名をAIに提案してもらう", + /** 提案中のラベル。 */ + PROPOSING: "表示名を提案中...", + /** レビューパネルの見出し。 */ + REVIEW_HEADING: "表示名の提案(確認して確定してください)", + /** 表示名入力欄のラベル。 */ + DISPLAY_NAME_LABEL: "表示名", + /** 確定ボタンのラベル。 */ + CONFIRM: "この内容で確定", + /** 確定中のラベル。 */ + CONFIRMING: "確定中...", + /** 提案破棄ボタンのラベル。 */ + DISCARD: "破棄", + /** スキルが未検出のときの空表示。 */ + EMPTY: "検出されたスキルがありません。先に GitHub 連携を実行してください。", + /** 提案が 0 件だったときの表示。 */ + PROPOSE_EMPTY: "提案できる表示名がありませんでした。", + /** 畳み込みメンバー数のラベル接尾(例: 「3 件をまとめる」)。 */ + memberCountLabel: (count: number): string => `${count} 件をまとめる`, +} as const; + /** 年セレクトの選択肢表記「N年」。 */ export function yearLabel(year: number): string { return `${year}年`; diff --git a/web/src/hooks/useGitHubSkills.test.ts b/web/src/hooks/useGitHubSkills.test.ts new file mode 100644 index 00000000..32c51845 --- /dev/null +++ b/web/src/hooks/useGitHubSkills.test.ts @@ -0,0 +1,139 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useGitHubSkills } from "./useGitHubSkills"; +import type { GitHubSkillItem } from "../api/types"; + +const getGitHubSkillsMock = vi.fn(); +const proposeSkillDisplayNamesMock = vi.fn(); +const confirmSkillDisplayDecisionsMock = vi.fn(); + +vi.mock("../api/githubLink", () => ({ + getGitHubSkills: (...args: unknown[]) => getGitHubSkillsMock(...args), + proposeSkillDisplayNames: (...args: unknown[]) => proposeSkillDisplayNamesMock(...args), + confirmSkillDisplayDecisions: (...args: unknown[]) => + confirmSkillDisplayDecisionsMock(...args), +})); + +function skill(overrides: Partial): GitHubSkillItem { + return { + kind: "package", + canonical_name: "react", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: null, + group_id: null, + decision_source: null, + decision_reviewed: false, + evidence: [], + proficiency: null, + ...overrides, + } as GitHubSkillItem; +} + +beforeEach(() => { + getGitHubSkillsMock.mockReset(); + proposeSkillDisplayNamesMock.mockReset(); + confirmSkillDisplayDecisionsMock.mockReset(); +}); + +describe("useGitHubSkills", () => { + it("マウント時にスキルを取得しグループ化する(success)", async () => { + getGitHubSkillsMock.mockResolvedValue({ skills: [skill({ canonical_name: "react" })] }); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.skills).toHaveLength(1); + expect(result.current.groups).toHaveLength(1); + expect(result.current.error).toBeNull(); + }); + + it("取得失敗時は error にメッセージが入る(error)", async () => { + getGitHubSkillsMock.mockRejectedValue("network down"); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.skills).toHaveLength(0); + }); + + it("propose で提案を編集可能な状態として保持する", async () => { + getGitHubSkillsMock.mockResolvedValue({ skills: [skill({})] }); + proposeSkillDisplayNamesMock.mockResolvedValue({ + groups: [ + { + display_name: "React", + members: [{ kind: "package", ecosystem: "npm", canonical_name: "react" }], + }, + ], + }); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.propose(); + }); + + expect(result.current.proposal).toHaveLength(1); + expect(result.current.proposal?.[0].displayName).toBe("React"); + expect(result.current.proposal?.[0].originalDisplayName).toBe("React"); + }); + + it("confirm で確定 API を呼び、返却された最新一覧で置き換え提案をクリアする", async () => { + getGitHubSkillsMock.mockResolvedValue({ skills: [skill({})] }); + proposeSkillDisplayNamesMock.mockResolvedValue({ + groups: [ + { + display_name: "React", + members: [{ kind: "package", ecosystem: "npm", canonical_name: "react" }], + }, + ], + }); + confirmSkillDisplayDecisionsMock.mockResolvedValue({ + skills: [skill({ confirmed_display_name: "React" })], + }); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.propose(); + }); + await act(async () => { + await result.current.confirm(); + }); + + expect(confirmSkillDisplayDecisionsMock).toHaveBeenCalledTimes(1); + const payload = confirmSkillDisplayDecisionsMock.mock.calls[0][0]; + expect(payload.decisions[0].canonical_name).toBe("react"); + expect(payload.decisions[0].display_name).toBe("React"); + expect(result.current.proposal).toBeNull(); + expect(result.current.skills[0].confirmed_display_name).toBe("React"); + }); + + it("提案の表示名を編集でき、破棄でクリアできる", async () => { + getGitHubSkillsMock.mockResolvedValue({ skills: [skill({})] }); + proposeSkillDisplayNamesMock.mockResolvedValue({ + groups: [ + { + display_name: "react", + members: [{ kind: "package", ecosystem: "npm", canonical_name: "react" }], + }, + ], + }); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.propose(); + }); + + act(() => result.current.updateProposalName(0, "React")); + expect(result.current.proposal?.[0].displayName).toBe("React"); + + act(() => result.current.discardProposal()); + expect(result.current.proposal).toBeNull(); + }); +}); diff --git a/web/src/hooks/useGitHubSkills.ts b/web/src/hooks/useGitHubSkills.ts new file mode 100644 index 00000000..78d0060b --- /dev/null +++ b/web/src/hooks/useGitHubSkills.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + confirmSkillDisplayDecisions, + getGitHubSkills, + proposeSkillDisplayNames, +} from "../api/githubLink"; +import { toAppError, type AppErrorState } from "../api"; +import type { AgentModelAlias, GitHubSkillItem } from "../api/types"; +import { FALLBACK_MESSAGES } from "../constants/messages"; +import { + buildDisplayDecisions, + groupSkillsForDisplay, + type DisplaySkillGroup, + type EditableProposalGroup, +} from "../utils/skillDisplay"; + +/** + * GitHub 連携スキルの一覧取得と、表示名の human-in-the-loop 確定フローを管理するフック + * (ADR-0016 D11)。 + * + * - 一覧: マウント時に取得(loading / error)。 + * - 提案: agent に表示名・畳み込みを提案させ、編集可能な状態(proposal)で保持する。 + * - 確定: 編集済みの提案を確定 API へ送り、返ってきた最新一覧で置き換える。 + * + * 決定論的な変換(表示名解決・グループ化・提案→確定ペイロード)は utils/skillDisplay に + * 切り出し、本フックは API 呼び出しと状態管理(loading / success / error)に専念する。 + * + * @param model 提案に使う LLM モデル(ユーザーメニューのグローバル設定を渡す) + */ +export function useGitHubSkills(model: AgentModelAlias) { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [proposal, setProposal] = useState(null); + const [proposing, setProposing] = useState(false); + const [confirming, setConfirming] = useState(false); + + const reload = useCallback(async () => { + setLoading(true); + // 前回の失敗が残らないよう、再取得の成功でエラー表示が消えるようにする(propose/confirm と同様) + setError(null); + try { + const res = await getGitHubSkills(); + setSkills(res.skills ?? []); + } catch (e) { + setError(toAppError(e, FALLBACK_MESSAGES.SKILL_FETCH)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + /** agent に表示名・畳み込みを提案させ、編集可能な状態で保持する。 */ + const propose = useCallback(async () => { + setError(null); + setProposing(true); + try { + const res = await proposeSkillDisplayNames({ model }); + setProposal( + (res.groups ?? []).map((group) => ({ + displayName: group.display_name, + originalDisplayName: group.display_name, + members: group.members ?? [], + })), + ); + } catch (e) { + setError(toAppError(e, FALLBACK_MESSAGES.SKILL_DISPLAY_PROPOSE)); + } finally { + setProposing(false); + } + }, [model]); + + /** レビュー中の 1 グループの表示名を編集する。 */ + const updateProposalName = useCallback((index: number, displayName: string) => { + setProposal((prev) => + prev ? prev.map((g, i) => (i === index ? { ...g, displayName } : g)) : prev, + ); + }, []); + + /** 提案を破棄する(確定しない)。 */ + const discardProposal = useCallback(() => setProposal(null), []); + + /** 編集済みの提案を確定・永続化し、返ってきた最新一覧で置き換える。 */ + const confirm = useCallback(async () => { + if (!proposal) return; + setError(null); + setConfirming(true); + try { + const decisions = buildDisplayDecisions(proposal); + const res = await confirmSkillDisplayDecisions({ decisions }); + setSkills(res.skills ?? []); + setProposal(null); + } catch (e) { + setError(toAppError(e, FALLBACK_MESSAGES.SKILL_DISPLAY_CONFIRM)); + } finally { + setConfirming(false); + } + }, [proposal]); + + const groups: DisplaySkillGroup[] = groupSkillsForDisplay(skills); + + return { + skills, + groups, + loading, + error, + proposal, + proposing, + confirming, + propose, + updateProposalName, + discardProposal, + confirm, + reload, + }; +} diff --git a/web/src/utils/skillDisplay.test.ts b/web/src/utils/skillDisplay.test.ts new file mode 100644 index 00000000..77c6629e --- /dev/null +++ b/web/src/utils/skillDisplay.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; + +import { + buildDisplayDecisions, + effectiveSkillName, + groupSkillsForDisplay, + type EditableProposalGroup, +} from "./skillDisplay"; +import type { GitHubSkillItem } from "../api/types"; + +function skill(overrides: Partial): GitHubSkillItem { + return { + kind: "package", + canonical_name: "x", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: null, + group_id: null, + decision_source: null, + decision_reviewed: false, + evidence: [], + proficiency: null, + ...overrides, + } as GitHubSkillItem; +} + +describe("effectiveSkillName", () => { + it("確定表示名 > 機械 display_name > canonical の順で解決する", () => { + expect( + effectiveSkillName( + skill({ canonical_name: "hcl", display_name: "Terraform", confirmed_display_name: "IaC" }), + ), + ).toBe("IaC"); + expect( + effectiveSkillName(skill({ canonical_name: "hcl", display_name: "Terraform" })), + ).toBe("Terraform"); + expect(effectiveSkillName(skill({ canonical_name: "react" }))).toBe("react"); + }); +}); + +describe("groupSkillsForDisplay", () => { + it("同一 group_id のスキルを 1 グループへ畳み、未確定は単独グループにする", () => { + const skills = [ + skill({ canonical_name: "@aws-sdk/client-s3", group_id: "g1", confirmed_display_name: "AWS SDK" }), + skill({ canonical_name: "@aws-sdk/client-sns", group_id: "g1", confirmed_display_name: "AWS SDK" }), + skill({ canonical_name: "react", group_id: null }), + ]; + const groups = groupSkillsForDisplay(skills); + expect(groups).toHaveLength(2); + const aws = groups.find((g) => g.label === "AWS SDK"); + expect(aws?.skills).toHaveLength(2); + const react = groups.find((g) => g.label === "react"); + expect(react?.skills).toHaveLength(1); + }); +}); + +describe("buildDisplayDecisions", () => { + const proposal: EditableProposalGroup[] = [ + { + displayName: "AWS SDK", + originalDisplayName: "AWS SDK", + members: [ + { kind: "package", ecosystem: "npm", canonical_name: "@aws-sdk/client-s3" }, + { kind: "package", ecosystem: "npm", canonical_name: "@aws-sdk/client-sns" }, + ], + }, + { + displayName: "React", + originalDisplayName: "react", + members: [{ kind: "package", ecosystem: "npm", canonical_name: "react" }], + }, + ]; + + it("複数メンバーのグループは共通 group_id を割り当て、単独は group_id なし", () => { + const decisions = buildDisplayDecisions(proposal); + const aws = decisions.filter((d) => d.display_name === "AWS SDK"); + expect(aws).toHaveLength(2); + expect(aws[0].group_id).toBeTruthy(); + expect(aws[0].group_id).toBe(aws[1].group_id); // 同一グループは同じ id を共有 + const react = decisions.find((d) => d.display_name === "React"); + expect(react?.group_id).toBeNull(); // 単独は null + }); + + it("表示名を編集していなければ source=agent、編集していれば human", () => { + const decisions = buildDisplayDecisions(proposal); + expect(decisions.find((d) => d.display_name === "AWS SDK")?.source).toBe("agent"); + // "react" → "React" に編集したので human + expect(decisions.find((d) => d.display_name === "React")?.source).toBe("human"); + }); + + it("表示名が空のグループは確定対象から除外する(切り詰めない)", () => { + const decisions = buildDisplayDecisions([ + { + displayName: " ", + originalDisplayName: "", + members: [{ kind: "package", ecosystem: "npm", canonical_name: "react" }], + }, + ]); + expect(decisions).toHaveLength(0); + }); +}); diff --git a/web/src/utils/skillDisplay.ts b/web/src/utils/skillDisplay.ts new file mode 100644 index 00000000..448bd990 --- /dev/null +++ b/web/src/utils/skillDisplay.ts @@ -0,0 +1,102 @@ +/** + * スキル表示名の human-in-the-loop(ADR-0016 D11)用の純粋関数群。 + * + * 表示名の解決順・グループ畳み込み・提案 → 確定ペイロード変換など、UI から切り離した + * 決定論ロジックを集約する(テスト・ミューテーション対象 / .claude/rules/web/test.md)。 + */ +import type { + GitHubSkillItem, + SkillDisplayDecisionInput, + SkillIdentityRef, +} from "../api/types"; + +/** + * スキルの実効表示名を返す。 + * 解決順は「人間の確定表示名 > 機械(Linguist)由来の表示補正 > canonical 名」(D11)。 + */ +export function effectiveSkillName(skill: GitHubSkillItem): string { + return skill.confirmed_display_name || skill.display_name || skill.canonical_name; +} + +/** 表示用にグループ化したスキル(同一 group_id は 1 グループへ畳む)。 */ +export interface DisplaySkillGroup { + /** React key・グループ識別に使う安定キー */ + key: string; + /** グループの表示ラベル(先頭スキルの実効表示名) */ + label: string; + /** グループに属するスキル(単独確定・未確定は 1 件) */ + skills: GitHubSkillItem[]; +} + +/** + * スキル一覧を表示用にグループ化する。 + * 確定済みで同一 ``group_id`` を持つスキルは 1 グループへ畳む。``group_id`` が無いスキルは + * それぞれ単独グループになる(畳み込みは後段ビュー変換 / D8「保持は細かく」)。 + */ +export function groupSkillsForDisplay(skills: GitHubSkillItem[]): DisplaySkillGroup[] { + const groups: DisplaySkillGroup[] = []; + const byGroupId = new Map(); + for (const skill of skills) { + const label = effectiveSkillName(skill); + if (skill.group_id) { + const existing = byGroupId.get(skill.group_id); + if (existing) { + existing.skills.push(skill); + continue; + } + const group: DisplaySkillGroup = { key: skill.group_id, label, skills: [skill] }; + byGroupId.set(skill.group_id, group); + groups.push(group); + } else { + groups.push({ + key: `${skill.kind}:${skill.ecosystem ?? ""}:${skill.canonical_name}`, + label, + skills: [skill], + }); + } + } + return groups; +} + +/** ユーザーがレビュー・編集した提案グループ(確定前の編集状態)。 */ +export interface EditableProposalGroup { + /** 現在の(編集後の)表示名 */ + displayName: string; + /** agent が最初に提案した表示名(source 判定に使う) */ + originalDisplayName: string; + /** このグループに畳むスキルの identity */ + members: SkillIdentityRef[]; +} + +/** + * レビュー済みの提案グループを確定 API のペイロードへ変換する(D11)。 + * + * - 表示名が空のグループは確定対象から除外する(切り詰めない / ADR-0010 踏襲)。 + * - メンバーが 2 件以上のグループには共通の ``group_id`` を割り当てて畳み込みを表す。 + * 単独グループ(1:1 リネーム)は ``group_id`` を null にする。 + * - ユーザーが表示名を編集していれば ``source="human"``、提案どおりなら ``source="agent"``。 + */ +export function buildDisplayDecisions( + groups: EditableProposalGroup[], +): SkillDisplayDecisionInput[] { + const decisions: SkillDisplayDecisionInput[] = []; + for (const group of groups) { + const displayName = group.displayName.trim(); + if (!displayName || group.members.length === 0) { + continue; + } + const groupId = group.members.length > 1 ? crypto.randomUUID() : null; + const source = displayName === group.originalDisplayName.trim() ? "agent" : "human"; + for (const member of group.members) { + decisions.push({ + kind: member.kind, + ecosystem: member.ecosystem ?? "", + canonical_name: member.canonical_name, + display_name: displayName, + group_id: groupId, + source, + }); + } + } + return decisions; +}