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
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Makefile は `nix develop --command bash -c "..."` でラップ済み。AI は
|---|---|
| CI 相当一括 | `make ci` (= `lint + test + build-web`) |
| Backend lint | `make lint-backend` |
| Backend 型チェック | `make typecheck-backend` (pyright。`make lint` に含まれる) |
| Backend test | `make test-backend` |
| Frontend lint | `make lint-web` |
| Frontend test | `make test-web` |
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ jobs:
working-directory: backend
run: uv run ruff check app tests alembic_migrations

# 型チェック(pyright)。pyright は pyproject [tool.pyright] の venv=".venv" を参照するため、
# 直前の uv pip install で .venv に依存が入っている必要がある(このジョブで担保済み)。
# バージョンは Makefile の typecheck-backend と揃えてピン留めする(ローカル/CI の drift 防止)。
- name: Type check backend (pyright)
working-directory: backend
run: uvx pyright@1.1.411 app tests alembic_migrations

- name: Run backend tests
working-directory: backend
run: uv run pytest -q tests
Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
setup install-hooks install-backend install-web generate-keys \
dev dev-build dev-down dev-amd64 dev-amd64-build dev-web preview-web dev-proxy dev-proxy-only stripe-webhook \
test test-backend test-web \
lint lint-backend lint-web lint-web-messages lint-env-keys lint-fix \
lint lint-backend typecheck-backend lint-web lint-web-messages lint-env-keys lint-fix \
format format-check \
ci \
dupe-check dupe-check-html dupe-clean \
Expand Down Expand Up @@ -40,6 +40,7 @@ help:
@echo " test-web Frontend: vitest"
@echo " lint 全リント (backend + web)"
@echo " lint-backend Backend: ruff check"
@echo " typecheck-backend Backend: pyright 型チェック"
@echo " lint-web Frontend: eslint"
@echo " lint-web-messages Frontend: setError等にリテラル日本語が渡っていないか検知"
@echo " lint-env-keys env名/エラーコードの SSoT drift を検知 (env_keys.py↔compose, errors.py↔errorCodes.ts)"
Expand Down Expand Up @@ -141,11 +142,17 @@ test-backend:
test-web:
nix develop --command bash -c "cd web && npm test"

lint: lint-backend lint-web lint-web-messages lint-env-keys
lint: lint-backend typecheck-backend lint-web lint-web-messages lint-env-keys

lint-backend:
nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check app tests alembic_migrations"

# Backend 型チェック(pyright)。pyproject [tool.pyright] の venv=".venv" を参照するため
# 事前に make install-backend で backend/.venv に依存を入れておくこと。
# バージョンは CI(.github/workflows/test.yml)と揃えてピン留めする(drift 防止)。
typecheck-backend:
nix develop --command bash -c "cd backend && uvx pyright@1.1.411 app tests alembic_migrations"

lint-web:
nix develop --command bash -c "cd web && npm run lint"

Expand Down
6 changes: 4 additions & 2 deletions backend/app/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import Literal, cast
from urllib.parse import urlparse

from . import env_keys
Expand Down Expand Up @@ -93,7 +94,7 @@ def get_cookie_secure() -> bool:
return True


def get_cookie_samesite() -> str:
def get_cookie_samesite() -> Literal["lax", "strict", "none"]:
default = "lax"
origins = get_cors_origins()
if origins and not all(_is_loopback_origin(origin) for origin in origins):
Expand All @@ -102,7 +103,8 @@ def get_cookie_samesite() -> str:
value = os.getenv(env_keys.COOKIE_SAMESITE, default).strip().lower()
if value not in {"lax", "strict", "none"}:
raise RuntimeError("COOKIE_SAMESITE must be one of: lax, strict, none")
return value
# 上の検証で値域は保証済み。set_cookie の samesite 引数が要求する Literal へ絞る。
return cast(Literal["lax", "strict", "none"], value)


