新功能:个性化推荐算法
This commit is contained in:
BIN
server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
server/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
223
server/tests/conftest.py
Normal file
223
server/tests/conftest.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
# 确保在任何 pytest rootdir 下都能 `import app.*`
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] # .../server
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
|
||||
def _read_env_kv(env_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
读取 .env 文件中的 KEY=VALUE(最小实现,避免引入额外依赖)。
|
||||
"""
|
||||
|
||||
data: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return data
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
data[k] = v
|
||||
return data
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""
|
||||
获取测试用数据库连接串。
|
||||
|
||||
约定(与 alembic/env.py 保持一致):
|
||||
- 优先读取环境变量 `DATABASE_URL`
|
||||
- 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod`
|
||||
"""
|
||||
|
||||
env_url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
server_dir = Path(__file__).resolve().parents[1] # .../server
|
||||
app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev"
|
||||
env_file = server_dir / (".env.prod" if app_env == "prod" else ".env.dev")
|
||||
kv = _read_env_kv(env_file)
|
||||
url = (kv.get("DATABASE_URL") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
|
||||
raise RuntimeError(
|
||||
"缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。"
|
||||
)
|
||||
|
||||
|
||||
def _assert_safe_mysql_test_db(url: str) -> None:
|
||||
"""
|
||||
为了避免对开发库造成破坏性影响,集成测试只允许连接到“测试库”。
|
||||
|
||||
规则(V1):
|
||||
- 必须是 mysql+aiomysql://...
|
||||
- 为避免误连生产库:不允许数据库名为 'mindfulness'(prod 默认库名)
|
||||
- 建议使用独立测试库(例如 mindfulness_dev_test)
|
||||
"""
|
||||
|
||||
if not url.startswith("mysql+"):
|
||||
raise RuntimeError(f"当前仅允许 MySQL 集成测试(mysql+aiomysql)。实际:{url!r}")
|
||||
|
||||
parsed = urlparse(url.replace("mysql+aiomysql://", "mysql://", 1))
|
||||
db_name = (parsed.path or "").lstrip("/")
|
||||
if db_name.lower() == "mindfulness":
|
||||
raise RuntimeError(
|
||||
"为避免误连生产库,集成测试不允许连接到数据库 'mindfulness'。"
|
||||
"请改用 dev 测试库(例如 mindfulness_dev_test 或 mindfulness_dev)。"
|
||||
)
|
||||
|
||||
|
||||
def _run_alembic_upgrade_head() -> None:
|
||||
"""
|
||||
使用 Alembic 将测试库升级到最新 schema。
|
||||
|
||||
说明:
|
||||
- 依赖 env.py 内部读取 DATABASE_URL
|
||||
- 仅在 session 级别执行一次,避免每个测试都跑迁移
|
||||
"""
|
||||
|
||||
server_dir = Path(__file__).resolve().parents[1] # .../server
|
||||
alembic_ini = server_dir / "alembic.ini"
|
||||
cfg = Config(str(alembic_ini))
|
||||
# 确保脚本路径正确(alembic.ini 里一般已配置,这里兜底)
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
def _assert_schema_exists(url: str) -> None:
|
||||
"""
|
||||
非破坏性检查:要求目标库已经存在所需表。
|
||||
|
||||
说明:
|
||||
- 默认不在测试中运行 Alembic(避免任何 schema 变更)
|
||||
- 若要自动迁移,请设置环境变量 ALLOW_SCHEMA_MIGRATION=1
|
||||
"""
|
||||
|
||||
allow_migration = (os.getenv("ALLOW_SCHEMA_MIGRATION") or "").strip() == "1"
|
||||
if allow_migration:
|
||||
_run_alembic_upgrade_head()
|
||||
return
|
||||
|
||||
# 使用 PyMySQL 做同步检查,避免依赖 MySQLdb(不要求系统安装 mysqlclient)
|
||||
sync_url = url.replace("mysql+aiomysql://", "mysql+pymysql://", 1)
|
||||
engine = sa.create_engine(sync_url, future=True)
|
||||
try:
|
||||
insp = sa.inspect(engine)
|
||||
tables = set(insp.get_table_names())
|
||||
required = {"contents", "content_profiles", "content_risk_flags"}
|
||||
missing = sorted(required - tables)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"集成测试检测到 schema 不完整(缺少表:"
|
||||
+ ", ".join(missing)
|
||||
+ ")。为避免破坏性操作,测试不会自动迁移。"
|
||||
"请先手动在该库执行 `alembic upgrade head`,或设置 ALLOW_SCHEMA_MIGRATION=1 允许测试自动迁移。"
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _migrate_db_once() -> None:
|
||||
"""
|
||||
Session 级 schema 检查(仅在配置了数据库连接时启用)。
|
||||
|
||||
说明:
|
||||
- 纯函数单元测试不需要 MySQL;若未配置 DATABASE_URL,则跳过检查
|
||||
- 集成测试(依赖 db_session/async_engine)仍会在获取 DATABASE_URL 时失败,从而提示用户配置
|
||||
"""
|
||||
|
||||
try:
|
||||
url = _get_database_url()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
_assert_safe_mysql_test_db(url)
|
||||
_assert_schema_exists(url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_engine() -> AsyncIterator[AsyncEngine]:
|
||||
url = _get_database_url()
|
||||
_assert_safe_mysql_test_db(url)
|
||||
engine = create_async_engine(url, pool_pre_ping=True)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(async_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
||||
"""
|
||||
提供一个干净的 AsyncSession。
|
||||
|
||||
清理策略:每个测试都在事务中执行,并在结束时回滚(不做 DELETE/TRUNCATE)。
|
||||
|
||||
说明:
|
||||
- 用例里严禁调用 session.commit(),只允许 flush()
|
||||
- 这样不会对测试库产生持久化写入,更不会影响开发库
|
||||
"""
|
||||
|
||||
SessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
async with SessionLocal() as session:
|
||||
trans = await session.begin()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await trans.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def query_counter(async_engine: AsyncEngine) -> Callable[[], int]:
|
||||
"""
|
||||
返回一个函数:调用可获得当前累计查询次数。
|
||||
"""
|
||||
|
||||
count = {"n": 0}
|
||||
|
||||
def before_cursor_execute(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
count["n"] += 1
|
||||
|
||||
event.listen(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
|
||||
|
||||
def get_count() -> int:
|
||||
return int(count["n"])
|
||||
|
||||
def fin() -> None:
|
||||
event.remove(async_engine.sync_engine, "before_cursor_execute", before_cursor_execute)
|
||||
|
||||
# 用 yield 确保测试后移除监听,避免重复绑定导致统计偏大
|
||||
try:
|
||||
yield get_count # type: ignore[misc]
|
||||
finally:
|
||||
fin()
|
||||
|
||||
178
server/tests/test_content_repository.py
Normal file
178
server/tests/test_content_repository.py
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
|
||||
165
server/tests/test_integration_api_worker.py
Normal file
165
server/tests/test_integration_api_worker.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _set_min_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# 让 Settings 可构造(引擎不会在单测中真正连接 DB/Redis)
|
||||
monkeypatch.setenv(
|
||||
"DATABASE_URL",
|
||||
"mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev_test?charset=utf8mb4",
|
||||
)
|
||||
monkeypatch.setenv("REDIS_URL", "redis://127.0.0.1:6379/0")
|
||||
monkeypatch.setenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
|
||||
|
||||
|
||||
def _user_profile_dict() -> dict[str, Any]:
|
||||
return {
|
||||
"profile_version": "v1.2",
|
||||
"profile_source": "questionnaire",
|
||||
"profile_generated_at": "2026-02-02T12:00:00Z",
|
||||
"profile_confidence": 1.0,
|
||||
"profile_answered": {"stage": True, "emotion": False, "context": False, "need": False},
|
||||
"stage": {"unknown": 1},
|
||||
"emotion_score": None,
|
||||
"context": {},
|
||||
"need": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch):
|
||||
_set_min_env(monkeypatch)
|
||||
|
||||
# 清理 settings cache,避免被其他测试污染
|
||||
from app.core import config as config_mod
|
||||
|
||||
config_mod.get_settings.cache_clear()
|
||||
|
||||
# 重新加载 main,确保使用最新 env
|
||||
import app.main as main_mod
|
||||
|
||||
importlib.reload(main_mod)
|
||||
|
||||
app = main_mod.create_app()
|
||||
|
||||
# override repo(避免依赖真实 DB)
|
||||
from app.api.v1 import reco as reco_mod
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
|
||||
class _FakeRepo:
|
||||
async def fetch_candidates(self, **kwargs): # type: ignore[no-untyped-def]
|
||||
# 返回一条可下发内容
|
||||
return [
|
||||
ContentProfileDTO(
|
||||
content_id=1,
|
||||
text="t1",
|
||||
stage="general",
|
||||
emotion_score=None,
|
||||
context_suitability={},
|
||||
need_suitability={},
|
||||
personalization_power=0.0,
|
||||
risk_flags=[],
|
||||
author_id=None,
|
||||
template_id=None,
|
||||
review_confidence=0.7,
|
||||
)
|
||||
]
|
||||
|
||||
async def fetch_contents_by_ids(self, **kwargs): # type: ignore[no-untyped-def]
|
||||
return []
|
||||
|
||||
async def _override_repo(): # type: ignore[no-untyped-def]
|
||||
return _FakeRepo()
|
||||
|
||||
app.dependency_overrides[reco_mod.get_reco_repo] = _override_repo
|
||||
|
||||
# 清空限流计数,避免跨测试污染
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
limits_mod._reco_rate_limiter._counters.clear() # type: ignore[attr-defined]
|
||||
limits_mod._reco_rate_limiter._last_gc_bucket = 0 # type: ignore[attr-defined]
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_accept_language_mapping_to_tc(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# 固定时间,避免跨分钟 flake
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0) # 2025-02-02 12:00:00Z 的某个时间戳
|
||||
|
||||
resp = client.post(
|
||||
"/v1/reco/feed",
|
||||
json={"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []},
|
||||
headers={"Accept-Language": "zh-TW,zh;q=0.9"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["meta"]["config_snapshot"]["locale"] == "tc"
|
||||
|
||||
|
||||
def test_x_now_header_priority_over_body_now(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/reco/push",
|
||||
json={
|
||||
"user_profile": _user_profile_dict(),
|
||||
"now": "2026-02-01T00:00:00Z",
|
||||
"already_recommended_ids": [],
|
||||
"touched_or_viewed_ids": [],
|
||||
},
|
||||
headers={"X-Now": "2026-02-02T12:00:00Z", "Accept-Language": "en-US"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# meta.config_snapshot 里没有 now,但 served_k 应该正常
|
||||
assert data["meta"]["served_k"] == 1
|
||||
|
||||
|
||||
def test_rate_limit_10_per_minute(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import app.api.limits as limits_mod
|
||||
|
||||
monkeypatch.setattr(limits_mod.time, "time", lambda: 1738497600.0)
|
||||
|
||||
body = {"user_profile": _user_profile_dict(), "already_recommended_ids": [], "touched_or_viewed_ids": []}
|
||||
for _ in range(10):
|
||||
r = client.post("/v1/reco/widget", json=body)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/v1/reco/widget", json=body)
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == "rate_limited"
|
||||
|
||||
|
||||
def test_celery_tasks_can_call_generate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_min_env(monkeypatch)
|
||||
from app.core import config as config_mod
|
||||
|
||||
config_mod.get_settings.cache_clear()
|
||||
|
||||
import app.tasks.reco as reco_tasks
|
||||
|
||||
# monkeypatch async runner,避免依赖 DB
|
||||
async def _fake_run_reco_async(**kwargs): # type: ignore[no-untyped-def]
|
||||
from app.features.personalized_reco.observability.types import RecoMeta
|
||||
from app.features.personalized_reco.reco_engine.types import RecoEngineResult, RecommendedItem
|
||||
|
||||
return RecoEngineResult(
|
||||
items=[RecommendedItem(content_id=1, text="t1", final_score=1.0, fallback_level_final=0, explanations={})],
|
||||
meta=RecoMeta(scene="push", served_k=1),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(reco_tasks, "_run_reco_async", _fake_run_reco_async)
|
||||
|
||||
out = reco_tasks.generate(scene="push", user_profile=_user_profile_dict(), k=1)
|
||||
assert out["meta"]["served_k"] == 1
|
||||
|
||||
129
server/tests/test_observability.py
Normal file
129
server/tests/test_observability.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.observability.builder import RecoMetaBuilder
|
||||
from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
def _u(*, need: dict | None = None, context: dict | None = None, emotion_score=None, conf_u: float = 0.9) -> UserProfileV1_2:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=now,
|
||||
profile_confidence=conf_u,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True),
|
||||
stage=UserStageOneHot(unknown=1),
|
||||
emotion_score=emotion_score,
|
||||
context=context or {},
|
||||
need=need or {},
|
||||
)
|
||||
|
||||
|
||||
def test_compute_missing_fields() -> None:
|
||||
u1 = _u(need={}, context={}, emotion_score=None)
|
||||
m1 = compute_missing_fields(u1)
|
||||
assert m1.need is True
|
||||
assert m1.context is True
|
||||
assert m1.emotion is True
|
||||
|
||||
u2 = _u(need={"x": 1}, context={"y": 1}, emotion_score=0.6)
|
||||
m2 = compute_missing_fields(u2)
|
||||
assert m2.need is False
|
||||
assert m2.context is False
|
||||
assert m2.emotion is False
|
||||
|
||||
|
||||
def test_compute_empty_reason_branches() -> None:
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=1,
|
||||
candidate_pool_size_raw=0,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=0,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "pool_empty"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=0,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "hard_filter_all"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=5,
|
||||
candidate_pool_size_after_freqcap=0,
|
||||
)
|
||||
== "freqcap_all"
|
||||
)
|
||||
|
||||
assert (
|
||||
compute_empty_reason(
|
||||
served_k=0,
|
||||
candidate_pool_size_raw=10,
|
||||
candidate_pool_size_after_hard_filter=5,
|
||||
candidate_pool_size_after_freqcap=3,
|
||||
)
|
||||
== "unknown"
|
||||
)
|
||||
|
||||
|
||||
def test_builder_outputs_stable_fields_and_monotonic_counts() -> None:
|
||||
u = _u(need={"emotional_support": 1}, context={}, emotion_score=None, conf_u=0.2)
|
||||
|
||||
# 故意设置“非单调”的输入,验证 builder 的防御修正
|
||||
meta = (
|
||||
RecoMetaBuilder(scene="feed", user_profile=u, k=30)
|
||||
.set_candidate_pool_size_raw(10)
|
||||
.set_after_hard_filter(12) # 非法:大于 raw
|
||||
.set_after_dedup(20) # 非法:大于 after_hard
|
||||
.set_after_freqcap(15) # 非法:大于 after_dedup(修正后会与 after_dedup 对齐)
|
||||
.set_served_k(99) # 非法:大于 after_freqcap
|
||||
.set_fallback_level_final(1, reason="freqcap_all")
|
||||
.build()
|
||||
)
|
||||
|
||||
d = meta.model_dump()
|
||||
for k in [
|
||||
"scene",
|
||||
"candidate_pool_size_raw",
|
||||
"candidate_pool_size_after_hard_filter",
|
||||
"candidate_pool_size_after_dedup",
|
||||
"candidate_pool_size_after_freqcap",
|
||||
"fallback_level_final",
|
||||
"served_k",
|
||||
"empty_reason",
|
||||
"conf_U",
|
||||
"missing_fields",
|
||||
]:
|
||||
assert k in d
|
||||
|
||||
assert meta.candidate_pool_size_raw == 10
|
||||
assert meta.candidate_pool_size_after_hard_filter == 10
|
||||
assert meta.candidate_pool_size_after_dedup == 10
|
||||
assert meta.candidate_pool_size_after_freqcap == 10
|
||||
assert meta.served_k == 10
|
||||
assert meta.conf_U == pytest.approx(0.2)
|
||||
assert meta.missing_fields.context is True
|
||||
assert meta.missing_fields.emotion is True
|
||||
|
||||
173
server/tests/test_reco_engine.py
Normal file
173
server/tests/test_reco_engine.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.interface import ContentRepository
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.reco_engine.orchestrator import recommend
|
||||
from app.features.personalized_reco.reco_engine.types import RecoConstraints
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
class _FakeRepo(ContentRepository):
|
||||
def __init__(self, candidates_by_level: dict[int, list[ContentProfileDTO]]):
|
||||
self._candidates_by_level = candidates_by_level
|
||||
|
||||
async def fetch_candidates( # type: ignore[override]
|
||||
self,
|
||||
*,
|
||||
scene: str,
|
||||
user_profile: object,
|
||||
fallback_level: int,
|
||||
limit: int,
|
||||
locale: str,
|
||||
exclude_content_ids: list[int] | None = None,
|
||||
) -> list[ContentProfileDTO]:
|
||||
# 单测:简化实现,只按 level 返回,忽略 limit/locale/exclude
|
||||
return list(self._candidates_by_level.get(int(fallback_level), []))[: int(limit)]
|
||||
|
||||
async def fetch_contents_by_ids(self, *, content_ids: list[int], locale: str) -> list[ContentProfileDTO]: # type: ignore[override]
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime(2026, 2, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _user_profile(*, stage: str = "unknown", emotion_score: float | None = 0.5) -> UserProfileV1_2:
|
||||
if stage == "expecting":
|
||||
st = UserStageOneHot(expecting=1, parenting=0, unknown=0) # type: ignore[arg-type]
|
||||
elif stage == "parenting":
|
||||
st = UserStageOneHot(expecting=0, parenting=1, unknown=0) # type: ignore[arg-type]
|
||||
else:
|
||||
st = UserStageOneHot(expecting=0, parenting=0, unknown=1) # type: ignore[arg-type]
|
||||
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=_now(),
|
||||
profile_confidence=1.0,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=emotion_score is not None, context=False, need=False),
|
||||
stage=st,
|
||||
emotion_score=emotion_score,
|
||||
context={},
|
||||
need={},
|
||||
)
|
||||
|
||||
|
||||
def _content(
|
||||
*,
|
||||
content_id: int,
|
||||
text: str = "hello",
|
||||
stage: str = "general",
|
||||
personalization_power: float = 0.0,
|
||||
risk_flags: list[str] | None = None,
|
||||
author_id: str | None = None,
|
||||
template_id: str | None = None,
|
||||
need_suitability: dict[str, float] | None = None,
|
||||
) -> ContentProfileDTO:
|
||||
return ContentProfileDTO(
|
||||
content_id=int(content_id),
|
||||
text=text,
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=None,
|
||||
context_suitability={},
|
||||
need_suitability=need_suitability or {},
|
||||
personalization_power=float(personalization_power),
|
||||
risk_flags=risk_flags or [],
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
review_confidence=0.7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_k_zero_returns_empty() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1)]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=0,
|
||||
now=_now(),
|
||||
locale=None,
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.served_k == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_pool_empty_sets_empty_reason() -> None:
|
||||
repo = _FakeRepo({0: []})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "pool_empty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_hard_filter_all_sets_empty_reason() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1, risk_flags=["block_health_medical"])]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="push",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "hard_filter_all"
|
||||
assert res.meta.risk_filtered_count_by_flag.get("block_health_medical", 0) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_freqcap_all_sets_empty_reason() -> None:
|
||||
# Push 场景:作者冷却命中,导致 after_freqcap=0
|
||||
repo = _FakeRepo({0: [_content(content_id=1, author_id="a1")]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="push",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
constraints=RecoConstraints(recent_author_ids=["a1"]),
|
||||
)
|
||||
assert res.items == []
|
||||
assert res.meta.empty_reason == "freqcap_all"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reco_engine_returns_item_and_explanations_enabled() -> None:
|
||||
repo = _FakeRepo({0: [_content(content_id=1, text="t1", personalization_power=0.0)]})
|
||||
res = await recommend(
|
||||
repo=repo,
|
||||
scene="feed",
|
||||
user_profile=_user_profile(stage="unknown"),
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=1,
|
||||
now=_now(),
|
||||
locale="en",
|
||||
)
|
||||
assert len(res.items) == 1
|
||||
assert res.items[0].content_id == 1
|
||||
assert res.items[0].text == "t1"
|
||||
assert res.items[0].explanations is not None
|
||||
|
||||
|
||||
130
server/tests/test_rerank_freqcap.py
Normal file
130
server/tests/test_rerank_freqcap.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap
|
||||
from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate
|
||||
|
||||
|
||||
def _c(
|
||||
*,
|
||||
content_id: int,
|
||||
score: float,
|
||||
author_id: str | None = None,
|
||||
template_id: str | None = None,
|
||||
stage: str = "general",
|
||||
need_key: str = "emotional_support",
|
||||
context_key: str = "family",
|
||||
) -> ScoredCandidate:
|
||||
cp = ContentProfileDTO(
|
||||
content_id=content_id,
|
||||
text="t",
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=None,
|
||||
context_suitability={context_key: 1.0},
|
||||
need_suitability={need_key: 1.0},
|
||||
personalization_power=0.0,
|
||||
risk_flags=[],
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
return ScoredCandidate(
|
||||
content_id=content_id,
|
||||
final_score=score,
|
||||
author_id=author_id,
|
||||
template_id=template_id,
|
||||
content_profile=cp,
|
||||
)
|
||||
|
||||
|
||||
def test_dedup_normalizes_str_int_ids() -> None:
|
||||
cands = [_c(content_id=1, score=0.9), _c(content_id=2, score=0.8), _c(content_id=3, score=0.7)]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=["1", "bad"],
|
||||
touched_or_viewed_ids=[2],
|
||||
k=10,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [3]
|
||||
assert r.meta.candidate_pool_size_after_dedup == 1
|
||||
assert r.meta.freqcap_filtered_counts["sentence"] == 2
|
||||
|
||||
|
||||
def test_freqcap_missing_recent_author_template_is_recorded() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.9, author_id="a1", template_id="t1"),
|
||||
_c(content_id=2, score=0.8, author_id="a2", template_id="t2"),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=10,
|
||||
recent_author_ids=None,
|
||||
recent_template_ids=None,
|
||||
)
|
||||
assert r.meta.missing_history_fields == ["author", "template"]
|
||||
assert "author" not in r.meta.freqcap_filtered_counts # 未提供则不执行该维度
|
||||
assert "template" not in r.meta.freqcap_filtered_counts
|
||||
|
||||
|
||||
def test_freqcap_filters_by_recent_author_when_provided() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.9, author_id="a1", template_id="t1"),
|
||||
_c(content_id=2, score=0.8, author_id="a2", template_id="t2"),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="widget",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=10,
|
||||
recent_author_ids=["a1"],
|
||||
recent_template_ids=None,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [2]
|
||||
assert r.meta.missing_history_fields == ["template"]
|
||||
assert r.meta.freqcap_filtered_counts["author"] == 1
|
||||
|
||||
|
||||
def test_feed_mmr_picks_diverse_second_item() -> None:
|
||||
# 构造:Top1 是 a1;c2 分数略高但同作者;c3 分数略低但不同作者/阶段
|
||||
c1 = _c(content_id=1, score=1.0, author_id="a1", template_id="t1", stage="general")
|
||||
c2 = _c(content_id=2, score=0.99, author_id="a1", template_id="t2", stage="general")
|
||||
c3 = _c(content_id=3, score=0.95, author_id="a2", template_id="t3", stage="expecting")
|
||||
|
||||
r = rerank_and_freqcap(
|
||||
scene="feed",
|
||||
scored_candidates=[c1, c2, c3],
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=2,
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids[0] == 1
|
||||
assert got_ids[1] == 3
|
||||
|
||||
|
||||
def test_push_topk_sorted_by_score_after_filters() -> None:
|
||||
cands = [
|
||||
_c(content_id=1, score=0.1),
|
||||
_c(content_id=2, score=0.9),
|
||||
_c(content_id=3, score=0.8),
|
||||
]
|
||||
r = rerank_and_freqcap(
|
||||
scene="push",
|
||||
scored_candidates=cands,
|
||||
already_recommended_ids=[],
|
||||
touched_or_viewed_ids=[],
|
||||
k=2,
|
||||
recent_author_ids=[],
|
||||
recent_template_ids=[],
|
||||
)
|
||||
got_ids = [x.content_id for x in r.ranked_items]
|
||||
assert got_ids == [2, 3]
|
||||
|
||||
133
server/tests/test_scoring.py
Normal file
133
server/tests/test_scoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.scoring.defaults import get_default_config
|
||||
from app.features.personalized_reco.scoring.score import score_content
|
||||
from app.features.personalized_reco.scoring.types import ExternalTerms, ScoreConfig
|
||||
from app.features.user_profile_scoring.types import ProfileAnswered, UserProfileV1_2, UserStageOneHot
|
||||
|
||||
|
||||
def _u(
|
||||
*,
|
||||
stage: str = "unknown",
|
||||
emotion_score: float | None = None,
|
||||
need: dict[str, int] | None = None,
|
||||
context: dict[str, int] | None = None,
|
||||
profile_confidence: float = 1.0,
|
||||
) -> UserProfileV1_2:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if stage == "expecting":
|
||||
s = UserStageOneHot(expecting=1, parenting=0, unknown=0)
|
||||
elif stage == "parenting":
|
||||
s = UserStageOneHot(expecting=0, parenting=1, unknown=0)
|
||||
else:
|
||||
s = UserStageOneHot(unknown=1)
|
||||
|
||||
return UserProfileV1_2(
|
||||
profile_generated_at=now,
|
||||
profile_confidence=profile_confidence,
|
||||
profile_answered=ProfileAnswered(stage=True, emotion=True, context=True, need=True),
|
||||
stage=s,
|
||||
emotion_score=emotion_score,
|
||||
context=context or {},
|
||||
need=need or {},
|
||||
)
|
||||
|
||||
|
||||
def _c(
|
||||
*,
|
||||
stage: str = "general",
|
||||
emotion_score: float | None = None,
|
||||
need_key: str = "emotional_support",
|
||||
context_key: str = "family",
|
||||
need_value: float = 1.0,
|
||||
context_value: float = 1.0,
|
||||
personalization_power: float = 1.0,
|
||||
review_confidence: float = 0.7,
|
||||
) -> ContentProfileDTO:
|
||||
return ContentProfileDTO(
|
||||
content_id=1,
|
||||
text="hello",
|
||||
stage=stage, # type: ignore[arg-type]
|
||||
emotion_score=emotion_score,
|
||||
context_suitability={context_key: context_value},
|
||||
need_suitability={need_key: need_value},
|
||||
personalization_power=personalization_power,
|
||||
risk_flags=[],
|
||||
review_confidence=review_confidence,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_fields_defaults_are_applied() -> None:
|
||||
u = _u(emotion_score=None, need={}, context={})
|
||||
c = _c(stage="general", emotion_score=None)
|
||||
r = score_content(scene="feed", user_profile=u, content_profile=c)
|
||||
|
||||
assert r.breakdown.S_need == pytest.approx(0.5)
|
||||
assert r.breakdown.S_context == pytest.approx(0.5)
|
||||
assert r.breakdown.S_emotion == pytest.approx(0.8)
|
||||
assert set(r.breakdown.missing_fields) == {"need", "context", "emotion"}
|
||||
|
||||
|
||||
def test_uncertainty_penalty_enabled_for_push_by_default_and_can_be_disabled() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1}, profile_confidence=0.2)
|
||||
c = _c(stage="general", emotion_score=0.6, personalization_power=1.0, review_confidence=0.2)
|
||||
|
||||
r_on = score_content(scene="push", user_profile=u, content_profile=c)
|
||||
assert r_on.breakdown.P_uncertainty > 0
|
||||
|
||||
cfg_off = ScoreConfig.model_validate(get_default_config("push").model_dump() | {"enable_uncertainty_penalty": False})
|
||||
r_off = score_content(scene="push", user_profile=u, content_profile=c, config=cfg_off)
|
||||
assert r_off.breakdown.P_uncertainty == pytest.approx(0.0)
|
||||
assert r_off.final_score > r_on.final_score
|
||||
|
||||
|
||||
def test_widget_emotion_soft_penalty_is_applied_outside_range() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1})
|
||||
|
||||
cfg = get_default_config("widget")
|
||||
assert cfg.widget_emotion_soft_range == (0.4, 0.8)
|
||||
assert cfg.widget_emotion_penalty_gamma == pytest.approx(0.25)
|
||||
|
||||
c_in = _c(stage="general", emotion_score=0.6, personalization_power=0.0)
|
||||
r_in = score_content(scene="widget", user_profile=u, content_profile=c_in, config=cfg)
|
||||
assert r_in.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.0)
|
||||
|
||||
c_out = _c(stage="general", emotion_score=0.0, personalization_power=0.0)
|
||||
r_out = score_content(scene="widget", user_profile=u, content_profile=c_out, config=cfg)
|
||||
assert r_out.breakdown.P_widget_emotion_out_of_range == pytest.approx(0.25)
|
||||
assert r_out.final_score < r_in.final_score
|
||||
|
||||
|
||||
def test_pass_false_forces_final_score_zero_but_breakdown_is_present() -> None:
|
||||
u = _u(stage="unknown", emotion_score=0.6, need={"emotional_support": 1}, context={"family": 1})
|
||||
c = _c(stage="general", emotion_score=0.6, personalization_power=1.0)
|
||||
r = score_content(scene="feed", user_profile=u, content_profile=c, pass_filters=False, external_terms=ExternalTerms())
|
||||
|
||||
assert r.final_score == pytest.approx(0.0)
|
||||
assert r.breakdown.passed is False
|
||||
# breakdown 字段集合稳定(至少包含关键分解项)
|
||||
d = r.breakdown.model_dump(by_alias=True)
|
||||
for k in [
|
||||
"scene",
|
||||
"pass",
|
||||
"S_need",
|
||||
"S_context",
|
||||
"S_stage",
|
||||
"S_emotion",
|
||||
"S_core",
|
||||
"S_personal",
|
||||
"S_fresh",
|
||||
"P_fatigue",
|
||||
"P_repeat",
|
||||
"P_risk",
|
||||
"P_uncertainty",
|
||||
"P_widget_emotion_out_of_range",
|
||||
"missing_fields",
|
||||
]:
|
||||
assert k in d
|
||||
|
||||
Reference in New Issue
Block a user