Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/app/repositories/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,24 @@ def upsert_many(self, decisions: list[DisplayDecisionInput]) -> None:
)
)
self.db.commit()

def delete_by_identities(
self, identities: list[tuple[str, str, str]]
) -> int:
"""指定 identity(kind + ecosystem + canonical_name)の確定行を削除する(#496)。

ユーザー自身の確定行だけを対象にし(``user_id`` 固定)、機械デフォルトへ戻す。
存在しない identity はスキップ(冪等)。削除件数を返す。畳み込みグループを解く場合は
当該グループの全メンバー identity をまとめて渡す。
"""
identity_set = set(identities)
if not identity_set:
return 0
deleted = 0
for decision in self.get_for_user():
key = (decision.kind, decision.ecosystem, decision.canonical_name)
if key in identity_set:
self.db.delete(decision)
deleted += 1
self.db.commit()
return deleted
30 changes: 30 additions & 0 deletions backend/app/routers/github_link/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SkillDisplayProposedGroup,
SkillDisplayProposeRequest,
SkillDisplayProposeResponse,
SkillDisplayResetRequest,
SkillIdentityRef,
)
from ...schemas.shared import TaskAcceptedResponse, TaskStatusResponse
Expand Down Expand Up @@ -272,6 +273,35 @@ def confirm_skill_display_decisions(
return _build_skills_response(db, user.id)


@router.delete("/skills/display-decisions", response_model=GitHubSkillsResponse)
def reset_skill_display_decisions(
body: SkillDisplayResetRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> GitHubSkillsResponse:
"""確定済みの表示名・畳み込みを解除(リセット)する(ADR-0016 D11 / #496)。

指定 identity の Layer 3 確定行を削除し、機械デフォルト(機械 display_name > canonical)
へ戻す。畳み込みグループの全メンバー identity を渡せば畳み込みも解ける。identity は当該
ユーザーの検出済みスキルに属していなければならない(confirm と同じ authz)。存在しない
確定行の指定は冪等に無視する。リセット後の最新スキル一覧を返す。
"""
skills = GitHubSkillRepository(db, user.id).list_for_user()
valid_identities = {(s.kind, s.ecosystem, s.canonical_name) for s in skills}
for ident in body.identities:
if (ident.kind, ident.ecosystem, ident.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).delete_by_identities(
[(i.kind, i.ecosystem, i.canonical_name) for i in body.identities]
)
return _build_skills_response(db, user.id)


@router.get("/cache/status", response_model=TaskStatusResponse)
def get_cache_status(
user: User = Depends(get_current_user),
Expand Down
10 changes: 10 additions & 0 deletions backend/app/schemas/github_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,13 @@ class SkillDisplayConfirmRequest(BaseModel):
"""表示名確定(人間)のバッチリクエスト(ADR-0016 D11)。"""

decisions: List[SkillDisplayDecisionInput] = Field(default_factory=list)


class SkillDisplayResetRequest(BaseModel):
"""表示名確定の解除(リセット)リクエスト(ADR-0016 D11 / #496)。

指定 identity の確定行(Layer 3)を削除し、機械デフォルト(機械 display_name /
canonical)へ完全に戻す。同一グループの全メンバー identity を渡せば畳み込みも解ける。
"""

identities: List[SkillIdentityRef] = Field(default_factory=list)
144 changes: 144 additions & 0 deletions backend/tests/test_skill_display_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,147 @@ def test_decision_survives_relink_wipe(client) -> None:
# スキルは作り直されたが、確定表示名は独立テーブルに残っているので復活する
assert by_name["@aws-sdk/client-s3"]["confirmed_display_name"] == "Amazon S3"
assert by_name["@aws-sdk/client-s3"]["group_id"] == "grp-aws"


# ---- reset(解除) -------------------------------------------------------


def test_reset_requires_auth(client) -> None:
"""未認証は 401。"""
resp = client.request(
"DELETE",
"/api/github-link/skills/display-decisions",
json={"identities": []},
)
assert resp.status_code == 401


def test_reset_rejects_unknown_identity(client) -> None:
"""当該ユーザーの検出済みスキルに無い identity のリセットは 422。"""
headers = auth_header(client, "disp_reset_badid")
uid = _user_id(client, "disp_reset_badid")
GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected())

resp = client.request(
"DELETE",
"/api/github-link/skills/display-decisions",
json={
"identities": [
{"kind": "package", "ecosystem": "npm", "canonical_name": "does-not-exist"}
]
},
headers=headers,
)
assert resp.status_code == 422


def test_reset_removes_decision_and_reverts_to_default(client) -> None:
"""確定行を削除し、確定表示名・グループが機械デフォルト(null)へ戻ること(#496)。"""
headers = auth_header(client, "disp_reset")
uid = _user_id(client, "disp_reset")
GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected())
GitHubSkillDisplayDecisionRepository(client._db_session, uid).upsert_many(
[DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "Amazon S3", "grp-aws")]
)