def get_admin_token() -> str:
Expand Down
7 changes: 5 additions & 2 deletions backend/app/repositories/master_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ def list_by_category(self, category: str) -> list[MTechnologyStack]:
)
return list(self.db.scalars(statement).all())

def create(self, category: str, name: str, sort_order: int = 0) -> MTechnologyStack:
# category 列を持つため基底の create/update をあえて拡張シグネチャで上書きする。
# MTechnologyStackRepository 型で直接呼ぶ箇所のみが使い、基底経由の多態呼び出しは無いため
# Liskov 非互換は意図的(reportIncompatibleMethodOverride を局所抑制)。
def create(self, category: str, name: str, sort_order: int = 0) -> MTechnologyStack: # pyright: ignore[reportIncompatibleMethodOverride]
item = MTechnologyStack(category=category, name=name, sort_order=sort_order)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

def update(
def update( # pyright: ignore[reportIncompatibleMethodOverride]
self, item_id: str, category: str, name: str, sort_order: int = 0
) -> MTechnologyStack | None:
item = self.db.scalar(select(MTechnologyStack).where(MTechnologyStack.id == item_id))
Expand Down
7 changes: 5 additions & 2 deletions backend/app/repositories/notification.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""通知リポジトリ。"""

from typing import Any, cast

from sqlalchemy import select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session

from ..models.notification import Notification
Expand Down Expand Up @@ -41,7 +44,7 @@ def mark_read(self, notification_id: str) -> bool:
.where(Notification.id == notification_id, Notification.user_id == self.user_id)
.values(is_read=True)
)
result = self.db.execute(stmt)
result = cast("CursorResult[Any]", self.db.execute(stmt))
self.db.commit()
return result.rowcount > 0

Expand All @@ -52,7 +55,7 @@ def mark_all_read(self) -> int:
.where(Notification.user_id == self.user_id, Notification.is_read.is_(False))
.values(is_read=True)
)
result = self.db.execute(stmt)
result = cast("CursorResult[Any]", self.db.execute(stmt))
self.db.commit()
return result.rowcount

Expand Down
14 changes: 8 additions & 6 deletions backend/app/repositories/resume.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

from sqlalchemy.orm import selectinload

from ..core.date_utils import parse_year_month
Expand Down Expand Up @@ -39,7 +41,7 @@ class ResumeRepository(SingleUserDocumentRepository):
selectinload(Resume.qualification_rows),
)

def _apply_payload(self, entity: Resume, payload: dict[str, object]) -> None:
def _apply_payload(self, entity: Resume, payload: dict[str, Any]) -> None:
entity.full_name = payload["full_name"]
entity.email = payload["email"]
entity.github_url = payload.get("github_url", "")
Expand Down Expand Up @@ -67,7 +69,7 @@ def _apply_payload(self, entity: Resume, payload: dict[str, object]) -> None:
for index, item in enumerate(sorted_qualifications)
]

def _build_experience_row(self, index: int, payload: dict[str, object]) -> ResumeExperience:
def _build_experience_row(self, index: int, payload: dict[str, Any]) -> ResumeExperience:
return ResumeExperience(
sort_order=index,
company=payload["company"],
Expand All @@ -88,7 +90,7 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum
],
)

def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient:
def _build_client_row(self, index: int, payload: dict[str, Any]) -> ResumeClient:
projects = list(payload.get("projects", []))
sorted_projects = sorted(projects, key=self._project_sort_key)
vacation_start = payload.get("vacation_start_date") or ""
Expand All @@ -109,7 +111,7 @@ def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeCli
)

@staticmethod
def _project_sort_key(proj: dict[str, object]) -> tuple:
def _project_sort_key(proj: dict[str, Any]) -> tuple:
"""複数期間を持つプロジェクトを sort_by_period_desc と同じ降順ロジックでソートする。"""
from datetime import date as _date

Expand All @@ -131,10 +133,10 @@ def _parse(val: object) -> _date | None:
if is_any_current:
return (0, _date.max - effective_start)
ends = [_parse(p.get("end_date")) for p in periods if p.get("end_date")]
effective_end = max(ends, default=_date.min)
effective_end = max((e for e in ends if e), default=_date.min)
return (1, _date.max - effective_end, _date.max - effective_start)

def _build_project_row(self, index: int, payload: dict[str, object]) -> ResumeProject:
def _build_project_row(self, index: int, payload: dict[str, Any]) -> ResumeProject:
team = payload.get("team", {})
periods = list(payload.get("periods", []))
return ResumeProject(
Expand Down
5 changes: 0 additions & 5 deletions backend/app/routers/blog/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,6 @@ async def add_account(
status_code=502,
detail=get_error("blog.account_check_failed"),
) from exc
except BlogAccountNotFoundError as exc:
raise HTTPException(
status_code=404,
detail=get_error("blog.account_not_found"),
) from exc


@router.patch("/accounts/{platform}", response_model=BlogAccountResponse)
Expand Down
8 changes: 7 additions & 1 deletion backend/app/routers/github_link/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ...schemas.github_link import (
CachedGitHubLinkResponse,
GitHubLinkRequest,
GitHubLinkResponse,
ProgressResponse,
)
from ...schemas.github_skill import GitHubSkillsResponse
Expand Down Expand Up @@ -69,8 +70,13 @@ def get_cache(
cache = GitHubLinkCacheRepository(db).get_by_user(user.id)
if not cache:
return CachedGitHubLinkResponse()
# cache.result は GitHubLinkResponse(...).model_dump() を保存した JSON(dict | None)。
# Pydantic v2 は dict を受け取ると検証済みモデルへコアース可能なので validate で型を絞る。
result = (
GitHubLinkResponse.model_validate(cache.result) if cache.result is not None else None
)
return CachedGitHubLinkResponse(
result=cache.result,
result=result,
status=cache.status,
error_message=cache.error_message,
error_code=resolve_async_error_code(cache.error_message),
Expand Down
5 changes: 3 additions & 2 deletions backend/app/services/agent/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
AgentOperation,
AgentProjectContext,
ExperienceTarget,
ProjectTarget,
)
from .llm.base import LLMError
from .llm.factory import get_llm_client
Expand Down Expand Up @@ -193,8 +194,8 @@ def _resolve_target_experience(request: AgentChatRequest) -> AgentExperienceCont
def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext:
"""target のインデックスから対象プロジェクトを取り出す。範囲外は専用例外。"""
target = request.target
# scope=project のとき target は schema で必須検証済み
assert target is not None
# scope=project のとき target は schema で ProjectTarget(client_index/project_index 必須)に検証済み
assert isinstance(target, ProjectTarget)
try:
return (
request.resume.experiences[target.experience_index]
Expand Down
8 changes: 5 additions & 3 deletions backend/app/services/agent/llm/anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
"""

import json
from typing import Any, cast

import anthropic
from anthropic import AsyncAnthropicVertex
from anthropic import AsyncAnthropicVertex # pyright: ignore[reportPrivateImportUsage]

from ....core import settings
from ..output_schema import TOOL_NAME, build_tool_definition
Expand Down Expand Up @@ -47,10 +48,11 @@ async def generate(
max_tokens=_MAX_TOKENS,
temperature=_TEMPERATURE,
system=system_prompt,
messages=messages,
# 履歴契約の dict 形は SDK の MessageParam と互換だが静的には別型のため境界で cast。
messages=cast(Any, messages),
# tool use 強制で出力構造をスキーマに従わせる(JSON mode は使わない)。
# maxLength は API では強制されないため、上限超過は呼び出し側で破棄する
tools=[build_tool_definition(output_schema)],
tools=[cast(Any, build_tool_definition(output_schema))],
tool_choice={"type": "tool", "name": TOOL_NAME},
)
except (
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/agent/llm/google_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
スキーマに従う JSON のシリアライズ文字列として返す。
"""

from typing import Any, cast

from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
Expand Down Expand Up @@ -59,7 +61,8 @@ async def generate(
)
try:
response = await self._client.aio.models.generate_content(
model=model_id, contents=contents, config=config
# list[Content] は ContentListUnion に含まれるが静的には別型のため境界で cast。
model=model_id, contents=cast(Any, contents), config=config
)
except genai_errors.APIError as exc:
raise wrap_api_error("Google", exc) from exc
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/agent/llm/openai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
応答テキストは Anthropic / Gemini と同じくスキーマに従う JSON のシリアライズ文字列で返す。
"""

from typing import Any, cast

import openai

from ....core import settings
Expand Down Expand Up @@ -37,7 +39,8 @@ async def generate(
response = await self._client.chat.completions.create(
model=model_id,
temperature=_TEMPERATURE,
messages=full_messages,
# role/content の dict 形は SDK の ChatCompletionMessageParam と互換だが静的には別型。
messages=cast(Any, full_messages),
response_format={
"type": "json_schema",
"json_schema": {
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/agent/output_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
maxLength は JSON Schema 仕様どおり Unicode 文字数(日本語の len() と一致)。
"""

from typing import cast

TOOL_NAME = "propose_revision"
TOOL_DESCRIPTION = "職務経歴書フィールドの改善案・説明・次の依頼候補を返す"

Expand Down Expand Up @@ -103,7 +105,8 @@ def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False
strip_keys = {"maxLength", "maxItems"}
if drop_additional_properties:
strip_keys = strip_keys | {"additionalProperties"}
portable = _strip_constraints(schema, strip_keys)
# _strip_constraints は object を返すが、dict 入力に対しては dict を返す(下で .get / 返却に使う)。
portable = cast(dict, _strip_constraints(schema, strip_keys))
operations = portable.get("properties", {}).get("operations", {})
items = operations.get("items", {})
branches = items.get("oneOf")
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/intelligence/skills/imports/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@


class GoImportScanner:
extensions = (".go",)
ecosystem = "go"
extensions: tuple[str, ...] = (".go",)
ecosystem: str = "go"

def scan(self, content: str) -> set[str]:
paths: set[str] = set()
Expand Down
13 changes: 11 additions & 2 deletions backend/app/services/intelligence/skills/imports/js_ts.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,17 @@ def _package_of(spec: str) -> str | None:


class JsTsImportScanner:
extensions = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")
ecosystem = "npm"
extensions: tuple[str, ...] = (
".js",
".jsx",
".ts",
".tsx",
".mjs",
".cjs",
".mts",
".cts",
)
ecosystem: str = "npm"

def scan(self, content: str) -> set[str]:
names: set[str] = set()
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/intelligence/skills/imports/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ def _top_level(module: str) -> str:


class PythonImportScanner:
extensions = (".py", ".pyi")
ecosystem = "pypi"
extensions: tuple[str, ...] = (".py", ".pyi")
ecosystem: str = "pypi"

def scan(self, content: str) -> set[str]:
return {_top_level(m) for m in _IMPORT_RE.findall(content)}
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/intelligence/skills/imports/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@


class RustImportScanner:
extensions = (".rs",)
ecosystem = "cargo"
extensions: tuple[str, ...] = (".rs",)
ecosystem: str = "cargo"

def scan(self, content: str) -> set[str]:
names = set(_USE_RE.findall(content)) | set(_EXTERN_RE.findall(content))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@


class CargoTomlParser:
filenames = ("Cargo.toml",)
ecosystem = "cargo"
filenames: tuple[str, ...] = ("Cargo.toml",)
ecosystem: str = "cargo"

def parse(self, content: str) -> list[PackageDeclaration]:
try:
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/intelligence/skills/manifests/go_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@


class GoModParser:
filenames = ("go.mod",)
ecosystem = "go"
filenames: tuple[str, ...] = ("go.mod",)
ecosystem: str = "go"

def parse(self, content: str) -> list[PackageDeclaration]:
declarations: list[PackageDeclaration] = []
Expand Down
Loading
Loading