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
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 3 additions & 1 deletion backend/app/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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(無料)に切り替えるか、クレジットを追加してください。",
Expand Down
8 changes: 7 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
ResumeProjectTechnologyStack,
ResumeQualification,
)
from .skill import GitHubSkill, GitHubSkillEvidence, GitHubSkillProficiency
from .skill import (
GitHubSkill,
GitHubSkillDisplayDecision,
GitHubSkillEvidence,
GitHubSkillProficiency,
)
from .user import User

__all__ = [
Expand All @@ -27,6 +32,7 @@
"CreditTransaction",
"GitHubLinkCache",
"GitHubSkill",
"GitHubSkillDisplayDecision",
"GitHubSkillEvidence",
"GitHubSkillProficiency",
"MQualification",
Expand Down
56 changes: 56 additions & 0 deletions backend/app/models/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
23 changes: 23 additions & 0 deletions backend/app/prompts/agent_skill_display.md
Original file line number Diff line number Diff line change
@@ -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. 各グループに一般に通用する正式名称の表示名を付ける(不確実なら生の名前のまま)
71 changes: 70 additions & 1 deletion backend/app/repositories/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 連携スキルの読み書き。"""

Expand Down Expand Up @@ -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()
12 changes: 2 additions & 10 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions backend/app/routers/github_link/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@
(.claude/rules/common/duplication.md の Backend ヒエラルキー「routers/<scope>/_responses.py」)。
"""

from ...models import GitHubSkillDisplayDecision
from ...schemas.github_skill import (
GitHubSkillItem,
SkillEvidence,
SkillProficiency,
)


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(
Expand All @@ -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,
Expand Down
Loading
Loading