from __future__ import annotations from datetime import datetime import pytest from app.db.models.content import Content from app.db.models.content_profile import ContentProfile from app.db.models.content_risk_flag import ContentRiskFlag from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository def _ctx_json() -> dict: return {"family": 0.5, "work": 0.5, "relationship": 0.5, "friends": 0.5, "health": 0.5} def _need_json() -> dict: return { "emotional_support": 0.5, "parenting_pressure": 0.5, "self_worth": 0.5, "anxiety_relief": 0.5, "rest_balance": 0.5, } @pytest.mark.asyncio async def test_fetch_contents_by_ids_locale_no_fallback(db_session, query_counter): # content 1: 只有英文 c1 = Content(text_en="hello", text_tc=None, author_id="a1", template_id="t1") db_session.add(c1) await db_session.flush() db_session.add( ContentProfile( content_id=c1.content_id, stage="general", emotion_score=None, context_suitability_json=_ctx_json(), need_suitability_json=_need_json(), personalization_power=10, review_confidence=None, is_safe_pool=False, ) ) db_session.add(ContentRiskFlag(content_id=c1.content_id, flag="block_stage_unknown")) # content 2: 只有繁中 c2 = Content(text_en=None, text_tc="繁體中文", author_id="a2", template_id="t2") db_session.add(c2) await db_session.flush() db_session.add( ContentProfile( content_id=c2.content_id, stage="general", emotion_score=None, context_suitability_json=_ctx_json(), need_suitability_json=_need_json(), personalization_power=0, review_confidence=0.9, is_safe_pool=True, ) ) db_session.add(ContentRiskFlag(content_id=c2.content_id, flag="block_health_sensitive")) await db_session.flush() repo = SqlAlchemyContentRepository(db_session) start = query_counter() # en:只能拿到有 text_en 的内容(不允许回退到 text_tc) en_items = await repo.fetch_contents_by_ids(content_ids=[c2.content_id, c1.content_id], locale="en") assert [x.content_id for x in en_items] == [c1.content_id] assert en_items[0].text == "hello" assert en_items[0].personalization_power == 1.0 assert en_items[0].review_confidence == 0.7 # NULL -> 0.7 assert "unsafe_for_stage_unknown" in en_items[0].risk_flags assert "block_stage_unknown" not in en_items[0].risk_flags # tc:只能拿到有 text_tc 的内容(不允许回退到 text_en) tc_items = await repo.fetch_contents_by_ids(content_ids=[c1.content_id, c2.content_id], locale="tc") assert [x.content_id for x in tc_items] == [c2.content_id] assert tc_items[0].text == "繁體中文" assert tc_items[0].review_confidence == 0.9 assert "block_health_medical" in tc_items[0].risk_flags assert "block_health_sensitive" not in tc_items[0].risk_flags # 两次调用各自 2 次查询(主体+画像一次,flags 一次),总计应为常数级 end = query_counter() assert (end - start) <= 4 @pytest.mark.asyncio async def test_fetch_candidates_fallback_and_locale_filter(db_session, query_counter): # 构造 4 条英文内容:power 0/5/10,安全池标记不同 contents = [] for i, (power, safe) in enumerate([(0, True), (5, False), (10, False), (0, False)], start=1): c = Content(text_en=f"en_{i}", text_tc=None, author_id=f"a{i}", template_id=f"t{i}") db_session.add(c) await db_session.flush() db_session.add( ContentProfile( content_id=c.content_id, stage="general", emotion_score=None, context_suitability_json=_ctx_json(), need_suitability_json=_need_json(), personalization_power=power, review_confidence=None, is_safe_pool=safe, # 让测试数据在候选排序中排到最前,避免依赖“库为空” updated_at=datetime(2099, 1, 1, 0, 0, 0), ) ) contents.append(c) await db_session.flush() inserted_ids = {int(c.content_id) for c in contents} repo = SqlAlchemyContentRepository(db_session) class _MinimalUser: # need/context/emotion_score 全缺失 -> effective_fallback 至少 L1 need = {} context = {} emotion_score = None stage = {"unknown": 1} start = query_counter() # 入参 L0,但因为缺失字段,effective_fallback=1 -> power<=5(排除 power=10) items_l0 = await repo.fetch_candidates( scene="feed", user_profile=_MinimalUser(), fallback_level=0, limit=3, locale="en-US", ) powers = [x.personalization_power for x in items_l0] assert 1.0 not in powers assert all(x.text.startswith("en_") for x in items_l0) # L2:强制 power=0 且 stage=general(这里都 general),只剩 power=0 的两条 items_l2 = await repo.fetch_candidates( scene="feed", user_profile=_MinimalUser(), fallback_level=2, limit=2, locale="en", ) assert all(x.personalization_power == 0.0 for x in items_l2) assert all(x.text.startswith("en_") for x in items_l2) # L3:只安全池(is_safe_pool=true)且 power=0 items_l3 = await repo.fetch_candidates( scene="feed", user_profile=_MinimalUser(), fallback_level=3, limit=1, locale="en", ) assert len(items_l3) == 1 assert items_l3[0].text.startswith("en_") assert items_l3[0].personalization_power == 0.0 # locale 过滤:tc 请求下这些内容都没有 text_tc -> 返回空 items_tc = await repo.fetch_candidates( scene="feed", user_profile=_MinimalUser(), fallback_level=0, limit=10, locale="tc", ) # 不要求库为空:只断言“不会把本次插入的 en-only 测试数据返回出来” assert not any(x.content_id in inserted_ids for x in items_tc) end = query_counter() # 期望为常数级(每次 fetch_candidates:1 次取 ids + 2 次补全),这里 4 次调用 -> <= 12 assert (end - start) <= 12