resp = client.request(
"DELETE",
"/api/github-link/skills/display-decisions",
json={
"identities": [
{"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"}
]
},
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"]
# 確定行が消え、確定フィールドは null(機械デフォルトへ完全リセット)
assert s3["confirmed_display_name"] is None
assert s3["group_id"] is None
assert s3["decision_source"] is None
assert s3["decision_reviewed"] is False

# DB からも消えており、GET でも確定が復活しないこと
remaining = GitHubSkillDisplayDecisionRepository(
client._db_session, uid
).get_for_user()
assert remaining == []


def test_reset_ungroups_all_members(client) -> None:
"""グループ全メンバーの identity を渡すと畳み込みが解ける(バラす / #496)。"""
headers = auth_header(client, "disp_reset_group")
uid = _user_id(client, "disp_reset_group")
# 2 つの package を同一 group_id で確定(N:1 畳み込み)
detected = [
DetectedSkill(
kind="package",
canonical_name=name,
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",
)
],
)
for name in ("@aws-sdk/client-s3", "@aws-sdk/client-eventbridge")
]
GitHubSkillRepository(client._db_session, uid).replace_for_user(detected)
GitHubSkillDisplayDecisionRepository(client._db_session, uid).upsert_many(
[
DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "AWS", "grp-aws"),
DisplayDecisionInput(
"package", "npm", "@aws-sdk/client-eventbridge", "AWS", "grp-aws"
),
]
)

resp = client.request(
"DELETE",
"/api/github-link/skills/display-decisions",
json={
"identities": [
{"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"},
{
"kind": "package",
"ecosystem": "npm",
"canonical_name": "@aws-sdk/client-eventbridge",
},
]
},
headers=headers,
)
assert resp.status_code == 200
by_name = {s["canonical_name"]: s for s in resp.json()["skills"]}
for name in ("@aws-sdk/client-s3", "@aws-sdk/client-eventbridge"):
assert by_name[name]["confirmed_display_name"] is None
assert by_name[name]["group_id"] is None


def test_reset_is_idempotent_for_missing_decision(client) -> None:
"""確定行が無い identity のリセットは 200・件数 0 で冪等(存在スキルなら 422 にしない)。"""
headers = auth_header(client, "disp_reset_idem")
uid = _user_id(client, "disp_reset_idem")
GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected())

resp = client.request(
"DELETE",
"/api/github-link/skills/display-decisions",
json={
"identities": [
{"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"}
]
},
headers=headers,
)
assert resp.status_code == 200
by_name = {s["canonical_name"]: s for s in resp.json()["skills"]}
assert by_name["@aws-sdk/client-s3"]["confirmed_display_name"] is None
4 changes: 3 additions & 1 deletion docs/adr/0016-github-skill-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ D3・D8 で「表示名・粒度の畳み込み(`@aws-sdk/client-eventbridge`
- **(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)を既存のまま流用する。
- **(g) 確定の解除(リセット / バラす)を提供する(2026-07 追加 / #496)**: 一度確定した表示名・畳み込みを機械デフォルトへ戻す導線を設ける。`DELETE /skills/display-decisions`(body に identity 群)が当該 Layer 3 確定行を削除し、解決順は自動的に「機械 `display_name` > canonical」へ戻る。同一グループの全メンバー identity をまとめて渡せば畳み込みも解ける(バラす)。authz は confirm と同じ(identity は当該ユーザーの検出済みスキルに属すること/存在しない確定行の指定は冪等に無視)。upsert(削除しない)設計では確定行が残り続け完全リセットできなかったギャップ(#496)を埋める。web はスキル一覧の確定済みチップに「解除」ボタンを出す。

**スコープ外(残課題)**: 確定値のバージョニング / 監査履歴、group をまたぐ evidence の重み再計算、agent による足切り(言語の上位 N 位)提案(D8 で別項)。動的 module 解決・Tier2 IaC は D10 の残課題のまま。
**スコープ外(残課題)**: 確定値のバージョニング / 監査履歴、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 新規)。

Expand Down Expand Up @@ -235,6 +236,7 @@ D3・D8 で「表示名・粒度の畳み込み(`@aws-sdk/client-eventbridge`
## 改訂履歴

- **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**: D11 に**確定の解除(リセット / 畳み込みをバラす)導線を追加(#496・D11 項 (g))**。`DELETE /skills/display-decisions`(identity 群指定)で Layer 3 確定行を削除し機械デフォルトへ完全に戻す(グループ全メンバー指定で畳み込みも解除)。upsert(削除しない)設計で確定行が残り続け完全リセットできなかったギャップを解消。web はスキル一覧の確定済みチップに「解除」ボタンを出す。schema 追加(`SkillDisplayResetRequest`)で `make codegen-types` 再生成。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 は不変。
Expand Down
82 changes: 82 additions & 0 deletions web/e2e/github-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,86 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ"
await page.getByRole("button", { name: "この内容で確定" }).click();
await expect(page.getByText("Amazon S3")).toBeVisible();
});

test("確定済みスキルの「解除」で機械デフォルト(canonical 名)に戻る(D11 / #496)", 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 },
},
}),
}),
);
// 初期一覧: 確定済み(confirmed_display_name あり)→ チップに確定名と「解除」が出る
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: "Amazon S3",
group_id: null,
decision_source: "human",
decision_reviewed: true,
evidence: [],
proficiency: null,
},
],
}),
}),
);
// 解除(DELETE)後の一覧: confirmed_display_name が null に戻る
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: null,
group_id: null,
decision_source: null,
decision_reviewed: false,
evidence: [],
proficiency: null,
},
],
}),
}),
);

await page.goto("/github_link");
await waitForAuthenticatedLayout(page);

// 確定済みなので確定表示名がチップに出る
await expect(page.getByText("Amazon S3")).toBeVisible();

// 「解除」→ 機械デフォルト(canonical 名)に戻る
await page
.getByRole("button", { name: "Amazon S3 の表示名確定を解除" })
.click();
await expect(page.getByText("@aws-sdk/client-s3")).toBeVisible();
await expect(page.getByText("Amazon S3")).toHaveCount(0);
});
});
Loading
Loading