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
2 changes: 2 additions & 0 deletions backend/app/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .base import BaseMasterRepository, SingleUserDocumentRepository
from .billing import BillingRepository
from .blog import BlogAccountRepository, BlogArticleRepository
from .github_link import GitHubLinkCacheRepository
from .master_data import MQualificationRepository, MTechnologyStackRepository
from .resume import ResumeRepository
from .skill import GitHubSkillRepository
Expand All @@ -13,6 +14,7 @@
"BillingRepository",
"BlogAccountRepository",
"BlogArticleRepository",
"GitHubLinkCacheRepository",
"GitHubSkillRepository",
"MQualificationRepository",
"MTechnologyStackRepository",
Expand Down
57 changes: 57 additions & 0 deletions backend/app/repositories/github_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""GitHub 連携キャッシュ(``GitHubLinkCache``)のデータアクセス。

``GitHubLinkCache`` はユーザーあたり 1 件のレコードで、``user_id`` を一意境界とする。
取得クエリ(``filter_by(user_id=...)``)を本リポジトリへ集約し、router / service /
handler / context_builder からの直クエリ散在を防ぐ。``user_id`` スコープは
IDOR 防止の認可境界であり、1 箇所に閉じ込めることで条件追加時の漏れを防ぐ。
"""

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from ..models import GitHubLinkCache


class GitHubLinkCacheRepository:
"""ユーザーの GitHub 連携キャッシュの読み取り・作成。

セッションはコンストラクタで受け取る。GitHub 連携の実行経路では libSQL の
idle stream timeout 対策でフェーズごとにセッションを開閉するため、本リポジトリは
セッションを保持せず呼び出し側が渡したものをそのまま使う。
"""

def __init__(self, db: Session):
self.db = db

def get_by_user(self, user_id: str) -> GitHubLinkCache | None:
"""ユーザーのキャッシュを取得する。存在しなければ ``None``。"""
return self.db.scalar(
select(GitHubLinkCache).where(GitHubLinkCache.user_id == user_id)
)

def get_or_create(self, user_id: str) -> GitHubLinkCache:
"""ユーザーのキャッシュを取得し、存在しなければ作成して flush する。

並列リクエストが ``user_id`` の一意制約で衝突した場合は rollback して再取得する。
他セッションが先に commit していたケースを想定し、再 SELECT が ``None`` を返したら
明示的に ``RuntimeError`` を上げて non-Optional な戻り値契約を守る
(.claude/rules/backend/database.md「IntegrityError 後の再 SELECT は None を判定する」)。
"""
cache = self.get_by_user(user_id)
if cache is not None:
return cache

cache = GitHubLinkCache(user_id=user_id)
self.db.add(cache)
try:
self.db.flush()
except IntegrityError:
self.db.rollback()
existing = self.get_by_user(user_id)
if existing is None:
raise RuntimeError(
f"GitHubLinkCache の作成と再取得に失敗しました (user_id={user_id})"
) from None
return existing
return cache
5 changes: 5 additions & 0 deletions backend/app/routers/github_link/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""GitHub 連携 API パッケージ。"""

from .endpoints import router

__all__ = ["router"]
47 changes: 47 additions & 0 deletions backend/app/routers/github_link/_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""GitHub 連携 API の ORM → レスポンススキーマ変換。

HTTP 出力整形(プレゼンテーション層)を endpoints から分離する
(.claude/rules/common/duplication.md の Backend ヒエラルキー「routers/<scope>/_responses.py」)。
"""

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


def to_skill_item(skill) -> GitHubSkillItem:
"""ORM の GitHubSkill を API スキーマへ変換する。"""
proficiency = None
if skill.proficiency is not None:
proficiency = SkillProficiency(
self_assessed_level=skill.proficiency.self_assessed_level,
narrative=skill.proficiency.narrative,
duration_months=skill.proficiency.duration_months,
scale=skill.proficiency.scale,
source=skill.proficiency.source,
reviewed=skill.proficiency.reviewed,
)
return GitHubSkillItem(
kind=skill.kind,
canonical_name=skill.canonical_name,
# 言語は ecosystem を "" で持つので API では null に正規化する
ecosystem=skill.ecosystem or None,
parent=skill.parent,
display_name=skill.display_name,
evidence=[
SkillEvidence(
repo_full_name=ev.repo_full_name,
repo_url=ev.repo_url,
signal_source=ev.signal_source,
confidence=ev.confidence,
language_bytes=ev.language_bytes,
dependency_kind=ev.dependency_kind,
manifest_path=ev.manifest_path,
partial_scan=ev.partial_scan,
)
for ev in skill.evidence
],
proficiency=proficiency,
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,24 @@
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from sqlalchemy.orm import Session

from ..core.errors import ErrorCode, raise_app_error, resolve_async_error_code
from ..core.messages import get_error
from ..core.security.auth import get_current_user
from ..core.security.dependencies import limiter
from ..db import get_db
from ..models import GitHubLinkCache, User
from ..repositories.skill import GitHubSkillRepository
from ..schemas.github_link import (
from ...core.errors import ErrorCode, raise_app_error, resolve_async_error_code
from ...core.messages import get_error
from ...core.security.auth import get_current_user
from ...core.security.dependencies import limiter
from ...db import get_db
from ...models import User
from ...repositories.github_link import GitHubLinkCacheRepository
from ...repositories.skill import GitHubSkillRepository
from ...schemas.github_link import (
CachedGitHubLinkResponse,
GitHubLinkRequest,
ProgressResponse,
)
from ..schemas.github_skill import (
GitHubSkillItem,
GitHubSkillsResponse,
SkillEvidence,
SkillProficiency,
)
from ..schemas.shared import TaskAcceptedResponse, TaskStatusResponse
from ..services.intelligence.github_link_service import get_or_create_github_link_cache
from ..services.tasks import AsyncTaskCacheService, TaskType
from ...schemas.github_skill import GitHubSkillsResponse
from ...schemas.shared import TaskAcceptedResponse, TaskStatusResponse
from ...services.intelligence.github_link_service import get_or_create_github_link_cache
from ...services.tasks import AsyncTaskCacheService, TaskType
from ._responses import to_skill_item

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -69,7 +66,7 @@ def get_cache(
db: Session = Depends(get_db),
):
"""保存済みの連携結果を取得する。"""
cache = db.query(GitHubLinkCache).filter_by(user_id=user.id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user.id)
if not cache:
return CachedGitHubLinkResponse()
return CachedGitHubLinkResponse(
Expand All @@ -89,48 +86,12 @@ async def get_link_progress(

Redis にデータがない場合(タスク未開始・Redis 障害)は step_index=0 のデフォルトを返す。
"""
from ..services.progress_service import get_progress
from ...services.progress_service import get_progress

