diff --git a/.githooks/pre-commit b/.githooks/pre-commit index fd3833bf..c5b10c47 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -172,11 +172,15 @@ SYNC_SCRIPT="$SCRIPT_DIR/scripts/sync-marathon-content.sh" if [[ -f "$SYNC_SCRIPT" ]]; then SYNC_OUT=$(bash "$SYNC_SCRIPT" --check 2>&1) SYNC_CODE=$? - if [[ $SYNC_CODE -ne 0 ]]; then + if [[ $SYNC_CODE -eq 1 ]]; then echo "" echo " ⚠️ Контент марафона: расхождение SoT vs bot-копия." echo " Запусти: bash scripts/sync-marathon-content.sh" echo " Затем: git add data/marathon-content.json && git commit --amend --no-edit" + elif [[ $SYNC_CODE -ne 0 ]]; then + echo "" + echo " ⚠️ Проверка контента марафона не выполнена (код $SYNC_CODE)." + echo "$SYNC_OUT" | sed 's/^/ /' fi fi diff --git a/bot.py b/bot.py index 84f6f09c..d8edd9ee 100644 --- a/bot.py +++ b/bot.py @@ -226,6 +226,16 @@ async def main(): except Exception as _e: logger.warning(f"⚠️ Migration 032 (notification_queue) skipped: {_e}", exc_info=True) + # Миграция 039: once-per-recipient receipts для milestone-нуджей (WP-117). + try: + _m039 = _il.import_module("db.migrations.039_wp117_milestone_receipts") + if await _m039.migrate_if_needed(await _get_pool()): + logger.info("✅ Migration 039: nudge_receipt создана") + else: + logger.info("✅ Migration 039: nudge_receipt уже существует") + except Exception as _e: + logger.warning(f"⚠️ Migration 039 (nudge_receipt) skipped: {_e}", exc_info=True) + # Миграция 037: scheduled_post — дедупликация + atomic publish lock (WP-167). # Индекс + статус 'publishing' защищают от дублей при публикации в клуб. try: diff --git a/config/nudge_registry.py b/config/nudge_registry.py new file mode 100644 index 00000000..9c28edfd --- /dev/null +++ b/config/nudge_registry.py @@ -0,0 +1,182 @@ +"""Loader for the WP-117 canonical nudge registry. + +The registry lives in `config/nudge_registry.yaml` and is the single source of +truth for the taxonomy of nudge rules, keys and DP.SC.116 classes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import yaml + +from config.settings import BASE_DIR + + +#: Default path to the canonical nudge registry YAML. +DEFAULT_REGISTRY_PATH = BASE_DIR / "config" / "nudge_registry.yaml" + + +@dataclass(frozen=True) +class NudgeKeyDataContract: + """Per-nudge-key data contract derived from the registry.""" + + cooldown_days: int + class_cap: str + payload_keys: tuple[str, ...] + ai_personalizable: bool + stopgap: bool + dedup_scope: str + + +@dataclass(frozen=True) +class NudgeKeyConfig: + """Configuration for one concrete nudge_key.""" + + nudge_key: str + rule_id: str + canonical_type: str + eligible_tiers: tuple[str, ...] + opt_out_category: str + phase: str + data_contract: NudgeKeyDataContract + + +@dataclass(frozen=True) +class RuleConfig: + """Configuration for one engagement_analyzer rule_id.""" + + rule_id: str + canonical_type: str + eligible_tiers: tuple[str, ...] + opt_out_category: str + phase: str + nudge_keys: tuple[str, ...] + data_contract: NudgeKeyDataContract + + +@dataclass(frozen=True) +class NudgeRegistry: + """In-memory view of the canonical nudge registry.""" + + version: str + rules: dict[str, RuleConfig] + nudge_keys: dict[str, NudgeKeyConfig] + dp_sc_116_class_map: dict[str, str] + + +def _as_tuple(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return (value,) + return tuple(str(item) for item in value) + + +def _load_data_contract(raw: dict[str, Any]) -> NudgeKeyDataContract: + return NudgeKeyDataContract( + cooldown_days=int(raw.get("cooldown_days", 0)), + class_cap=str(raw.get("class_cap", "capped")), + payload_keys=_as_tuple(raw.get("payload_keys")), + ai_personalizable=bool(raw.get("ai_personalizable", False)), + stopgap=bool(raw.get("stopgap", False)), + dedup_scope=str(raw.get("dedup_scope", "recurring")), + ) + + +def load_nudge_registry(path: Optional[Path] = None) -> NudgeRegistry: + """Load and normalize the canonical nudge registry. + + Args: + path: optional override path; defaults to `config/nudge_registry.yaml`. + + Returns: + NudgeRegistry with indexed rules and nudge_keys. + """ + path = path or DEFAULT_REGISTRY_PATH + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + version = str(data.get("version", "0.0.0")) + raw_rules = data.get("rules", []) + + rules: dict[str, RuleConfig] = {} + nudge_keys: dict[str, NudgeKeyConfig] = {} + + for raw in raw_rules: + rule_id = str(raw["rule_id"]) + canonical_type = str(raw["canonical_type"]) + eligible_tiers = _as_tuple(raw.get("eligible_tiers")) + opt_out_category = str(raw.get("opt_out_category", "engagement")) + phase = str(raw.get("phase", "F1")) + rule_keys = _as_tuple(raw.get("nudge_keys")) + data_contract = _load_data_contract(raw.get("data_contract", {})) + + rule = RuleConfig( + rule_id=rule_id, + canonical_type=canonical_type, + eligible_tiers=eligible_tiers, + opt_out_category=opt_out_category, + phase=phase, + nudge_keys=rule_keys, + data_contract=data_contract, + ) + if rule_id in rules: + raise ValueError(f"Duplicate rule_id in nudge registry: {rule_id}") + rules[rule_id] = rule + + for key in rule_keys: + if key in nudge_keys: + raise ValueError( + f"Duplicate nudge_key '{key}' mapped to both " + f"'{nudge_keys[key].rule_id}' and '{rule_id}'" + ) + nudge_keys[key] = NudgeKeyConfig( + nudge_key=key, + rule_id=rule_id, + canonical_type=canonical_type, + eligible_tiers=eligible_tiers, + opt_out_category=opt_out_category, + phase=phase, + data_contract=data_contract, + ) + + dp_sc_116_class_map = { + str(k): str(v) for k, v in data.get("dp_sc_116_class_map", {}).items() + } + + return NudgeRegistry( + version=version, + rules=rules, + nudge_keys=nudge_keys, + dp_sc_116_class_map=dp_sc_116_class_map, + ) + + +def get_rule_config(rule_id: str, registry: Optional[NudgeRegistry] = None) -> RuleConfig: + """Return the canonical configuration for a rule_id.""" + registry = registry or _REGISTRY + if rule_id not in registry.rules: + raise KeyError(f"rule_id '{rule_id}' is not mapped in the nudge registry") + return registry.rules[rule_id] + + +def get_nudge_key_config( + nudge_key: str, registry: Optional[NudgeRegistry] = None +) -> NudgeKeyConfig: + """Return the canonical configuration for a nudge_key.""" + registry = registry or _REGISTRY + if nudge_key not in registry.nudge_keys: + raise KeyError(f"nudge_key '{nudge_key}' is not mapped in the nudge registry") + return registry.nudge_keys[nudge_key] + + +def get_canonical_type_for_dp_sc_116_class(class_name: str) -> Optional[str]: + """Map a DP.SC.116 class name to its canonical nudge type.""" + return _REGISTRY.dp_sc_116_class_map.get(class_name) + + +# Module-level singleton, loaded once at import time. +_REGISTRY = load_nudge_registry() diff --git a/config/nudge_registry.yaml b/config/nudge_registry.yaml new file mode 100644 index 00000000..be86c843 --- /dev/null +++ b/config/nudge_registry.yaml @@ -0,0 +1,322 @@ +# ═══════════════════════════════════════════════════════════════════ +# WP-117 Canonical Nudge Registry +# ═══════════════════════════════════════════════════════════════════ +# +# Machine-readable source-of-truth for nudge taxonomy: +# rule_id -> canonical_type, eligible_tiers, opt_out_category, phase, data_contract +# nudge_key -> rule_id, cooldown_days, class_cap, payload_keys, +# ai_personalizable, stopgap, dedup_scope +# +# Boundaries: +# - WP-117 owns the rule/taxonomy and this registry. +# - WP-418 owns cooldown, class cap, opt-out enforcement and channel policy. +# - engagement_analyzer.py registers rules at import time; this file maps them. +# +# Versioning: bump `version` whenever the schema or mapping changes. +version: "1.1.0" + +# ─────────────────────────────────────────────────────────────────── +# 1. RULES: mapping from engagement_analyzer rule_id to canonical metadata. +# `nudge_keys` lists every concrete key a rule can produce. +# ─────────────────────────────────────────────────────────────────── +rules: + # T1. Реактивация и восстановление engagement + - rule_id: inactivity_3d + canonical_type: engagement_reactivation + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [nudge_inactivity] + data_contract: + cooldown_days: 7 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: low_engagement_7d + canonical_type: engagement_reactivation + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [nudge_low_engagement] + data_contract: + cooldown_days: 14 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: streak_drop + canonical_type: engagement_reactivation + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [nudge_streak_drop] + data_contract: + cooldown_days: 14 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + # T2. Ритм практики и устранение застревания + - rule_id: slot_missing_3d + canonical_type: practice_rhythm + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [nudge_slot_missing_3d] + data_contract: + cooldown_days: 7 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: marathon_stalled + canonical_type: practice_rhythm + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: return + phase: F1 + nudge_keys: [nudge_marathon_stalled] + data_contract: + cooldown_days: 7 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: low_regularity + canonical_type: practice_rhythm + eligible_tiers: [T2, T3, T4] + opt_out_category: engagement + phase: F2 + nudge_keys: [nudge_low_regularity] + data_contract: + cooldown_days: 14 + class_cap: class_capped + payload_keys: [] + ai_personalizable: true + stopgap: false + + # T3. Признание прогресса и переход по траектории + - rule_id: achievement_sessions + canonical_type: recognition_progress + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: recognition + phase: F1 + nudge_keys: + - nudge_sessions_10 + - nudge_sessions_25 + - nudge_sessions_50 + - nudge_sessions_100 + data_contract: + cooldown_days: 30 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: true + dedup_scope: once_per_recipient + + - rule_id: achievement_active_days + canonical_type: recognition_progress + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: recognition + phase: F1 + nudge_keys: + - nudge_active_days_7 + - nudge_active_days_14 + - nudge_active_days_30 + data_contract: + cooldown_days: 30 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: true + dedup_scope: once_per_recipient + + - rule_id: stage_upgrade + canonical_type: recognition_progress + eligible_tiers: [T2, T3, T4] + opt_out_category: trajectory + phase: F2 + nudge_keys: + - nudge_stage_reached_2 + - nudge_stage_reached_3 + - nudge_stage_reached_4 + data_contract: + cooldown_days: 30 + class_cap: class_capped + payload_keys: [stage, recommend_stream, suggest_tier_upgrade] + ai_personalizable: true + stopgap: true + dedup_scope: once_per_recipient + + - rule_id: agency_growing + canonical_type: recognition_progress + eligible_tiers: [T3, T4] + opt_out_category: trajectory + phase: F2 + nudge_keys: [nudge_agency_growing] + data_contract: + cooldown_days: 14 + class_cap: class_capped + payload_keys: [] + ai_personalizable: true + stopgap: false + + - rule_id: agency_high + canonical_type: recognition_progress + eligible_tiers: [T3, T4] + opt_out_category: trajectory + phase: F2 + nudge_keys: [nudge_agency_high] + data_contract: + cooldown_days: 30 + class_cap: class_capped + payload_keys: [] + ai_personalizable: true + stopgap: false + + # T4. Диагностический ЦД-инсайт + - rule_id: diagnost_bottleneck + canonical_type: diagnostic_insight + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: trajectory + phase: F2 + nudge_keys: + - nudge_bottleneck_cp_rhy + - nudge_bottleneck_cp_wld + - nudge_bottleneck_cp_skl + - nudge_bottleneck_cp_int + - nudge_bottleneck_cp_agt + data_contract: + cooldown_days: 14 + class_cap: class_capped + payload_keys: [bottleneck_slot, recommended_stream] + ai_personalizable: true + stopgap: false + + # T5. Онбординг, оснащение и lifecycle-конверсия + - rule_id: onboarder_gap + canonical_type: onboarding_lifecycle + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: onboarder + phase: F1 + nudge_keys: + - nudge_onboarder_gap_x2 + - nudge_onboarder_gap_x3 + data_contract: + cooldown_days: 7 + class_cap: class_capped + payload_keys: [gap] + ai_personalizable: false + stopgap: false + + # T6. Адаптация частоты и защита от notification fatigue + - rule_id: notification_fatigue + canonical_type: frequency_adaptation + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F2 + nudge_keys: [nudge_reduce_frequency] + data_contract: + cooldown_days: 30 + class_cap: class_capped + payload_keys: [] + ai_personalizable: true + stopgap: false + + # T7. Транзакционное сопровождение обучения и расписания + # NOTE: WP-117 has no live rules for this type yet; placeholder rule_ids are declared + # so the registry stays complete and the test enforces future mapping. + - rule_id: trial_reminder + canonical_type: transactional_learning + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: onboarder + phase: F1 + nudge_keys: [] + data_contract: + cooldown_days: 7 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: homework_deadline + canonical_type: transactional_learning + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [] + data_contract: + cooldown_days: 1 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: homework_reviewed + canonical_type: transactional_learning + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: recognition + phase: F1 + nudge_keys: [] + data_contract: + cooldown_days: 1 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: schedule_event + canonical_type: transactional_learning + eligible_tiers: [T1, T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [] + data_contract: + cooldown_days: 1 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + + - rule_id: mentor_queue + canonical_type: transactional_learning + eligible_tiers: [T2, T3, T4] + opt_out_category: engagement + phase: F1 + nudge_keys: [] + data_contract: + cooldown_days: 1 + class_cap: class_capped + payload_keys: [] + ai_personalizable: false + stopgap: false + +# ─────────────────────────────────────────────────────────────────── +# 2. DP.SC.116 CLASS MAP: each declared nudge class maps to exactly one +# canonical_type. See PACK-digital-platform/pack/digital-platform/ +# 08-service-clauses/DP.SC.116-notifications-nudges.md. +# ─────────────────────────────────────────────────────────────────── +dp_sc_116_class_map: + inactivity: engagement_reactivation + streak: engagement_reactivation + milestone: recognition_progress + cp_insight: diagnostic_insight + trial: onboarding_lifecycle + onboarding: onboarding_lifecycle + fatigue: frequency_adaptation + homework_deadline: transactional_learning + homework_reviewed: transactional_learning + schedule: transactional_learning + mentor: transactional_learning + +# ─────────────────────────────────────────────────────────────────── +# 3. TIER AXIOM (declarative, not enforced here): +# T0 = pre-Ory / consent; T1 = identified; T2 = subscription; +# T3 = AI-client + personal memory/projection; T4 = GitHub/pack/agents. +# WP-418 owns the authoritative Tier Authority lookup. +# ─────────────────────────────────────────────────────────────────── diff --git a/core/notification_service.py b/core/notification_service.py index 25cea780..17483ded 100644 --- a/core/notification_service.py +++ b/core/notification_service.py @@ -235,6 +235,21 @@ async def drain( ) try: await deliver_fn(chat_id, content_spec) + try: + await conn.execute( + """UPDATE development.nudge_receipt + SET status = 'delivered', delivered_at = NOW() + WHERE queue_id = $1 AND status = 'reserved'""", + row["id"], + ) + except Exception as receipt_error: + # Migration 039 is fail-open at boot for legacy deploys. + # Delivery must not fail after transport acknowledgement; + # a missing settlement remains safely at-most-once. + logger.warning( + "[Delivery] receipt settlement failed id=%s: %s", + row["id"], receipt_error, + ) delivered += 1 except Exception as e: failed += 1 diff --git a/core/nudge_delivery.py b/core/nudge_delivery.py index d49b07c8..b89faf79 100644 --- a/core/nudge_delivery.py +++ b/core/nudge_delivery.py @@ -40,11 +40,17 @@ class ClassCap(Enum): CLASS_EXCLUSIVE = "exclusive" # единственный в батче для пользователя +class DedupScope(Enum): + RECURRING = "recurring" + ONCE_PER_RECIPIENT = "once_per_recipient" + + @dataclass(frozen=True) class NudgeTypeConfig: nudge_type: str cooldown_days: int # 0 = кулдаун не применяется (окно схлопывается в NOW()) class_cap: ClassCap + dedup_scope: DedupScope = DedupScope.RECURRING channel_defaults: list[str] = field(default_factory=lambda: ["telegram"]) @@ -194,9 +200,29 @@ async def _try_enqueue_one(user_id: int, candidate: NudgeCandidate) -> EnqueueRe enqueued=False, reason="cap-exceeded", ) + receipt_id: Optional[int] = None + if config.dedup_scope == DedupScope.ONCE_PER_RECIPIENT: + receipt = await conn.fetchrow( + """INSERT INTO development.nudge_receipt + (recipient_chat_id, nudge_key, status, reserved_at) + VALUES ($1, $2, 'reserved', NOW()) + ON CONFLICT (recipient_chat_id, nudge_key) DO NOTHING + RETURNING id""", + user_id, + candidate.nudge_type, + ) + if not receipt: + return EnqueueResult( + user_id=user_id, + nudge_type=candidate.nudge_type, + enqueued=False, + reason="once-claimed", + ) + receipt_id = receipt["id"] + queue_class = "capped" if config.class_cap == ClassCap.CLASS_CAPPED else candidate.nudge_type idempotency_key = f"nudge:{user_id}:{_today_utc().isoformat()}:{candidate.nudge_type}" - await conn.fetchrow( + queue_row = await conn.fetchrow( """INSERT INTO development.notification_queue (chat_id, notification_class, payload, priority, dedup_key, journal_key, journal_type, status) @@ -205,6 +231,14 @@ async def _try_enqueue_one(user_id: int, candidate: NudgeCandidate) -> EnqueueRe user_id, queue_class, json.dumps(candidate.payload), candidate.priority, candidate.dedup_key, idempotency_key, "nudge", ) + if receipt_id is not None: + await conn.execute( + """UPDATE development.nudge_receipt + SET queue_id = $1 + WHERE id = $2""", + queue_row["id"], + receipt_id, + ) return EnqueueResult(user_id=user_id, nudge_type=candidate.nudge_type, enqueued=True, reason=None) diff --git a/core/nudge_producer.py b/core/nudge_producer.py index fb4e0e8a..88cd9eba 100644 --- a/core/nudge_producer.py +++ b/core/nudge_producer.py @@ -26,6 +26,7 @@ import logging +from config.nudge_registry import get_rule_config from core.nudge_delivery import NudgeCandidate from core.nudge_policy import stopgap_suppression_reason from core.onboarder.offer import offer_payload @@ -33,25 +34,28 @@ logger = logging.getLogger(__name__) -# Maps engagement_analyzer.py rule_id -> opt_out_category (DP.SC.116 vocab). -# Unmapped rule_ids fall back to "engagement" (the majority case). -_RULE_CATEGORY: dict[str, str] = { - "inactivity_3d": "engagement", - "slot_missing_3d": "engagement", - "low_engagement_7d": "engagement", - "low_regularity": "engagement", - "notification_fatigue": "engagement", - "streak_drop": "engagement", - "marathon_stalled": "return", - "achievement_sessions": "recognition", - "achievement_active_days": "recognition", - "stage_upgrade": "trajectory", - "agency_growing": "trajectory", - "agency_high": "trajectory", - "diagnost_bottleneck": "trajectory", - "onboarder_gap": "onboarder", -} -_DEFAULT_CATEGORY = "engagement" +_REACTIVATION_TYPE = "engagement_reactivation" +_RECOGNITION_TYPE = "recognition_progress" + + +def arbitrate_narrative(nudges: list[dict]) -> list[dict]: + """Suppress recognition while current rule facts say reactivation is needed. + + This runs on raw analyzer output before cooldown filtering. A fresh activity + event makes the reactivation rule stop firing on the next run, which closes + the suppression window without a second TTL. + """ + has_reactivation = any( + get_rule_config(n["rule_id"]).canonical_type == _REACTIVATION_TYPE + for n in nudges + ) + if not has_reactivation: + return list(nudges) + return [ + n + for n in nudges + if get_rule_config(n["rule_id"]).canonical_type != _RECOGNITION_TYPE + ] def produce( @@ -91,7 +95,7 @@ def produce( ) continue - category = _RULE_CATEGORY.get(rule_id, _DEFAULT_CATEGORY) + category = get_rule_config(rule_id).opt_out_category if category == "return" and active_today: continue # 21-apr incident class: active user, no "come back". diff --git a/core/nudge_type_registry.py b/core/nudge_type_registry.py index 4c05a2e5..003e3ea2 100644 --- a/core/nudge_type_registry.py +++ b/core/nudge_type_registry.py @@ -1,175 +1,67 @@ -"""Nudge type registry for WP-117 Ф-roles. +"""Runtime adapter from the WP-117 canonical registry to WP-418 policy. -Registers all nudge types produced by engagement_analyzer.py in the -WP-418 policy engine (core.nudge_delivery.NUDGE_TYPE_CONFIG). - -This is a transitional step: the legacy scheduler still routes engagement -nudges through notification_service.enqueue(), but the new NudgeProducer -will use core.nudge_delivery.select_and_enqueue() once wired. - -Boundary: -- WP-117 owns the rule/taxonomy and this registry. -- WP-418 owns cooldown, class cap, opt-out, channel preferences. +WP-117 owns rule identity and recurrence semantics in +``config/nudge_registry.yaml``. WP-418 owns enforcement of cooldown, class cap, +opt-out and channel policy in ``core.nudge_delivery``. """ from __future__ import annotations -from core.nudge_delivery import ClassCap, NudgeTypeConfig +from config.nudge_registry import load_nudge_registry +from core.nudge_delivery import ClassCap, DedupScope, NudgeTypeConfig + -# Cooldowns mirror engagement_analyzer.RULES and DERIVED_RULES. -# All engagement nudges are CLASS_CAPPED because they are optional, -# source-agnostic outreach subject to the global daily cap (DP.SC.177). -_REGISTERED: dict[str, NudgeTypeConfig] = { - # ── Basic threshold rules ───────────────────────────────────────── - "nudge_slot_missing_3d": NudgeTypeConfig( - nudge_type="nudge_slot_missing_3d", - cooldown_days=7, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_inactivity": NudgeTypeConfig( - nudge_type="nudge_inactivity", - cooldown_days=7, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_streak_drop": NudgeTypeConfig( - nudge_type="nudge_streak_drop", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_low_engagement": NudgeTypeConfig( - nudge_type="nudge_low_engagement", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_marathon_stalled": NudgeTypeConfig( - nudge_type="nudge_marathon_stalled", - cooldown_days=7, - class_cap=ClassCap.CLASS_CAPPED, - ), - # Achievement milestones - "nudge_sessions_10": NudgeTypeConfig( - nudge_type="nudge_sessions_10", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_sessions_25": NudgeTypeConfig( - nudge_type="nudge_sessions_25", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_sessions_50": NudgeTypeConfig( - nudge_type="nudge_sessions_50", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_sessions_100": NudgeTypeConfig( - nudge_type="nudge_sessions_100", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_active_days_7": NudgeTypeConfig( - nudge_type="nudge_active_days_7", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_active_days_14": NudgeTypeConfig( - nudge_type="nudge_active_days_14", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_active_days_30": NudgeTypeConfig( - nudge_type="nudge_active_days_30", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - # ── Derived-aware rules ─────────────────────────────────────────── - "nudge_stage_reached_2": NudgeTypeConfig( - nudge_type="nudge_stage_reached_2", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_stage_reached_3": NudgeTypeConfig( - nudge_type="nudge_stage_reached_3", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_stage_reached_4": NudgeTypeConfig( - nudge_type="nudge_stage_reached_4", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_agency_growing": NudgeTypeConfig( - nudge_type="nudge_agency_growing", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_agency_high": NudgeTypeConfig( - nudge_type="nudge_agency_high", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_low_regularity": NudgeTypeConfig( - nudge_type="nudge_low_regularity", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_reduce_frequency": NudgeTypeConfig( - nudge_type="nudge_reduce_frequency", - cooldown_days=30, - class_cap=ClassCap.CLASS_CAPPED, - ), - # Onboarder gap (WP-406 Х2/Х3 не закрыты) - "nudge_onboarder_gap_x2": NudgeTypeConfig( - nudge_type="nudge_onboarder_gap_x2", - cooldown_days=7, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_onboarder_gap_x3": NudgeTypeConfig( - nudge_type="nudge_onboarder_gap_x3", - cooldown_days=7, - class_cap=ClassCap.CLASS_CAPPED, - ), - # Diagnost bottleneck slots (cp-profile) - "nudge_bottleneck_cp_rhy": NudgeTypeConfig( - nudge_type="nudge_bottleneck_cp_rhy", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_bottleneck_cp_wld": NudgeTypeConfig( - nudge_type="nudge_bottleneck_cp_wld", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_bottleneck_cp_skl": NudgeTypeConfig( - nudge_type="nudge_bottleneck_cp_skl", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_bottleneck_cp_int": NudgeTypeConfig( - nudge_type="nudge_bottleneck_cp_int", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), - "nudge_bottleneck_cp_agt": NudgeTypeConfig( - nudge_type="nudge_bottleneck_cp_agt", - cooldown_days=14, - class_cap=ClassCap.CLASS_CAPPED, - ), +_CLASS_CAPS = { + "class_capped": ClassCap.CLASS_CAPPED, + "capped": ClassCap.CLASS_CAPPED, + "class_any": ClassCap.CLASS_ANY, + "any": ClassCap.CLASS_ANY, + "class_exclusive": ClassCap.CLASS_EXCLUSIVE, + "exclusive": ClassCap.CLASS_EXCLUSIVE, } +def _build_registered_types() -> dict[str, NudgeTypeConfig]: + registry = load_nudge_registry() + registered: dict[str, NudgeTypeConfig] = {} + for nudge_key, canonical in registry.nudge_keys.items(): + contract = canonical.data_contract + try: + class_cap = _CLASS_CAPS[contract.class_cap] + except KeyError as exc: + raise ValueError( + f"Unknown class_cap '{contract.class_cap}' for {nudge_key}" + ) from exc + try: + dedup_scope = DedupScope(contract.dedup_scope) + except ValueError as exc: + raise ValueError( + f"Unknown dedup_scope '{contract.dedup_scope}' for {nudge_key}" + ) from exc + registered[nudge_key] = NudgeTypeConfig( + nudge_type=nudge_key, + cooldown_days=contract.cooldown_days, + class_cap=class_cap, + dedup_scope=dedup_scope, + ) + return registered + + +_REGISTERED = _build_registered_types() + + def registered_types() -> dict[str, NudgeTypeConfig]: - """Return a shallow copy of the registry.""" + """Return a shallow copy of the canonical runtime mapping.""" return dict(_REGISTERED) -def register_types(target_config: dict[str, NudgeTypeConfig] | None = None) -> dict[str, NudgeTypeConfig]: - """Merge the WP-117 registry into a target config dict. - - If target_config is omitted, merges into core.nudge_delivery.NUDGE_TYPE_CONFIG. - """ +def register_types( + target_config: dict[str, NudgeTypeConfig] | None = None, +) -> dict[str, NudgeTypeConfig]: + """Merge canonical WP-117 types into the WP-418 runtime config.""" if target_config is None: from core.nudge_delivery import NUDGE_TYPE_CONFIG + target_config = NUDGE_TYPE_CONFIG target_config.update(_REGISTERED) return target_config diff --git a/core/scheduler.py b/core/scheduler.py index e84bd573..742da26f 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -2478,7 +2478,7 @@ async def send_engagement_nudges(): is_ai_personalizable, ) from core.nudge_delivery import get_recent_nudges_batch, select_and_enqueue - from core.nudge_producer import produce as produce_nudges + from core.nudge_producer import arbitrate_narrative, produce as produce_nudges from db.queries.nudges import get_nudge_candidates from i18n import t @@ -2614,6 +2614,13 @@ async def send_engagement_nudges(): if not nudges: continue + # WP-117 Ф-narrative: arbitrate on raw rule facts before cooldown + # filtering and before choosing the first message. A reactivation + # cooldown must not let a contradictory recognition message pass. + nudges = arbitrate_narrative(nudges) + if not nudges: + continue + # Drop nudge_types already sent within their cooldown window # (batch-fetched above via get_recent_nudges_batch — replaces the # old per-candidate was_nudge_sent_recently() lookup). diff --git a/db/migrations/039_wp117_milestone_receipts.py b/db/migrations/039_wp117_milestone_receipts.py new file mode 100644 index 00000000..08c1f3ff --- /dev/null +++ b/db/migrations/039_wp117_milestone_receipts.py @@ -0,0 +1,54 @@ +"""Migration 039: at-most-once receipts for WP-117 milestone nudges. + +The receipt and ``development.notification_queue`` share one database so a +producer can claim a milestone and enqueue it in the same transaction. +``recipient_chat_id`` is intentionally explicit: the current delivery contract +has no canonical account key until WP-117 Ф-identity is completed. +""" + +from __future__ import annotations + +import asyncio + +import asyncpg + + +DDL = """ +CREATE TABLE IF NOT EXISTS development.nudge_receipt ( + id BIGSERIAL PRIMARY KEY, + recipient_chat_id BIGINT NOT NULL, + nudge_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'reserved' + CHECK (status IN ('reserved', 'delivered')), + queue_id INTEGER REFERENCES development.notification_queue(id) + ON DELETE SET NULL, + reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ, + UNIQUE (recipient_chat_id, nudge_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_nudge_receipt_queue +ON development.nudge_receipt(queue_id) +WHERE queue_id IS NOT NULL; +""" + + +async def migrate_if_needed(pool: asyncpg.Pool) -> bool: + async with pool.acquire() as conn: + existed = await conn.fetchval( + """SELECT to_regclass('development.nudge_receipt') IS NOT NULL""" + ) + await conn.execute(DDL) + return not bool(existed) + + +if __name__ == "__main__": + from config import DATABASE_URL + + async def run() -> None: + pool = await asyncpg.create_pool(DATABASE_URL) + created = await migrate_if_needed(pool) + print(f"Migration 039: {'created' if created else 'already exists'}") + await pool.close() + + asyncio.run(run()) diff --git a/db/models.py b/db/models.py index 31dca5c6..12ee0a57 100644 --- a/db/models.py +++ b/db/models.py @@ -286,6 +286,29 @@ async def create_tables(pool: asyncpg.Pool): ON development.notification_queue(dedup_key) ''') + # WP-117 Ф-milestone-once: at-most-once claim for milestone messages. + # recipient_chat_id is the honest runtime identity until Ф-identity + # introduces a canonical account key. + await conn.execute(''' + CREATE TABLE IF NOT EXISTS development.nudge_receipt ( + id BIGSERIAL PRIMARY KEY, + recipient_chat_id BIGINT NOT NULL, + nudge_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'reserved' + CHECK (status IN ('reserved', 'delivered')), + queue_id INTEGER REFERENCES development.notification_queue(id) + ON DELETE SET NULL, + reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ, + UNIQUE (recipient_chat_id, nudge_key) + ) + ''') + await conn.execute(''' + CREATE UNIQUE INDEX IF NOT EXISTS idx_nudge_receipt_queue + ON development.nudge_receipt(queue_id) + WHERE queue_id IS NOT NULL + ''') + # ═══════════════════════════════════════════════════════════ # ЛЕНТА: НЕДЕЛЬНЫЕ ПЛАНЫ # ═══════════════════════════════════════════════════════════ diff --git a/docs/data/tables.md b/docs/data/tables.md index 98756299..66fe5118 100644 --- a/docs/data/tables.md +++ b/docs/data/tables.md @@ -326,6 +326,22 @@ **Ретеншна нет** (рост навсегда). Constraint будущей чистки: retention ≥ max(dedup_hours) = 48 ч, иначе дедуп `capped` ослепнет. +### 3.5. `nudge_receipt` (однократные milestone-нуджи) + +> WP-117 Ф-milestone-once. Receipt резервируется в одной транзакции с `notification_queue`; неоднозначная транспортная ошибка оставляет `reserved` терминальным (at-most-once, без автоматического reaper). Гарантия пока действует на Telegram-получателя, а не на каноническую личность — переход к account key относится к Ф-identity. + +| Поле | Тип | Default | Описание | +|------|-----|---------|----------| +| `id` | BIGSERIAL | — | PK | +| `recipient_chat_id` | BIGINT | — | Текущий идентификатор получателя в Telegram | +| `nudge_key` | TEXT | — | Неизменяемая идентичность milestone-сообщения | +| `status` | TEXT | `reserved` | `reserved` → `delivered`; иных автоматических переходов нет | +| `queue_id` | INTEGER | `NULL` | Ссылка на строку `notification_queue` | +| `reserved_at` | TIMESTAMPTZ | `NOW()` | Момент атомарного claim | +| `delivered_at` | TIMESTAMPTZ | `NULL` | Заполняется только после успешного вызова транспорта | + +**Constraints:** UNIQUE(`recipient_chat_id`, `nudge_key`); UNIQUE(`queue_id`) WHERE queue_id IS NOT NULL. + --- ## 4. Аутентификация и интеграции @@ -947,6 +963,7 @@ channel_mentions_log — standalone (по channel_id + message_id) | Дата | Изменение | |------|-----------| +| 2026-08-06 | **Миграция 039 (WP-117 Ф-milestone-once):** `development.nudge_receipt` — at-most-once claim для milestone-нуджей с атомарной постановкой в очередь и честным ограничением once-per-Telegram-recipient до завершения Ф-identity. | | 2026-07-17 | **Миграции 236+238 на Railway (WP-117 Ф-onboarding-gap):** `learning.onboarding_state` на Railway пилот-бота — было 29 колонок (только каноническая 233), не хватало 9 из `neon-migrations/mvp/236-wp349-onboarding-state-upgrade-markers.sql` и `238-wp349-onboarding-state-referral.sql` (`msg_f_sent_at`/`msg_g_sent_at`, `cp_stage`, `has_diagnosis`, `msg_b_low/b_high_sent_at`, `msg_c_sent_at`, `msg_e_sent_at`, `referral_source`). Batch-fetch F/G-маркеров (WP-349 Ф6/Ф7, `core/scheduler.py`) падал fail-open на каждый запуск 13:00. Миграции 236+238 применены напрямую к живой Railway-БД; сверка с Neon-прод — 38/38, diff пуст. Миграция 025 (bootstrap) дополнена теми же 9 колонками, чтобы дрейф не повторился при будущем пересоздании базы с нуля. | | 2026-07-08 | **Миграция 038 (WP-117 Ф-onboarding-gap):** `learning.onboarding_state` на Railway пересоздана по канонической схеме (`neon-migrations/mvp/233-wp346-onboarding-state.sql`) — было 16 колонок вместо 29 (не хватало `last_nudge_at`, `has_subscription`, всех `first_use_*`, `slot_count` и др., блокировало PR #291). Причина: миграция 025 (2026-06-06) создавала таблицу своим устаревшим inline DDL, разошедшимся с 233. `marathon_queue` получил недостающую `bot_id` (канонический `240-wp7-mar5-marathon-queue-bot-id.sql`). Миграция 025 исправлена, чтобы будущий bootstrap с нуля не воспроизводил тот же дрейф. Пир-сессия `2026-07-08-01-wp117-railway-column-drift`. | | 2026-06-12 | **§3.4 `notification_queue` (WP-418 Ф3-Ф4):** приватная очередь Доставщика (класс-модель, дедуп, потолок, журнал). Ф4 добавил `journal_key`/`journal_type` (семантический журнал drain — контракт readers domain_event) + idempotent ALTER. Peer-сессия 2026-06-12-06. | diff --git a/scripts/sync-marathon-content.sh b/scripts/sync-marathon-content.sh index 4c9862fd..0341f1cf 100755 --- a/scripts/sync-marathon-content.sh +++ b/scripts/sync-marathon-content.sh @@ -18,7 +18,7 @@ BOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEST="$BOT_DIR/data/marathon-content.json" # Авторский файл ищем относительно бота (сиблинг в ~/IWE) или по env SRC override. -SRC="${MARATHON_CONTENT_SRC:-$BOT_DIR/../../DS-marathon-v2-tseren/materials/participants/marathon-content.json}" +SRC="${MARATHON_CONTENT_SRC:-$BOT_DIR/../DS-marathon-v2-tseren/materials/participants/marathon-content.json}" CHECK_ONLY=0 [[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 diff --git a/scripts/wp117_backfill_nudge_receipts.py b/scripts/wp117_backfill_nudge_receipts.py new file mode 100755 index 00000000..62c6b9dd --- /dev/null +++ b/scripts/wp117_backfill_nudge_receipts.py @@ -0,0 +1,120 @@ +"""Backfill WP-117 milestone receipts from delivered notification events. + +Default mode is dry-run. Use ``--apply`` only after migration 039 is deployed. +The script intentionally accepts only the three achievement key families; +``agency_high`` is recognition, but not a one-time milestone. +""" + +from __future__ import annotations + +import argparse +import asyncio +from collections import Counter +import os +import re +import sys +from dataclasses import dataclass +from datetime import datetime + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +from db.connection import get_learning_pool, get_pool + + +_EXTERNAL_ID = re.compile( + r"^notification-nudge:(?P\d+):[^:]+:" + r"(?Pnudge_(?:sessions|active_days|stage_reached)_\d+)$" +) + + +@dataclass(frozen=True) +class HistoricalReceipt: + recipient_chat_id: int + nudge_key: str + delivered_at: datetime + + +def parse_historical_receipt( + external_id: str, delivered_at: datetime +) -> HistoricalReceipt | None: + match = _EXTERNAL_ID.fullmatch(external_id) + if not match: + return None + return HistoricalReceipt( + recipient_chat_id=int(match.group("chat_id")), + nudge_key=match.group("nudge_key"), + delivered_at=delivered_at, + ) + + +async def load_historical_receipts() -> list[HistoricalReceipt]: + learning_pool = await get_learning_pool() + async with learning_pool.acquire() as conn: + rows = await conn.fetch( + """SELECT external_id, ingested_at + FROM domain_event + WHERE source = 'aist-bot' + AND event_type = 'notification_sent' + AND payload->>'notification_type' = 'nudge' + AND ( + external_id LIKE 'notification-nudge:%:nudge_sessions_%' + OR external_id LIKE 'notification-nudge:%:nudge_active_days_%' + OR external_id LIKE 'notification-nudge:%:nudge_stage_reached_%' + ) + ORDER BY ingested_at""" + ) + + first_delivery: dict[tuple[int, str], HistoricalReceipt] = {} + for row in rows: + receipt = parse_historical_receipt(row["external_id"], row["ingested_at"]) + if receipt is None: + continue + first_delivery.setdefault( + (receipt.recipient_chat_id, receipt.nudge_key), receipt + ) + return list(first_delivery.values()) + + +async def apply_receipts(receipts: list[HistoricalReceipt]) -> int: + pool = await get_pool() + inserted = 0 + async with pool.acquire() as conn: + async with conn.transaction(): + for receipt in receipts: + row_id = await conn.fetchval( + """INSERT INTO development.nudge_receipt + (recipient_chat_id, nudge_key, status, + reserved_at, delivered_at) + VALUES ($1, $2, 'delivered', $3, $3) + ON CONFLICT (recipient_chat_id, nudge_key) DO NOTHING + RETURNING id""", + receipt.recipient_chat_id, + receipt.nudge_key, + receipt.delivered_at, + ) + inserted += int(row_id is not None) + return inserted + + +async def main(apply: bool) -> None: + receipts = await load_historical_receipts() + print(f"Historical milestone receipts: {len(receipts)}") + if not apply: + by_key = Counter(receipt.nudge_key for receipt in receipts) + for nudge_key, count in sorted(by_key.items()): + print(f"DRY RUN: {nudge_key}={count}") + return + inserted = await apply_receipts(receipts) + print(f"Applied: {inserted}; already present: {len(receipts) - inserted}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "--apply", + action="store_true", + help="Write receipts. Without this flag the command is a dry-run.", + ) + args = parser.parse_args() + asyncio.run(main(apply=args.apply)) diff --git a/tests/smoke/test_notification_service.py b/tests/smoke/test_notification_service.py index a3cd76a6..976720a0 100644 --- a/tests/smoke/test_notification_service.py +++ b/tests/smoke/test_notification_service.py @@ -22,6 +22,7 @@ def __init__(self, cap_count=0, duplicate=False, drain_rows=None): self.updates = [] # id строк, по которым прошёл UPDATE (дренаж) self.update_sqls = [] # SQL UPDATE'ов — различение sent / suppressed (Ф4) self.inserts = [] # args INSERT'ов — проверка journal_key/journal_type (Ф4) + self.receipt_updates = [] def transaction(self): class _Tx: @@ -33,8 +34,11 @@ async def __aexit__(self_, *a): async def execute(self, sql, *args): if sql.strip().upper().startswith("UPDATE"): - self.updates.append(args[0] if args else None) - self.update_sqls.append(sql) + if "development.nudge_receipt" in sql: + self.receipt_updates.append(args[0] if args else None) + else: + self.updates.append(args[0] if args else None) + self.update_sqls.append(sql) return "OK" async def fetchval(self, sql, *args): @@ -175,11 +179,40 @@ async def deliver(chat_id, content_spec): assert delivered == [(555, "урок дня")] # реально доставлено assert stats["delivered"] == 1 assert conn.updates == [7] # статус помечен sent (log-before-send) + assert conn.receipt_updates == [7] # receipt подтверждён только после deliver # без journal_* — технический fallback (canary/ops-alert) assert journaled[0]["idempotency_key"] == "delivery:7" assert journaled[0]["notification_type"] == ns.CLASS_MUST_DELIVER +@pytest.mark.asyncio +async def test_transport_failure_leaves_once_receipt_reserved(monkeypatch): + row = { + "id": 8, + "chat_id": 555, + "notification_class": ns.CLASS_CAPPED, + "payload": '{"text": "milestone"}', + "priority": 4, + "journal_key": "nudge:555:2026-08-06:nudge_sessions_10", + "journal_type": "nudge", + } + conn = FakeConn(drain_rows=[row]) + _patch_pool(monkeypatch, conn) + + async def _journal_new(**_kwargs): + return True + + async def _ambiguous_failure(_chat_id, _content_spec): + raise TimeoutError("transport outcome unknown") + + monkeypatch.setattr(ns, "try_insert_notification", _journal_new) + + stats = await ns.drain(_ambiguous_failure) + + assert stats == {"delivered": 0, "failed": 1} + assert conn.receipt_updates == [] + + @pytest.mark.asyncio async def test_drain_journals_semantic_key_and_type(monkeypatch): # Ф4: контракт readers (was_nudge_sent_recently ищет diff --git a/tests/smoke/test_nudge_delivery.py b/tests/smoke/test_nudge_delivery.py index 44c664be..02a396a9 100644 --- a/tests/smoke/test_nudge_delivery.py +++ b/tests/smoke/test_nudge_delivery.py @@ -18,6 +18,8 @@ def __init__(self, cap_count=0, duplicate=False): self._duplicate = duplicate self._next_id = 100 self.inserts = [] # (chat_id, notification_class, payload, priority, dedup_key, journal_key, journal_type) + self.receipt_claimed = False + self.receipt_insert_attempts = 0 def transaction(self): class _Tx: @@ -37,6 +39,13 @@ async def fetchval(self, sql, *args): async def fetchrow(self, sql, *args): if sql.strip().startswith("SELECT 1"): return {"x": 1} if self._duplicate else None + if "INSERT INTO development.nudge_receipt" in sql: + self.receipt_insert_attempts += 1 + if self.receipt_claimed: + return None + self.receipt_claimed = True + self._next_id += 1 + return {"id": self._next_id} self._next_id += 1 self.inserts.append(args) return {"id": self._next_id} @@ -242,3 +251,72 @@ async def _fake_fetch(user_ids, nudge_type_cooldowns): await nd.get_recent_nudges_batch([1]) assert captured["cooldowns"] == {"a": 1, "b": 2} + + +@pytest.mark.asyncio +async def test_once_per_recipient_survives_cooldown_expiry(monkeypatch): + _register(monkeypatch, milestone=nd.NudgeTypeConfig( + nudge_type="milestone", cooldown_days=30, + class_cap=nd.ClassCap.CLASS_CAPPED, + dedup_scope=nd.DedupScope.ONCE_PER_RECIPIENT, + )) + conn = FakeConn(cap_count=0, duplicate=False) + _patch_pool(monkeypatch, conn) + candidate = nd.NudgeCandidate( + user_id=1, nudge_type="milestone", payload={"text": "done"}, + dedup_key="nudge:1:milestone", priority=4, + ) + + first = await nd.select_and_enqueue([candidate]) + second = await nd.select_and_enqueue([candidate]) + + assert first[0].enqueued is True + assert second == [nd.EnqueueResult( + user_id=1, nudge_type="milestone", enqueued=False, reason="once-claimed" + )] + assert len(conn.inserts) == 1 + + +@pytest.mark.asyncio +async def test_concurrent_once_claim_creates_one_queue_row(monkeypatch): + import asyncio + + _register(monkeypatch, milestone=nd.NudgeTypeConfig( + nudge_type="milestone", cooldown_days=30, + class_cap=nd.ClassCap.CLASS_CAPPED, + dedup_scope=nd.DedupScope.ONCE_PER_RECIPIENT, + )) + conn = FakeConn(cap_count=0, duplicate=False) + _patch_pool(monkeypatch, conn) + candidate = nd.NudgeCandidate( + user_id=1, nudge_type="milestone", payload={"text": "done"}, + dedup_key="nudge:1:milestone", priority=4, + ) + + results = await asyncio.gather( + nd.select_and_enqueue([candidate]), + nd.select_and_enqueue([candidate]), + ) + + assert sum(batch[0].enqueued for batch in results) == 1 + assert len(conn.inserts) == 1 + + +@pytest.mark.asyncio +async def test_suppressed_candidate_does_not_claim_receipt(monkeypatch): + _register(monkeypatch, milestone=nd.NudgeTypeConfig( + nudge_type="milestone", cooldown_days=30, + class_cap=nd.ClassCap.CLASS_CAPPED, + dedup_scope=nd.DedupScope.ONCE_PER_RECIPIENT, + )) + conn = FakeConn(cap_count=2, duplicate=False) + _patch_pool(monkeypatch, conn) + candidate = nd.NudgeCandidate( + user_id=1, nudge_type="milestone", payload={"text": "done"}, + dedup_key="nudge:1:milestone", priority=4, + ) + + result = await nd.select_and_enqueue([candidate]) + + assert result[0].reason == "cap-exceeded" + assert conn.receipt_insert_attempts == 0 diff --git a/tests/smoke/test_nudge_producer.py b/tests/smoke/test_nudge_producer.py index 025b1473..49582f88 100644 --- a/tests/smoke/test_nudge_producer.py +++ b/tests/smoke/test_nudge_producer.py @@ -48,7 +48,10 @@ def test_return_category_produced_when_not_active_today(): def test_onboarding_category_suppressed_when_ai_client_connected(): - np._RULE_CATEGORY["onboarding_gap"] = "onboarding" + original = np.get_rule_config + class _Rule: + opt_out_category = "onboarding" + np.get_rule_config = lambda _rule_id: _Rule() try: nudges = [_analyze_result("onboarding_gap", "nudge_onboarding_gap")] result = np.produce( @@ -57,7 +60,7 @@ def test_onboarding_category_suppressed_when_ai_client_connected(): ) assert result == [] finally: - del np._RULE_CATEGORY["onboarding_gap"] + np.get_rule_config = original def test_onboarder_gap_category_not_suppressed_by_ai_client_connected(): @@ -166,3 +169,21 @@ def test_stopgap_mixed_list_keeps_allowed_nudges(): active_today=False, first_use_connect_full=False, ) assert {c.nudge_type for c in result} == {"nudge_inactivity", "nudge_agency_high"} + + +def test_narrative_reactivation_suppresses_recognition_before_delivery_filters(): + nudges = [ + _analyze_result("inactivity_3d", "nudge_inactivity"), + _analyze_result("agency_high", "nudge_agency_high"), + _analyze_result("low_regularity", "nudge_low_regularity"), + ] + result = np.arbitrate_narrative(nudges) + assert {n["nudge_key"] for n in result} == { + "nudge_inactivity", + "nudge_low_regularity", + } + + +def test_narrative_activity_event_reopens_recognition_without_ttl(): + nudges = [_analyze_result("agency_high", "nudge_agency_high")] + assert np.arbitrate_narrative(nudges) == nudges diff --git a/tests/smoke/test_nudge_type_registry.py b/tests/smoke/test_nudge_type_registry.py index 9d69f71a..7274a522 100644 --- a/tests/smoke/test_nudge_type_registry.py +++ b/tests/smoke/test_nudge_type_registry.py @@ -60,3 +60,20 @@ def test_register_types_merges_into_target(): assert set(target) == {t[0] for t in EXPECTED_TYPES} # Default channel preserved assert target["nudge_inactivity"].channel_defaults == ["telegram"] + + +def test_only_true_milestones_are_once_per_recipient(): + types = reg.registered_types() + once = { + key for key, config in types.items() + if config.dedup_scope == nd.DedupScope.ONCE_PER_RECIPIENT + } + assert once == { + "nudge_sessions_10", "nudge_sessions_25", + "nudge_sessions_50", "nudge_sessions_100", + "nudge_active_days_7", "nudge_active_days_14", + "nudge_active_days_30", + "nudge_stage_reached_2", "nudge_stage_reached_3", + "nudge_stage_reached_4", + } + assert types["nudge_agency_high"].dedup_scope == nd.DedupScope.RECURRING diff --git a/tests/test_nudge_registry.py b/tests/test_nudge_registry.py new file mode 100644 index 00000000..a4986cb4 --- /dev/null +++ b/tests/test_nudge_registry.py @@ -0,0 +1,205 @@ +"""Tests for config.nudge_registry — canonical WP-117 nudge taxonomy. + +This is a hard completeness gate: any new rule_id produced by +engagement_analyzer or nudge_key registered in nudge_type_registry must +be explicitly mapped in config/nudge_registry.yaml. +""" + +from __future__ import annotations + +import core.engagement_analyzer as analyzer +import core.nudge_delivery as delivery +import core.nudge_policy as policy +import core.nudge_type_registry as type_reg +from config.nudge_registry import ( + load_nudge_registry, + get_rule_config, + get_nudge_key_config, + get_canonical_type_for_dp_sc_116_class, +) + + +# DP.SC.116 declared nudge classes. Every class must map to exactly one +# canonical_type. See PACK-digital-platform/08-service-clauses/DP.SC.116. +DP_SC_116_CLASSES = frozenset({ + "inactivity", + "streak", + "milestone", + "cp_insight", + "trial", + "onboarding", + "fatigue", + "homework_deadline", + "homework_reviewed", + "schedule", + "mentor", +}) + +# Canonical types produced by the 7-type taxonomy. +CANONICAL_TYPES = frozenset({ + "engagement_reactivation", + "practice_rhythm", + "recognition_progress", + "diagnostic_insight", + "onboarding_lifecycle", + "frequency_adaptation", + "transactional_learning", +}) + + +def _collect_analyzer_rule_ids() -> set[str]: + """Return every rule_id registered by engagement_analyzer decorators.""" + return {rule_id for rule_id, _fn, _cooldown in analyzer.RULES + analyzer.DERIVED_RULES} + + +def _collect_registered_nudge_keys() -> set[str]: + """Return every nudge_key registered in nudge_type_registry.""" + return set(type_reg.registered_types().keys()) + + +def _class_cap_name(nudge_key: str) -> str: + config = type_reg.registered_types()[nudge_key] + return config.class_cap.name.lower() + + +def test_registry_loads_without_errors(): + registry = load_nudge_registry() + assert registry.version + assert registry.rules + assert registry.nudge_keys + + +def test_every_analyzer_rule_id_is_mapped(): + """Fail if a new rule is added to engagement_analyzer without registry mapping.""" + registry = load_nudge_registry() + rule_ids = _collect_analyzer_rule_ids() + mapped = set(registry.rules.keys()) + missing = rule_ids - mapped + assert not missing, ( + f"rule_id(s) from engagement_analyzer missing in nudge registry: {sorted(missing)}. " + f"Add them to config/nudge_registry.yaml." + ) + + +def test_every_registered_nudge_key_is_mapped(): + """Fail if a new nudge_key is registered in nudge_type_registry without mapping.""" + registry = load_nudge_registry() + registered = _collect_registered_nudge_keys() + mapped = set(registry.nudge_keys.keys()) + missing = registered - mapped + assert not missing, ( + f"nudge_key(s) missing in nudge registry: {sorted(missing)}. " + f"Add them under the producing rule_id in config/nudge_registry.yaml." + ) + + +def test_nudge_keys_belong_to_their_rule_id(): + """Every mapped nudge_key must be declared under the rule that produces it.""" + registry = load_nudge_registry() + for nudge_key, key_config in registry.nudge_keys.items(): + rule = registry.rules[key_config.rule_id] + assert nudge_key in rule.nudge_keys, ( + f"nudge_key '{nudge_key}' mapped to rule '{key_config.rule_id}' " + f"but that rule declares {rule.nudge_keys}" + ) + + +def test_cooldown_and_class_cap_match_nudge_type_registry(): + """Registry data_contract must agree with the runtime NudgeTypeConfig.""" + registry = load_nudge_registry() + for nudge_key, type_config in type_reg.registered_types().items(): + key_config = registry.nudge_keys[nudge_key] + assert key_config.data_contract.cooldown_days == type_config.cooldown_days, ( + f"nudge_key '{nudge_key}': cooldown mismatch: " + f"registry={key_config.data_contract.cooldown_days} " + f"type_registry={type_config.cooldown_days}" + ) + expected_cap = type_config.class_cap.name.lower() + assert key_config.data_contract.class_cap == expected_cap, ( + f"nudge_key '{nudge_key}': class_cap mismatch: " + f"registry={key_config.data_contract.class_cap} expected={expected_cap}" + ) + assert key_config.data_contract.dedup_scope == type_config.dedup_scope.value + + +def test_once_per_recipient_is_explicitly_limited_to_milestones(): + registry = load_nudge_registry() + once_rules = { + rule.rule_id + for rule in registry.rules.values() + if rule.data_contract.dedup_scope == "once_per_recipient" + } + assert once_rules == { + "achievement_sessions", + "achievement_active_days", + "stage_upgrade", + } + assert registry.rules["agency_high"].data_contract.dedup_scope == "recurring" + + +def test_stopgap_flags_match_nudge_policy(): + """Registry stopgap flag must agree with nudge_policy stopgap lists.""" + registry = load_nudge_registry() + for rule_id in _collect_analyzer_rule_ids(): + rule = registry.rules[rule_id] + expected_stopgap = rule_id in policy.STOPGAP_DISABLED_RULES + assert rule.data_contract.stopgap == expected_stopgap, ( + f"rule_id '{rule_id}': stopgap mismatch: " + f"registry={rule.data_contract.stopgap} policy={expected_stopgap}" + ) + + for nudge_key in _collect_registered_nudge_keys(): + key_config = registry.nudge_keys[nudge_key] + expected_prefix_stopgap = nudge_key.startswith( + policy.STOPGAP_DISABLED_NUDGE_PREFIXES + ) + assert key_config.data_contract.stopgap == expected_prefix_stopgap, ( + f"nudge_key '{nudge_key}': stopgap prefix mismatch" + ) + + +def test_ai_personalizable_matches_analyzer(): + """Registry ai_personalizable flag must agree with engagement_analyzer prefixes.""" + registry = load_nudge_registry() + for nudge_key in _collect_registered_nudge_keys(): + key_config = registry.nudge_keys[nudge_key] + expected = nudge_key.startswith(analyzer.AI_PERSONALIZABLE_PREFIXES) + assert key_config.data_contract.ai_personalizable == expected, ( + f"nudge_key '{nudge_key}': ai_personalizable mismatch: " + f"registry={key_config.data_contract.ai_personalizable} expected={expected}" + ) + + +def test_all_dp_sc_116_classes_are_mapped(): + """Every declared DP.SC.116 class must map to a canonical type.""" + registry = load_nudge_registry() + mapped_classes = set(registry.dp_sc_116_class_map.keys()) + missing = DP_SC_116_CLASSES - mapped_classes + assert not missing, ( + f"DP.SC.116 class(es) missing from registry mapping: {sorted(missing)}" + ) + + +def test_dp_sc_116_classes_map_to_valid_canonical_types(): + """DP.SC.116 class values must be known canonical types.""" + registry = load_nudge_registry() + for class_name, canonical_type in registry.dp_sc_116_class_map.items(): + assert canonical_type in CANONICAL_TYPES, ( + f"DP.SC.116 class '{class_name}' maps to unknown canonical_type " + f"'{canonical_type}'" + ) + + +def test_get_helpers_raise_on_unknown_keys(): + registry = load_nudge_registry() + with _pytest.raises(KeyError): + get_rule_config("nonexistent_rule", registry=registry) + with _pytest.raises(KeyError): + get_nudge_key_config("nonexistent_key", registry=registry) + + +def test_dp_sc_116_helper_returns_none_for_unknown_class(): + assert get_canonical_type_for_dp_sc_116_class("unknown_class") is None + + +import pytest as _pytest diff --git a/tests/test_wp117_backfill_nudge_receipts.py b/tests/test_wp117_backfill_nudge_receipts.py new file mode 100644 index 00000000..c9d4d24d --- /dev/null +++ b/tests/test_wp117_backfill_nudge_receipts.py @@ -0,0 +1,33 @@ +from datetime import datetime, timezone + +from scripts.wp117_backfill_nudge_receipts import parse_historical_receipt + + +NOW = datetime(2026, 8, 6, tzinfo=timezone.utc) + + +def test_parses_each_once_per_recipient_family(): + for key in ( + "nudge_sessions_10", + "nudge_active_days_30", + "nudge_stage_reached_4", + ): + receipt = parse_historical_receipt( + f"notification-nudge:123:2026-07-31:{key}", NOW + ) + assert receipt is not None + assert receipt.recipient_chat_id == 123 + assert receipt.nudge_key == key + assert receipt.delivered_at == NOW + + +def test_does_not_backfill_recurring_recognition(): + assert parse_historical_receipt( + "notification-nudge:123:2026-07-31:nudge_agency_high", NOW + ) is None + + +def test_rejects_malformed_external_id(): + assert parse_historical_receipt( + "notification-nudge:not-a-chat:2026-07-31:nudge_sessions_10", NOW + ) is None