From c0b17063c26eec7847ff76c833982706e1c16420 Mon Sep 17 00:00:00 2001 From: Shimpei Ohtani Date: Sat, 26 Sep 2026 16:49:24 +0900 Subject: [PATCH 1/3] fix(time): use JST for "today" defaults and daily aggregation The Today page took its default date/time from the server clock (UTC on Streamlit Community Cloud) while combine_jst_to_utc interprets the input as JST, so entries saved with the default time were recorded 9 hours early and the page opened on the previous day between 0:00 and 8:59 JST. Daily features, ML real-data loading and RecommendationService also bucketed logs by UTC date, counting morning JST logs toward the previous day and disagreeing with the dashboard's JST day boundary. Add health_intelligence.local_time as the single source of the app timezone and route both paths through it. Co-Authored-By: Claude Opus 5.5 --- app/components/formatting.py | 22 +++++++++- app/pages/today.py | 14 ++++--- .../features/daily_features.py | 12 +++--- src/health_intelligence/local_time.py | 28 +++++++++++++ src/health_intelligence/ml/real_data.py | 9 +++-- .../services/recommendation_service.py | 13 +++--- tests/unit/test_daily_features.py | 40 +++++++++++++++++++ 7 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 src/health_intelligence/local_time.py create mode 100644 tests/unit/test_daily_features.py diff --git a/app/components/formatting.py b/app/components/formatting.py index da4e104..45ceadc 100644 --- a/app/components/formatting.py +++ b/app/components/formatting.py @@ -5,9 +5,9 @@ from datetime import UTC, datetime from datetime import date as date_type from datetime import time as time_type -from zoneinfo import ZoneInfo -_JST = ZoneInfo("Asia/Tokyo") +from health_intelligence.local_time import APP_TIMEZONE as _JST +from health_intelligence.local_time import local_today def format_datetime_jst(dt: datetime) -> str: @@ -49,3 +49,21 @@ def jst_time_of(dt: datetime) -> time_type: 編集フォームの`st.time_input`初期値として使う。 """ return dt.astimezone(_JST).time() + + +def jst_today() -> date_type: + """日本時間での今日の日付。`st.date_input`の初期値として使う。 + + `date.today()`は実行環境(Streamlit Community CloudはUTC)の + タイムゾーンに依存し、JST 0:00〜8:59の間は前日になってしまう。 + """ + return local_today() + + +def jst_now_time() -> time_type: + """日本時間での現在時刻(秒以下切り捨て)。`st.time_input`の初期値として使う。 + + `datetime.now().time()`は実行環境のタイムゾーン(UTC)の時刻になり、 + `combine_jst_to_utc`でJSTとして解釈されると9時間ずれて保存される。 + """ + return jst_time_of(datetime.now(UTC)).replace(second=0, microsecond=0) diff --git a/app/pages/today.py b/app/pages/today.py index 3cbaec7..85b5d5d 100644 --- a/app/pages/today.py +++ b/app/pages/today.py @@ -33,7 +33,9 @@ combine_jst_to_utc, format_datetime_jst, jst_date_of, + jst_now_time, jst_time_of, + jst_today, ) from components.goal_task_labels import ( # noqa: E402 DURATION_OPTIONS, @@ -350,7 +352,7 @@ def _render_workout_new_record_section( exercise_templates = frequent_exercise_templates(all_workout_logs) workout_date = st.date_input(t("日付"), value=journal_date_value, key="today_workout_date") - workout_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_workout_time") + workout_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_workout_time") quick_workout_col1, quick_workout_col2 = st.columns([1, 2]) with quick_workout_col1: @@ -819,7 +821,7 @@ def _render_learning_new_record_section( st.rerun(scope="fragment") learning_date = st.date_input(t("日付"), value=journal_date_value, key="today_learning_date") - learning_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_learning_time") + learning_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_learning_time") learning_location = st.selectbox( t("場所"), options=[None, *LearningLocation], @@ -906,7 +908,7 @@ def _render_learning_new_record_section( st.title(":material/today: " + t("今日")) if "today_journal_date" not in st.session_state: - st.session_state["today_journal_date"] = date.today() + st.session_state["today_journal_date"] = jst_today() journal_date: date = st.session_state["today_journal_date"] with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"): @@ -1129,7 +1131,7 @@ def _render_learning_new_record_section( st.markdown(f"**{t('体重')}**") with st.form("today_weight_form"): weight_date = st.date_input(t("日付"), value=journal_date, key="today_weight_date") - weight_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_weight_time") + weight_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_weight_time") weight_kg = st.number_input(t("体重(kg)"), min_value=0.0, step=0.1) submitted_weight = st.form_submit_button(t("体重記録を保存する"), type="primary") @@ -1174,7 +1176,7 @@ def _render_learning_new_record_section( st.markdown(f"**{t('体調(5軸)')}**") with st.form("today_health_form"): health_date = st.date_input(t("日付"), value=journal_date, key="today_health_date") - health_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_health_time") + health_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_health_time") axes = _condition_axis_sliders(key_prefix="today_health_new", initial=None) symptoms_text = st.text_input( t("症状(カンマ区切り、任意)"), placeholder=t("例: 頭痛, 倦怠感") @@ -1305,7 +1307,7 @@ def _render_learning_new_record_section( with st.form("today_meal_form"): meal_date = st.date_input(t("日付"), value=journal_date, key="today_meal_date") - meal_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_meal_time") + meal_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_meal_time") meal_type = st.selectbox( t("食事区分"), options=list(MealType), diff --git a/src/health_intelligence/features/daily_features.py b/src/health_intelligence/features/daily_features.py index 12577b4..0c6c989 100644 --- a/src/health_intelligence/features/daily_features.py +++ b/src/health_intelligence/features/daily_features.py @@ -11,6 +11,7 @@ from datetime import date from health_intelligence.domain.models import MealLog, SleepLog, WeightLog, WorkoutLog +from health_intelligence.local_time import local_date_of @dataclass(frozen=True, kw_only=True, slots=True) @@ -39,17 +40,18 @@ def compute_daily_features( 各引数は呼び出し側であらかじめ対象日でフィルタ済みのものを渡しても、 フィルタ前の全期間分を渡してもよい(この関数内で`target_date`との - 突合を行う)。 + 突合を行う)。`target_date`は日本時間の日付として扱い、各ログの + UTC日時もJSTの日付へ変換してから突合する(local_time.py参照)。 workout_duration_minutesは有酸素運動(CARDIO)のduration_minutesの 合計のみを対象とする。筋力トレーニング(STRENGTH)は所要時間を 記録していないため計上されない(workout_features.pyの total_strength_volume_kgで別途評価する)。 """ - day_meals = [m for m in meal_logs if m.consumed_at.date() == target_date] - day_sleeps = [s for s in sleep_logs if s.wake_time.date() == target_date] - day_workouts = [w for w in workout_logs if w.performed_at.date() == target_date] - day_weights = [w for w in weight_logs if w.recorded_at.date() == target_date] + day_meals = [m for m in meal_logs if local_date_of(m.consumed_at) == target_date] + day_sleeps = [s for s in sleep_logs if local_date_of(s.wake_time) == target_date] + day_workouts = [w for w in workout_logs if local_date_of(w.performed_at) == target_date] + day_weights = [w for w in weight_logs if local_date_of(w.recorded_at) == target_date] calories = sum(m.calories for m in day_meals) if day_meals else None protein_g = sum(m.protein_g for m in day_meals) if day_meals else None diff --git a/src/health_intelligence/local_time.py b/src/health_intelligence/local_time.py new file mode 100644 index 0000000..1d3ad82 --- /dev/null +++ b/src/health_intelligence/local_time.py @@ -0,0 +1,28 @@ +"""ユーザーの生活時間帯(日本時間)に関するヘルパー。 + +DomainModel/DBの日時はタイムゾーン付きUTCで統一している +(database/schema.pyのDateTime(timezone=True)参照)。一方で「何日の +記録か」はユーザーの生活時間帯(JST)で区切る必要があり、UTCのまま +`.date()`を取るとJST 0:00〜8:59の記録(朝食・起床・朝の体重測定等)が +前日扱いになる。日次集計・「今日」の判定はこのモジュールを経由する。 + +実行環境(Streamlit Community Cloud等)のローカルタイムゾーンはUTCの +ため、`date.today()`/`datetime.now()`(naive)は使わないこと。 +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +APP_TIMEZONE = ZoneInfo("Asia/Tokyo") + + +def local_date_of(dt: datetime) -> date: + """aware datetimeを日本時間での日付に変換する。""" + return dt.astimezone(APP_TIMEZONE).date() + + +def local_today() -> date: + """日本時間での今日の日付を返す(実行環境のタイムゾーンに依存しない)。""" + return local_date_of(datetime.now(UTC)) diff --git a/src/health_intelligence/ml/real_data.py b/src/health_intelligence/ml/real_data.py index ae557d2..aecc45a 100644 --- a/src/health_intelligence/ml/real_data.py +++ b/src/health_intelligence/ml/real_data.py @@ -24,6 +24,7 @@ DailyFeatures, compute_daily_features, ) +from health_intelligence.local_time import local_date_of from health_intelligence.ml.dataset import MIN_DAYS from health_intelligence.repositories.sqlalchemy.meal_log_repository import ( SqlAlchemyMealLogRepository, @@ -64,10 +65,10 @@ def load_real_daily_features(session: Session) -> list[DailyFeatures]: weight_logs = weight_repo.list_by_user(target_user_id) all_dates: set[date] = set() - all_dates.update(m.consumed_at.date() for m in meal_logs) - all_dates.update(s.wake_time.date() for s in sleep_logs) - all_dates.update(w.performed_at.date() for w in workout_logs) - all_dates.update(w.recorded_at.date() for w in weight_logs) + all_dates.update(local_date_of(m.consumed_at) for m in meal_logs) + all_dates.update(local_date_of(s.wake_time) for s in sleep_logs) + all_dates.update(local_date_of(w.performed_at) for w in workout_logs) + all_dates.update(local_date_of(w.recorded_at) for w in weight_logs) if not all_dates: return [] diff --git a/src/health_intelligence/services/recommendation_service.py b/src/health_intelligence/services/recommendation_service.py index cccfc6c..fb700b3 100644 --- a/src/health_intelligence/services/recommendation_service.py +++ b/src/health_intelligence/services/recommendation_service.py @@ -20,6 +20,7 @@ compute_weight_features, compute_workout_features, ) +from health_intelligence.local_time import local_date_of, local_today from health_intelligence.ml.dataset import DataQualityReport, check_data_quality from health_intelligence.ml.weight_predictor import WeightPredictorService from health_intelligence.recommendation import ( @@ -96,7 +97,7 @@ def get_recommendations(self, user_id: UUID) -> RecommendationReport: recommendations = self._recommendation_engine.generate(health_score) today_features = compute_daily_features( - now.date(), + local_date_of(now), meal_logs=meal_logs, sleep_logs=sleep_logs, workout_logs=workout_logs, @@ -140,16 +141,16 @@ def get_ml_progress(self, user_id: UUID) -> DataQualityReport: weight_logs = uow.weight_logs.list_by_user(user_id) candidate_dates = ( - [m.consumed_at.date() for m in meal_logs] - + [s.wake_time.date() for s in sleep_logs] - + [w.performed_at.date() for w in workout_logs] - + [w.recorded_at.date() for w in weight_logs] + [local_date_of(m.consumed_at) for m in meal_logs] + + [local_date_of(s.wake_time) for s in sleep_logs] + + [local_date_of(w.performed_at) for w in workout_logs] + + [local_date_of(w.recorded_at) for w in weight_logs] ) if not candidate_dates: return check_data_quality([]) start_date = min(candidate_dates) - end_date = datetime.now(UTC).date() + end_date = local_today() num_days = (end_date - start_date).days + 1 daily_features = [ diff --git a/tests/unit/test_daily_features.py b/tests/unit/test_daily_features.py new file mode 100644 index 0000000..998bc26 --- /dev/null +++ b/tests/unit/test_daily_features.py @@ -0,0 +1,40 @@ +"""compute_daily_featuresの日付境界(JST)のテスト。""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from uuid import uuid4 + +from health_intelligence.domain.enums import MealType +from health_intelligence.domain.models import MealLog +from health_intelligence.features import compute_daily_features +from health_intelligence.local_time import local_date_of + + +def _calories_on(target_date: date, meal: MealLog) -> float | None: + return compute_daily_features( + target_date, meal_logs=[meal], sleep_logs=[], workout_logs=[], weight_logs=[] + ).calories + + +def test_morning_log_counts_toward_jst_date() -> None: + """UTCでは前日でも、JSTで当日の朝の記録は当日の集計に含まれる。""" + # 2026-06-01 23:30 UTC = 2026-06-02 08:30 JST + breakfast = MealLog( + meal_log_id=uuid4(), + user_id=uuid4(), + consumed_at=datetime(2026, 6, 1, 23, 30, tzinfo=UTC), + meal_type=MealType.BREAKFAST, + calories=500.0, + protein_g=20.0, + fat_g=10.0, + carbohydrate_g=60.0, + ) + + assert _calories_on(date(2026, 6, 2), breakfast) == 500.0 + assert _calories_on(date(2026, 6, 1), breakfast) is None + + +def test_local_date_of_converts_utc_to_jst_date() -> None: + assert local_date_of(datetime(2026, 6, 1, 14, 59, tzinfo=UTC)) == date(2026, 6, 1) + assert local_date_of(datetime(2026, 6, 1, 15, 0, tzinfo=UTC)) == date(2026, 6, 2) From 43393a6370fcd809a87b4e341fc09fa84118a107 Mon Sep 17 00:00:00 2001 From: Shimpei Ohtani Date: Sat, 26 Sep 2026 16:49:24 +0900 Subject: [PATCH 2/3] fix(recipe): merge duplicate AI ingredients before saving recipe_ingredients is keyed by (recipe_id, ingredient_id), so an AI response listing the same ingredient twice (e.g. salt for seasoning and finishing) failed on save. Because the AI result is cached before the save, retrying with the same ingredients kept failing until restart. Merge ingredients case-insensitively (trimmed) before get-or-create, summing amounts when units match and keeping the first otherwise. Co-Authored-By: Claude Opus 5.5 --- .../services/recipe_service.py | 31 ++++++- tests/unit/test_recipe_service.py | 88 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_recipe_service.py diff --git a/src/health_intelligence/services/recipe_service.py b/src/health_intelligence/services/recipe_service.py index 715dec2..28d682d 100644 --- a/src/health_intelligence/services/recipe_service.py +++ b/src/health_intelligence/services/recipe_service.py @@ -119,7 +119,7 @@ def generate_recipe(self, user_id: UUID, ingredient_names: list[str]) -> Recipe: amount=generated_ingredient.amount, unit=generated_ingredient.unit, ) - for generated_ingredient in generated.ingredients + for generated_ingredient in _merge_duplicate_ingredients(generated.ingredients) ] uow.recipes.add(recipe) @@ -140,3 +140,32 @@ def _get_or_create_ingredient( ingredient = Ingredient(ingredient_id=uuid4(), name=generated_ingredient.name) uow.ingredients.add(ingredient) return ingredient + + +def _merge_duplicate_ingredients( + ingredients: list[GeneratedRecipeIngredient], +) -> list[GeneratedRecipeIngredient]: + """AIの出力に同じ食材が複数回含まれる場合、1件にまとめる。 + + recipe_ingredientsの主キーは(recipe_id, ingredient_id)のため、 + 「塩」が2回(例: 下味用と仕上げ用)返ってくると保存時に主キー違反と + なり、レシピ生成全体が失敗する。しかもAIの結果はRecipeCacheに + 保存済みのため、同じ食材で再実行しても同じ結果で失敗し続ける。 + + 食材名は`IngredientRepository.get_by_name`と同じく大文字小文字を + 区別せず(前後の空白も無視して)突合する。単位が同じなら分量を合算し、 + 単位が異なる場合(例: "大さじ"と"少々")は合算できないため最初の + 分量を採用する。 + """ + merged: dict[str, GeneratedRecipeIngredient] = {} + for ingredient in ingredients: + name = ingredient.name.strip() + key = name.lower() + existing = merged.get(key) + if existing is None: + merged[key] = ingredient.model_copy(update={"name": name}) + elif existing.unit == ingredient.unit: + merged[key] = existing.model_copy( + update={"amount": existing.amount + ingredient.amount} + ) + return list(merged.values()) diff --git a/tests/unit/test_recipe_service.py b/tests/unit/test_recipe_service.py new file mode 100644 index 0000000..e7c6a83 --- /dev/null +++ b/tests/unit/test_recipe_service.py @@ -0,0 +1,88 @@ +"""RecipeService.generate_recipeのテスト。""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from health_intelligence.ai.fake_provider import FakeAIProvider +from health_intelligence.ai.schemas import GeneratedRecipe, GeneratedRecipeIngredient +from health_intelligence.domain.models import Ingredient, Recipe, RecipeIngredient +from health_intelligence.services.recipe_service import RecipeService + + +class _FakeIngredientRepository: + def __init__(self) -> None: + self.items: dict[UUID, Ingredient] = {} + + def get_by_name(self, name: str) -> Ingredient | None: + return next((i for i in self.items.values() if i.name.lower() == name.lower()), None) + + def add(self, ingredient: Ingredient) -> None: + self.items[ingredient.ingredient_id] = ingredient + + +class _FakeRecipeRepository: + def __init__(self) -> None: + self.items: list[Recipe] = [] + + def add(self, recipe: Recipe) -> None: + self.items.append(recipe) + + +class _FakeRecipeIngredientRepository: + """主キー(recipe_id, ingredient_id)の重複を実DBと同様に拒否する。""" + + def __init__(self) -> None: + self.items: dict[tuple[UUID, UUID], RecipeIngredient] = {} + + def add(self, recipe_ingredient: RecipeIngredient) -> None: + key = (recipe_ingredient.recipe_id, recipe_ingredient.ingredient_id) + if key in self.items: + raise AssertionError(f"duplicate primary key: {key}") + self.items[key] = recipe_ingredient + + +def _generated(*ingredients: GeneratedRecipeIngredient) -> GeneratedRecipe: + return GeneratedRecipe( + title="鶏の照り焼き", + instructions="焼く", + ingredients=list(ingredients), + total_calories=500.0, + protein_g=30.0, + fat_g=20.0, + carbohydrate_g=40.0, + ) + + +def test_generate_recipe_merges_duplicate_ingredients(make_fake_uow_factory) -> None: + ingredients = _FakeIngredientRepository() + recipe_ingredients = _FakeRecipeIngredientRepository() + uow_factory = make_fake_uow_factory( + ingredients=ingredients, + recipes=_FakeRecipeRepository(), + recipe_ingredients=recipe_ingredients, + ) + ai_provider = FakeAIProvider( + fixed_recipe=_generated( + GeneratedRecipeIngredient(name="鶏もも肉", amount=200, unit="g"), + GeneratedRecipeIngredient(name="塩", amount=1, unit="g"), + GeneratedRecipeIngredient(name=" 塩 ", amount=2, unit="g"), + GeneratedRecipeIngredient(name="Soy Sauce", amount=1, unit="大さじ"), + GeneratedRecipeIngredient(name="soy sauce", amount=1, unit="少々"), + ) + ) + service = RecipeService(uow_factory, ai_provider) + + recipe = service.generate_recipe(uuid4(), ["鶏もも肉"]) + + saved = { + ingredients.items[ri.ingredient_id].name: (ri.amount, ri.unit) + for ri in recipe_ingredients.items.values() + if ri.recipe_id == recipe.recipe_id + } + assert saved == { + "鶏もも肉": (200, "g"), + "塩": (3, "g"), + "Soy Sauce": (1, "大さじ"), + } + assert len(ingredients.items) == 3 From e279ff96b5b61aea72a58444a6f2fee485cb187e Mon Sep 17 00:00:00 2001 From: Shimpei Ohtani Date: Sun, 27 Sep 2026 18:20:17 +0900 Subject: [PATCH 3/3] fix(recipe): prevent duplicate ingredients from concurrent get-or-create Add a unique index on lower(ingredients.name) (migration 0010 merges existing duplicates first). IngredientRepository.add flushes inside a SAVEPOINT and raises DuplicateKeyError on conflict, so RecipeService can re-fetch the winning row within the same transaction. Co-Authored-By: Claude Opus 5.5 --- .../0010_add_unique_ingredient_name.py | 76 +++++++++++++++++++ src/health_intelligence/database/schema.py | 8 +- .../repositories/protocols.py | 8 +- .../sqlalchemy/ingredient_repository.py | 22 +++++- .../services/recipe_service.py | 11 ++- tests/unit/test_recipe_service.py | 42 ++++++++++ 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 src/health_intelligence/database/migrations/versions/0010_add_unique_ingredient_name.py diff --git a/src/health_intelligence/database/migrations/versions/0010_add_unique_ingredient_name.py b/src/health_intelligence/database/migrations/versions/0010_add_unique_ingredient_name.py new file mode 100644 index 0000000..37dce1d --- /dev/null +++ b/src/health_intelligence/database/migrations/versions/0010_add_unique_ingredient_name.py @@ -0,0 +1,76 @@ +"""add unique index on lower(ingredients.name) + +食材マスタのget-or-createが同時リクエストで競合(TOCTOU)し、同名の食材が +重複作成されるのを防ぐため、`lower(name)`に一意インデックスを張る +(`IngredientRepository.get_by_name`が大文字小文字を区別しないのに合わせる)。 + +インデックス作成前に既存の重複を統合する。同じ`lower(name)`の中でidが +最小の行を残し、recipe_ingredientsの参照を付け替えてから重複行を削除する。 +付け替えによって同一レシピ内で主キー(recipe_id, ingredient_id)が衝突する +行は、1件だけ残して削除する(同一レシピ内の同名食材は保存時に統合される +ため、通常は発生しない)。 + +Revision ID: 0010 +Revises: 0009 +Create Date: (食材マスタの重複作成対策に伴い追加) +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0010" +down_revision: str | None = "0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TEMP TABLE ingredient_dedup AS + SELECT id AS dup_id, keep_id + FROM ( + SELECT id, first_value(id) OVER (PARTITION BY lower(name) ORDER BY id) AS keep_id + FROM ingredients + ) ranked + WHERE id <> keep_id + """ + ) + # 付け替え後に同じ(recipe_id, keep_id)になる行のうち、ingredient_idが + # 最小の行(=残す食材を直接参照している行があればそれ)以外を削除する + op.execute( + """ + DELETE FROM recipe_ingredients ri + USING ingredient_dedup d + WHERE ri.ingredient_id = d.dup_id + AND EXISTS ( + SELECT 1 + FROM recipe_ingredients other + LEFT JOIN ingredient_dedup od ON od.dup_id = other.ingredient_id + WHERE other.recipe_id = ri.recipe_id + AND COALESCE(od.keep_id, other.ingredient_id) = d.keep_id + AND other.ingredient_id < ri.ingredient_id + ) + """ + ) + op.execute( + """ + UPDATE recipe_ingredients ri + SET ingredient_id = d.keep_id + FROM ingredient_dedup d + WHERE ri.ingredient_id = d.dup_id + """ + ) + op.execute("DELETE FROM ingredients WHERE id IN (SELECT dup_id FROM ingredient_dedup)") + op.execute("DROP TABLE ingredient_dedup") + + op.execute("CREATE UNIQUE INDEX uq_ingredients_name_lower ON ingredients (lower(name))") + + +def downgrade() -> None: + # 統合済みの重複データは復元しない(インデックスのみ削除する) + op.drop_index("uq_ingredients_name_lower", table_name="ingredients") diff --git a/src/health_intelligence/database/schema.py b/src/health_intelligence/database/schema.py index eb00640..4687f55 100644 --- a/src/health_intelligence/database/schema.py +++ b/src/health_intelligence/database/schema.py @@ -11,7 +11,7 @@ import uuid from datetime import date, datetime, time -from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint +from sqlalchemy import DateTime, ForeignKey, Index, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -158,6 +158,12 @@ class IngredientORM(Base): carbohydrate_per_100g: Mapped[float | None] +# `IngredientRepository.get_by_name`は大文字小文字を区別せずに突合するため、 +# 一意制約も`lower(name)`に張る。同時リクエストによるget-or-createの +# 競合(TOCTOU)で同名の食材が重複作成されるのをDB側で防ぐ。 +Index("uq_ingredients_name_lower", func.lower(IngredientORM.name), unique=True) + + class RecipeORM(Base): __tablename__ = "recipes" diff --git a/src/health_intelligence/repositories/protocols.py b/src/health_intelligence/repositories/protocols.py index 508e8c3..38daa3d 100644 --- a/src/health_intelligence/repositories/protocols.py +++ b/src/health_intelligence/repositories/protocols.py @@ -153,7 +153,13 @@ def get_by_name(self, name: str) -> Ingredient | None: ... def list_all(self) -> list[Ingredient]: ... - def add(self, ingredient: Ingredient) -> None: ... + def add(self, ingredient: Ingredient) -> None: + """同名(大文字小文字を区別しない)が既に存在する場合は`DuplicateKeyError`。 + + 違反時もトランザクション全体は中断せず、同じUnitOfWork内で + `get_by_name`による引き直しができること。 + """ + ... class RecipeRepository(Protocol): diff --git a/src/health_intelligence/repositories/sqlalchemy/ingredient_repository.py b/src/health_intelligence/repositories/sqlalchemy/ingredient_repository.py index 1bc5717..b047d53 100644 --- a/src/health_intelligence/repositories/sqlalchemy/ingredient_repository.py +++ b/src/health_intelligence/repositories/sqlalchemy/ingredient_repository.py @@ -8,10 +8,12 @@ from uuid import UUID from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from health_intelligence.database.schema import IngredientORM from health_intelligence.domain.models import Ingredient +from health_intelligence.repositories.errors import DuplicateKeyError def _to_domain(orm: IngredientORM) -> Ingredient: @@ -55,7 +57,8 @@ def list_by_ids(self, ingredient_ids: list[UUID]) -> list[Ingredient]: def get_by_name(self, name: str) -> Ingredient | None: """大文字小文字を区別せず名前で検索する(get or create用)。""" - stmt = select(IngredientORM).where(func.lower(IngredientORM.name) == name.lower()) + # 一意インデックス(lower(name))と判定を揃えるため、比較対象もDB側でlowerする + stmt = select(IngredientORM).where(func.lower(IngredientORM.name) == func.lower(name)) orm = self._session.scalars(stmt).first() return _to_domain(orm) if orm is not None else None @@ -64,4 +67,19 @@ def list_all(self) -> list[Ingredient]: return [_to_domain(orm) for orm in self._session.scalars(stmt)] def add(self, ingredient: Ingredient) -> None: - self._session.add(_to_orm(ingredient)) + """Ingredientを追加する。 + + 同名(大文字小文字を区別しない)の食材が既に存在する場合は + `DuplicateKeyError`を送出する。 + + レシピ生成ではIngredientのget-or-createとRecipeの保存を1つの + トランザクションで行うため、SAVEPOINT(`begin_nested`)内で + `flush()`して一意制約違反を検知する。違反時はSAVEPOINTまでしか + rollbackされないので、呼び出し側は同じUnitOfWorkのまま + `get_by_name`で引き直して処理を続行できる。 + """ + try: + with self._session.begin_nested(): + self._session.add(_to_orm(ingredient)) + except IntegrityError as e: + raise DuplicateKeyError(f"ingredient already exists: {ingredient.name}") from e diff --git a/src/health_intelligence/services/recipe_service.py b/src/health_intelligence/services/recipe_service.py index 28d682d..115eb70 100644 --- a/src/health_intelligence/services/recipe_service.py +++ b/src/health_intelligence/services/recipe_service.py @@ -11,6 +11,7 @@ from health_intelligence.ai.schemas import GeneratedRecipeIngredient, RecipeInput from health_intelligence.domain.enums import RecipeSource from health_intelligence.domain.models import Ingredient, Recipe, RecipeIngredient +from health_intelligence.repositories.errors import DuplicateKeyError from health_intelligence.repositories.unit_of_work import UnitOfWork @@ -138,7 +139,15 @@ def _get_or_create_ingredient( return existing ingredient = Ingredient(ingredient_id=uuid4(), name=generated_ingredient.name) - uow.ingredients.add(ingredient) + try: + uow.ingredients.add(ingredient) + except DuplicateKeyError: + # 事前チェック(`get_by_name`)をすり抜けて、別リクエストが同時に + # 同じ食材を登録した場合(TOCTOU)。`add`はSAVEPOINTまでしか + # rollbackしないため、同じUnitOfWorkのまま登録済みの食材を引き直す。 + existing = uow.ingredients.get_by_name(generated_ingredient.name) + assert existing is not None + return existing return ingredient diff --git a/tests/unit/test_recipe_service.py b/tests/unit/test_recipe_service.py index e7c6a83..5e748d5 100644 --- a/tests/unit/test_recipe_service.py +++ b/tests/unit/test_recipe_service.py @@ -7,6 +7,7 @@ from health_intelligence.ai.fake_provider import FakeAIProvider from health_intelligence.ai.schemas import GeneratedRecipe, GeneratedRecipeIngredient from health_intelligence.domain.models import Ingredient, Recipe, RecipeIngredient +from health_intelligence.repositories.errors import DuplicateKeyError from health_intelligence.services.recipe_service import RecipeService @@ -86,3 +87,44 @@ def test_generate_recipe_merges_duplicate_ingredients(make_fake_uow_factory) -> "Soy Sauce": (1, "大さじ"), } assert len(ingredients.items) == 3 + + +class _RacingIngredientRepository(_FakeIngredientRepository): + """`get_by_name`の直後に別リクエストが同名の食材を登録した状況を再現する。 + + 最初の`add`で、先に登録された食材(`winner`)をマスタへ入れたうえで + 実DBの一意制約違反と同様に`DuplicateKeyError`を送出する。 + """ + + def __init__(self, winner: Ingredient) -> None: + super().__init__() + self._winner: Ingredient | None = winner + + def add(self, ingredient: Ingredient) -> None: + if self._winner is not None: + self.items[self._winner.ingredient_id] = self._winner + self._winner = None + if self.get_by_name(ingredient.name) is not None: + raise DuplicateKeyError(ingredient.name) + super().add(ingredient) + + +def test_generate_recipe_reuses_ingredient_created_concurrently(make_fake_uow_factory) -> None: + winner = Ingredient(ingredient_id=uuid4(), name="鶏もも肉") + ingredients = _RacingIngredientRepository(winner) + recipe_ingredients = _FakeRecipeIngredientRepository() + uow_factory = make_fake_uow_factory( + ingredients=ingredients, + recipes=_FakeRecipeRepository(), + recipe_ingredients=recipe_ingredients, + ) + ai_provider = FakeAIProvider( + fixed_recipe=_generated(GeneratedRecipeIngredient(name="鶏もも肉", amount=200, unit="g")) + ) + service = RecipeService(uow_factory, ai_provider) + + recipe = service.generate_recipe(uuid4(), ["鶏もも肉"]) + + assert [ri.ingredient_id for ri in recipe_ingredients.items.values()] == [winner.ingredient_id] + assert recipe_ingredients.items[(recipe.recipe_id, winner.ingredient_id)].amount == 200 + assert list(ingredients.items) == [winner.ingredient_id]