data = await get_progress(user.id)
return ProgressResponse(**data)


def _to_skill_item(skill) -> GitHubSkillItem:
"""ORM の GitHubSkill を API スキーマへ変換する。"""
proficiency = None
if skill.proficiency is not None:
proficiency = SkillProficiency(
self_assessed_level=skill.proficiency.self_assessed_level,
narrative=skill.proficiency.narrative,
duration_months=skill.proficiency.duration_months,
scale=skill.proficiency.scale,
source=skill.proficiency.source,
reviewed=skill.proficiency.reviewed,
)
return GitHubSkillItem(
kind=skill.kind,
canonical_name=skill.canonical_name,
# 言語は ecosystem を "" で持つので API では null に正規化する
ecosystem=skill.ecosystem or None,
parent=skill.parent,
display_name=skill.display_name,
evidence=[
SkillEvidence(
repo_full_name=ev.repo_full_name,
repo_url=ev.repo_url,
signal_source=ev.signal_source,
confidence=ev.confidence,
language_bytes=ev.language_bytes,
dependency_kind=ev.dependency_kind,
manifest_path=ev.manifest_path,
partial_scan=ev.partial_scan,
)
for ev in skill.evidence
],
proficiency=proficiency,
)


@router.get("/skills", response_model=GitHubSkillsResponse)
def get_skills(
user: User = Depends(get_current_user),
Expand All @@ -141,7 +102,7 @@ def get_skills(
連携がまだ実行されていない場合は空配列を返す。
"""
skills = GitHubSkillRepository(db, user.id).list_for_user()
return GitHubSkillsResponse(skills=[_to_skill_item(s) for s in skills])
return GitHubSkillsResponse(skills=[to_skill_item(s) for s in skills])


@router.get("/cache/status", response_model=TaskStatusResponse)
Expand All @@ -150,7 +111,7 @@ def get_cache_status(
db: Session = Depends(get_db),
):
"""連携ステータスを返す(軽量ポーリング用)。"""
cache = db.query(GitHubLinkCache).filter_by(user_id=user.id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user.id)
if not cache:
return TaskStatusResponse(status="completed")
return TaskStatusResponse(
Expand Down Expand Up @@ -213,7 +174,7 @@ async def retry_github_link(
``dead_letter`` 状態のキャッシュのみ再実行可能。
``retry_count`` を 0 にリセットし、ステータスを ``pending`` に戻して再ディスパッチする。
"""
cache = db.query(GitHubLinkCache).filter_by(user_id=user.id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user.id)
if not cache:
raise_app_error(
status_code=404,
Expand Down
28 changes: 18 additions & 10 deletions backend/app/services/agent/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,24 @@ async def run_agent_chat(
input_tokens = 0
output_tokens = 0

async def _generate_and_account(call_messages: list[dict], *, label: str):
"""LLM を呼び出し、合算トークンへ加算してから生応答を返す。

課金漏れ防止(ADR-0012)のため、初回・リトライの両方でトークン加算経路を
この 1 箇所に集約する。``client.generate`` が失敗した場合は加算前に例外が伝播し、
呼び出し元で確定済みの合算使用量を載せて再 raise する(使用量の二重計上を防ぐ)。
"""
nonlocal input_tokens, output_tokens
call_result = await client.generate(
system_prompt, call_messages, output_schema, model_id
)
input_tokens += call_result.input_tokens
output_tokens += call_result.output_tokens
logger.debug("Agent LLM %s応答(パース前): len=%d", label, len(call_result.text))
return call_result

# 1 回目の呼び出し。パースまで成功すればここで確定して返す。
result = await client.generate(system_prompt, messages, output_schema, model_id)
input_tokens += result.input_tokens
output_tokens += result.output_tokens
logger.debug("Agent LLM 生応答(パース前): len=%d", len(result.text))
result = await _generate_and_account(messages, label="生")
try:
response = _parse_response(result.text, request.scope)
return AgentChatResult(
Expand All @@ -331,17 +344,12 @@ async def run_agent_chat(

# リトライ呼び出し(1 回のみ)。以降は失敗時も合算使用量を載せて伝播する。
try:
result = await client.generate(
system_prompt, retry_messages, output_schema, model_id
)
result = await _generate_and_account(retry_messages, label="リトライ")
except LLMError as retry_exc:
# リトライ呼び出し自体が失敗。1 回目の API 原価は発生済みのため使用量を
# 載せて伝播し、router 側で課金を確定させる(課金漏れを防ぐ / ADR-0012)
retry_exc.usage = _make_usage(request, input_tokens, output_tokens)
raise
input_tokens += result.input_tokens
output_tokens += result.output_tokens
logger.debug("Agent LLM リトライ応答(パース前): len=%d", len(result.text))
try:
response = _parse_response(result.text, request.scope)
except AgentResponseParseError as retry_exc:
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/agent/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

from sqlalchemy.orm import Session

from ...models.cache import GitHubLinkCache
from ...repositories.blog import BlogArticleRepository
from ...repositories.github_link import GitHubLinkCacheRepository
from ...services.blog.scorer import blog_articles_to_score_dicts, calculate_blog_score

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -63,7 +63,7 @@ def build_reference_context(db: Session, user_id: str, scope: str) -> dict | Non
def _build_github_context(db: Session, user_id: str) -> dict | None:
"""GitHubLinkCache から圧縮済み GitHub コンテキストを生成する。"""
try:
cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user_id)
if not cache or cache.status != "completed" or not cache.result:
return None

Expand Down
20 changes: 10 additions & 10 deletions backend/app/services/intelligence/github_link_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ...core.logging_utils import get_logger
from ...core.messages import get_error
from ...models import GitHubLinkCache
from ...repositories.github_link import GitHubLinkCacheRepository
from ...repositories.skill import GitHubSkillRepository
from ..progress_service import set_progress
from ..tasks.exceptions import NonRetryableError
Expand All @@ -36,13 +37,12 @@ def _now() -> datetime:


def get_or_create_github_link_cache(db: Session, user_id: str) -> GitHubLinkCache:
"""ユーザーの GitHubLinkCache レコードを取得し、存在しなければ作成する。"""
cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
if not cache:
cache = GitHubLinkCache(user_id=user_id)
db.add(cache)
db.flush()
return cache
"""ユーザーの GitHubLinkCache レコードを取得し、存在しなければ作成する。

取得・作成ロジックは GitHubLinkCacheRepository に集約済み。本関数は既存呼び出し元
(router)の入口を維持するための薄いラッパ。
"""
return GitHubLinkCacheRepository(db).get_or_create(user_id)


async def run_github_link(session_factory: SessionFactory, payload: dict) -> None:
Expand All @@ -64,7 +64,7 @@ async def run_github_link(session_factory: SessionFactory, payload: dict) -> Non

# ── フェーズA: 検証 + processing マーク ─────────────────────────────────
with session_factory() as db:
cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user_id)
if not cache:
message = "GitHub 連携キャッシュが見つかりません"
logger.error(message, extra={"user_id": user_id})
Expand Down Expand Up @@ -101,7 +101,7 @@ async def _on_repo_fetched(done: int, total: int) -> None:
)
except GitHubUserNotFoundError as exc:
with session_factory() as db:
cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user_id)
if cache:
cache.status = "dead_letter"
cache.error_message = (
Expand Down Expand Up @@ -147,7 +147,7 @@ async def _on_repo_fetched(done: int, total: int) -> None:
# ステップ 4: DB 保存
await set_progress(task_id, 4, _TOTAL_STEPS, "結果を保存中...")
with session_factory() as db:
cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
cache = GitHubLinkCacheRepository(db).get_by_user(user_id)
if not cache:
logger.warning(
"結果書き戻し時にキャッシュが見つかりません",
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/tasks/handlers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from sqlalchemy.orm import Session

from ....models import GitHubLinkCache
from ....repositories.github_link import GitHubLinkCacheRepository
from .base import SessionFactory, TaskHandler


Expand All @@ -13,7 +14,7 @@ def get_record(self, db: Session, payload: dict) -> GitHubLinkCache | None:
user_id = payload.get("user_id")
if not user_id:
return None
return db.query(GitHubLinkCache).filter_by(user_id=user_id).first()
return GitHubLinkCacheRepository(db).get_by_user(user_id)

async def run(self, session_factory: SessionFactory, payload: dict) -> None:
# 循環インポート回避のため遅延 import する
Expand Down
Loading
Loading