Skip to content

Commit 880324f

Browse files
wehosHongzhi Wenclaude
authored
feat(activity): 屏幕专注态搭话调度改固定间隔+后端抖动,竞技 skip 归零 (#1327)
* feat(activity): 屏幕专注态搭话调度改固定间隔+后端抖动,竞技类 skip_probability 归零 用户负面反馈:竞技对局是用户最长会话段,AI 30% 概率整轮静默打了陪伴产品命题的脸。 原 3-tier 退避会让间隔指数级增长到 60min 硬顶,叠加 skip_probability=0.3 后竞技 游戏期间几乎听不见 AI。改用: 前端:propensity=restricted_screen_only 时跳过 tier backoff,重置 level=0, 按 baseInterval 等间隔触发,不做前端抖动。模式切换由 /proactive_chat 响应的 next_schedule_fixed_mode 字段反向通知(_end_proactive 统一注入)。 后端:/proactive_chat 入口在 restricted_screen_only 时 sleep random.uniform(0, 0.5 * baseInterval)(兜底 60s 上限),把实际间隔 抹成 [base, 1.5*base] 均匀分布,均值 1.25*base。 默认:competitive 0.3 → 0(screen-only 安静感由新机制承担); immersive_horror 保留 0.3(氛围比信息密度更怕打扰)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(activity): screen-only 抖动 sleep 让位给 must-fire 提醒,注释表达收紧 Codex P2 / CodeRabbit Major:focused_work 也走 restricted_screen_only, 而 anti_slack_pending / work_break_pending 是这个状态下的 must-fire 提醒, 本身时间敏感。原实现把抖动 sleep 放在 must-fire 分支之前,会让本该 ASAP 投递的提醒被随机延后最多 60s。修法: - 标志位 _next_schedule_fixed_mode 仍然总是设置(否则 must-fire 走 _end_proactive 时响应里带回 False,前端误切回 tier backoff) - sleep 仅在 anti_slack_pending 与 work_break_pending 都为 None 时才执行 CodeRabbit Minor:activity_keywords.py 把 "base*1.25 + 后端抖动" 这种 易误读为"先乘 1.25 再叠抖动"的口语描述,改成精确的 "前端 base_interval + 后端 uniform(0, 0.5*base)",并标注实际区间 [base, 1.5*base] 与 60s 上限。 CodeRabbit Low:voice mode 故意不带 base_interval_seconds 也不读 next_schedule_fixed_mode(语音 fast path 早退、本身固定间隔无 backoff), 原代码没说清,加注释记录设计意图。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): 字段缺席时保留 fixed mode,避免短路响应踢出 fixed 调度 Codex P2 review (PR #1327): triggerProactiveChat 之前在 next_schedule_fixed_mode 缺席时硬重置成 false,但 409 try_start_proactive 冲突 / voice fast path / game-route active 这些短路响应都不走 _end_proactive,自然拿不到这个字段—— 而用户的活动状态没变,重置会让一次纯并发冲突就把客户端踢出 fixed 模式、 被 tier backoff 吞几轮。改成:只有显式收到 boolean 才同步,缺席时保留旧状态。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proactive): level 推进用本轮调度时捕获的 fixedMode,避免 fixed→tier 切换误升一级 CodeRabbit Minor (PR #1327): triggerProactiveChat() 内部会用响应里的 next_schedule_fixed_mode 同步 S.proactiveFixedScheduleMode;本轮的 level 推进 决策应该基于「这一 round 是按哪种模式调度的」而不是「返回后的最新模式」。 否则 fixed→tier 切换的那一跳会误升一级(本来按 fixed 走 delay=base、level=0, 却被升到 1),下一轮 tier 不能从干净的 base 起步。 修法:用循环顶部已捕获的局部 fixedMode,而不是被同步过的 S.proactiveFixedScheduleMode。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 21ce320 commit 880324f

7 files changed

Lines changed: 170 additions & 18 deletions

File tree

config/activity_keywords.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,15 @@ class ClassifyResult:
298298
#
299299
# Tagged games drive propensity / skip_probability / tone derivation:
300300
#
301-
# competitive → propensity=restricted_screen_only, skip 0.3,
301+
# competitive → propensity=restricted_screen_only, skip 0.0,
302302
# tone=terse (LoL team fight, CS round, etc.)
303+
# screen-only 的安静感由 /proactive_chat 的
304+
# 前端固定 base_interval + 后端 uniform(0, 0.5*base)
305+
# 抖动承担(实际间隔 [base, 1.5*base],0.5*base 上限
306+
# 兜底 60s);skip 不再叠加。
303307
# immersive horror → propensity=restricted_screen_only, skip 0.3,
304-
# tone=hushed (silent hill, RE2, etc.)
308+
# tone=hushed (silent hill, RE2, etc.) —
309+
# 氛围比信息密度更怕打扰,保留整轮 skip
305310
# immersive (other) → propensity=restricted_screen_only, skip 0.0,
306311
# tone=mellow (RPG, story-driven)
307312
# casual → propensity=open, skip 0.0, tone=playful

docs/design/user-activity-tracker.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,23 @@ Defaults are derived from `(state, intensity, genre)` in
115115

116116
| Combo | Default skip |
117117
|---|---|
118-
| `gaming + competitive` (any genre) | 0.3 |
118+
| `gaming + competitive` (any genre) | 0.0 |
119119
| `gaming + immersive + horror` | 0.3 |
120120
| `gaming + immersive` (other genre) | 0.0 |
121121
| `gaming + casual` | 0.0 |
122122
| `gaming + varied` / untagged | 0.0 |
123123
| Non-gaming states | 0.0 |
124124

125+
Note: `competitive` used to default to `0.3` but produced negative user
126+
feedback (the AI vanishing during the user's longest gaming sessions
127+
defeats the companion product thesis). The quietness for
128+
`restricted_screen_only` propensity is now handled by the
129+
fixed-interval scheduler branch in `static/app-proactive.js` plus a
130+
backend `[0, 0.5×baseInterval]` sleep in `proactive_chat` — see the
131+
`restricted_screen_only` block in `main_routers/system_router.py`. Only
132+
`immersive_horror` keeps the full-skip default (atmosphere is more
133+
sensitive to interruption than information density).
134+
125135
User overrides via `user_preferences.json`'s
126136
`__global_conversation__::activity::skip_probability_overrides` take
127137
precedence — set `1.0` for "fully silent during this combo" or `0.0`

main_logic/activity/snapshot.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,13 +433,19 @@ def derive_tone(
433433

434434
# ── Default skip_probability for gaming subtypes ───────────────────
435435
#
436-
# Source: design doc + user direction (concise: competitive, immersive
437-
# horror are the only two non-zero defaults; casual/immersive_rpg/varied
438-
# all stay at 0). User can shift via ``skip_probability_overrides``
439-
# in preferences. Keys are gaming-only; non-gaming states always have
440-
# default 0 (skip is not a propensity-strength choice for non-game).
436+
# Source: design doc + user direction. Only immersive_horror keeps a
437+
# non-zero default — atmospheric tension genuinely breaks on
438+
# interruption, so a probabilistic full-skip is worth it. Competitive
439+
# games used to have 0.3 here too but produced negative feedback (the
440+
# AI vanishing during the user's longest gaming sessions defeats the
441+
# companion product thesis); the quietness for restricted_screen_only
442+
# gaming now comes from the frontend-scheduler / backend-jitter path
443+
# instead (see proactive_chat in main_routers/system_router.py).
444+
# Casual / immersive_rpg / varied / competitive all stay at 0. User
445+
# can shift via ``skip_probability_overrides`` in preferences. Keys
446+
# are gaming-only; non-gaming states always have default 0 (skip is
447+
# not a propensity-strength choice for non-game).
441448
_DEFAULT_GAMING_SKIP_PROB: dict[tuple[GameIntensity, str | None], float] = {
442-
('competitive', None): 0.3, # Any competitive game (genre doesn't refine further)
443449
('immersive', 'horror'): 0.3, # Atmospheric tension — interruption breaks immersion
444450
('immersive', None): 0.0, # RPG / story — propensity already restricts to screen
445451
('casual', None): 0.0, # Pause-anytime

main_routers/system_router.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import hashlib
2323
import hmac
2424
import ipaddress
25+
import json
2526
import math
2627
import random
2728
import re
@@ -4245,17 +4246,38 @@ async def proactive_chat(request: Request):
42454246
"state": mgr.state.snapshot(),
42464247
}, status_code=409)
42474248
_proactive_done_emitted = False
4249+
# Set after activity snapshot fetch — tells the frontend scheduler
4250+
# to skip the regular tier backoff and use a flat baseInterval on
4251+
# the next round (the backend will then inject a uniform
4252+
# [0, 0.5*baseInterval] sleep to provide the jitter). See the
4253+
# screen-only delay block further down and the matching
4254+
# ``S.proactiveFixedScheduleMode`` branch in static/app-proactive.js.
4255+
_next_schedule_fixed_mode = False
42484256

42494257
async def _end_proactive(resp: JSONResponse) -> JSONResponse:
4250-
"""包装所有 proactive 正常/短路退出:幂等地 fire PROACTIVE_DONE。"""
4258+
"""包装所有 proactive 正常/短路退出:幂等地 fire PROACTIVE_DONE。
4259+
4260+
同时把 ``next_schedule_fixed_mode`` 注入响应体,前端读取后
4261+
决定下一轮调度走 tier backoff 还是固定 base interval。注入
4262+
发生在统一出口,新增的响应路径无需逐个修改。
4263+
"""
42514264
nonlocal _proactive_done_emitted
42524265
if not _proactive_done_emitted:
42534266
_proactive_done_emitted = True
42544267
try:
42554268
await mgr.state.fire(_SE.PROACTIVE_DONE)
42564269
except Exception as _done_err:
42574270
logger.warning("[%s] PROACTIVE_DONE fire 异常: %s", lanlan_name, _done_err)
4258-
return resp
4271+
try:
4272+
body = json.loads(resp.body)
4273+
except Exception:
4274+
return resp
4275+
if not isinstance(body, dict):
4276+
return resp
4277+
if 'next_schedule_fixed_mode' in body:
4278+
return resp
4279+
body['next_schedule_fixed_mode'] = _next_schedule_fixed_mode
4280+
return JSONResponse(body, status_code=resp.status_code)
42594281

42604282
def _proactive_preempted_json(where: str) -> dict:
42614283
logger.info(
@@ -4371,6 +4393,50 @@ def _proactive_preempted_json(where: str) -> dict:
43714393
"message": f"user state={activity_snapshot.state} → closed (privacy lockdown)",
43724394
}))
43734395

4396+
# ========== Screen-only:固定间隔 + 后端抖动 ==========
4397+
# 用户处于 gaming / focused_work(propensity=restricted_screen_only)
4398+
# 时,常规的前端 3-tier 退避会让搭话间隔指数级增长,跟陪伴产品
4399+
# 命题冲突(用户最长会话段反而最安静)。改用:
4400+
# 1. 前端 reset backoffLevel=0 并按 baseInterval 等间隔触发
4401+
# (由响应里的 next_schedule_fixed_mode=True 通知前端切换)
4402+
# 2. 后端在 LLM 调用前 sleep uniform(0, 0.5 * baseInterval),把每轮
4403+
# 实际间隔从 base 抹成 [base, 1.5*base] 的均匀分布
4404+
# 总效果:屏幕态平均间隔 ≈ 1.25*base,且有自然的随机抖动。
4405+
# skip_probability(仅 immersive_horror=0.3)作为正交机制保留。
4406+
#
4407+
# ⚠️ 标志位 vs sleep 拆开:anti_slack_pending / work_break_pending
4408+
# 是 focused_work 下的 must-fire 提醒(紧跟在下一段 4425+),本身
4409+
# 时间敏感,不能被这里的随机抖动延后。但前端 fixed_mode 标志位
4410+
# 仍然要设——否则 must-fire 走 _end_proactive 时响应里会带回
4411+
# next_schedule_fixed_mode=False,前端误切回 tier backoff,让用户
4412+
# 离开 must-fire 状态后又被退避机制吞掉一段时间。
4413+
# Codex P2 + CodeRabbit Major review。
4414+
if (
4415+
activity_snapshot is not None
4416+
and activity_snapshot.propensity == 'restricted_screen_only'
4417+
):
4418+
_next_schedule_fixed_mode = True
4419+
_has_must_fire = (
4420+
activity_snapshot.anti_slack_pending is not None
4421+
or activity_snapshot.work_break_pending is not None
4422+
)
4423+
if _has_must_fire:
4424+
print(f"[{lanlan_name}] propensity=restricted_screen_only 但有 must-fire 提醒待发,跳过本轮抖动 sleep")
4425+
else:
4426+
try:
4427+
_base_interval_raw = data.get('base_interval_seconds')
4428+
_base_interval = float(_base_interval_raw) if _base_interval_raw is not None else 0.0
4429+
except (TypeError, ValueError):
4430+
_base_interval = 0.0
4431+
# 上限兜底:base 过大时把 0.5*base 截到 60s,避免极端配置
4432+
# (比如 user 把 proactiveChatInterval 调到 300s)让后端
4433+
# 单请求占连接十分钟。
4434+
if _base_interval > 0:
4435+
_jitter_max = min(_base_interval * 0.5, 60.0)
4436+
_jitter = random.uniform(0.0, _jitter_max)
4437+
print(f"[{lanlan_name}] propensity=restricted_screen_only, 后端注入 {_jitter:.2f}s 间隔抖动(base={_base_interval:.1f}s)")
4438+
await asyncio.sleep(_jitter)
4439+
43744440
# ========== Must-fire: break-reminder branches ==========
43754441
# Anti-slack outranks water-break (transition trigger more
43764442
# time-sensitive than the cumulative one). Both bypass Phase 1

static/app-proactive.js

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,14 @@
543543
//
544544
// 单调性: 固定 M1 使 delay(T) ≈ base + T×(M1-1),
545545
// ∂delay/∂base = 1 > 0,base 越高期望 delay 越高。
546+
//
547+
// ── 固定间隔分支 (proactiveFixedScheduleMode) ──
548+
// 当后端 propensity=restricted_screen_only(屏幕专注态:gaming /
549+
// focused_work)时,常规退避会让搭话间隔指数级增长,跟陪伴产品
550+
// 命题冲突。前端跳过 tier backoff,按 baseInterval 等间隔触发,
551+
// 后端在 /proactive_chat 入口注入 [0, 0.5×base] sleep 把实际间隔
552+
// 抹成 [base, 1.5×base] 均匀分布。详见 main_routers/system_router.py
553+
// 的 restricted_screen_only 处理段。
546554

547555
var baseInterval = S.proactiveChatInterval;
548556
var BACKOFF_TARGET = 120;
@@ -562,10 +570,18 @@
562570
var cap1 = caps.cap1;
563571
var cap2 = caps.cap2;
564572

573+
var fixedMode = !!S.proactiveFixedScheduleMode;
565574
var level = S.proactiveChatBackoffLevel;
566575
var delay;
567576

568-
if (level < cap1) {
577+
if (fixedMode) {
578+
// 屏幕专注态:跳过 tier backoff,重置 level,按 baseInterval 等间隔
579+
// 触发。抖动完全交给后端([0, 0.5×base] sleep),前端不做乘性抖动,
580+
// 否则两层叠加会让方差大于设计目标。
581+
S.proactiveChatBackoffLevel = 0;
582+
level = 0;
583+
delay = baseInterval * 1000;
584+
} else if (level < cap1) {
569585
// Tier 1: base × M1^level,确定性爬升
570586
delay = (baseInterval * 1000) * Math.pow(BACKOFF_M1, level);
571587
} else {
@@ -576,7 +592,10 @@
576592
}
577593

578594
// 对 delay 做 ±12% 乘性随机抖动,避免节奏过于机械
579-
delay *= 1 + (Math.random() - 0.5) * 0.24;
595+
// 固定模式下抖动由后端注入,前端不再叠加
596+
if (!fixedMode) {
597+
delay *= 1 + (Math.random() - 0.5) * 0.24;
598+
}
580599

581600
// 首次启动时额外等待 6 秒,避免程序刚启动就触发音乐推荐。
582601
// 用一次性 flag 而非 backoffLevel === 0 —— 后者在 user_input reset 或
@@ -589,12 +608,18 @@
589608
}
590609
delay += startupDelay;
591610

611+
// 输入放缓 floor 跟 fixed/tier 模式正交:用户在打字时不该被主动搭话打断,
612+
// 不管处于屏幕专注态还是常规态。两边都套这个下限。
592613
var inputSlowdownDelay = _getChatInputSlowdownDelay(baseInterval);
593614
if (inputSlowdownDelay > 0) {
594615
delay = Math.max(delay, inputSlowdownDelay);
595616
}
596617

597-
console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(基础间隔:' + baseInterval + '秒,退避级别:' + level + ',cap1:' + cap1 + ',cap2:' + cap2 + ',启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')');
618+
if (fixedMode) {
619+
console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(屏幕专注态固定间隔,base=' + baseInterval + 's,level 已重置,后端注入抖动,启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')');
620+
} else {
621+
console.log('主动搭话:' + (delay / 1000).toFixed(1) + '秒后触发(基础间隔:' + baseInterval + '秒,退避级别:' + level + ',cap1:' + cap1 + ',cap2:' + cap2 + ',启动延迟:' + (startupDelay / 1000) + '秒,输入放缓:' + (inputSlowdownDelay ? ((inputSlowdownDelay / 1000) + '秒') : '无') + ')');
622+
}
598623

599624
S.proactiveChatTimer = setTimeout(async function () {
600625
// 双重检查锁:定时器触发时再次检查是否正在执行
@@ -628,7 +653,16 @@
628653
// tier 1 (level < cap1): 每次必升 — 确定性爬升阶段
629654
// tier 2 (cap1 ≤ level < cap2): 9% 概率升级 — 慢区,长时间停留
630655
// tier 3 (level ≥ cap2): 每次必升 — 快区,快速逼近 60min 硬顶
631-
if (triggered) {
656+
// 屏幕专注态固定模式下不动 level(由 next_schedule_fixed_mode
657+
// 反向通知后续 reset),让用户离开屏幕态回到常规态时 backoff
658+
// 不会带着旧值。
659+
//
660+
// ⚠️ 用本轮调度时捕获的 ``fixedMode`` 而非已被响应同步过的
661+
// ``S.proactiveFixedScheduleMode``:本轮的 level 推进决策应该基于
662+
// 「这一 round 是按哪种模式调度的」而不是「返回后的最新模式」。
663+
// 否则 fixed → tier 切换的那一跳会误升一级,下一轮 tier 不能从
664+
// 干净的 base 起步。CodeRabbit Minor review: PR #1327。
665+
if (triggered && !fixedMode) {
632666
var currentCaps = computeBackoffCaps(S.proactiveChatInterval);
633667
var currentCap1 = currentCaps.cap1;
634668
var currentCap2 = currentCaps.cap2;
@@ -714,6 +748,11 @@
714748
voiceModes.push('vision');
715749
}
716750
console.log('[ProactiveChat] 语音模式快速路径,modes: [' + voiceModes.join(', ') + ']');
751+
// 故意不带 base_interval_seconds / 不读 next_schedule_fixed_mode:
752+
// 语音模式在后端走 voice fast path(system_router.py 4222 行附近),
753+
// 在 propensity / restricted_screen_only / 抖动 sleep 这一整套门
754+
// 之前就早退;语音 scheduler 自己也是固定 baseInterval 不带 backoff。
755+
// 既然两边都不读,发了也是冗余字段。
717756
var resp = await fetch('/api/proactive_chat', {
718757
method: 'POST',
719758
headers: { 'Content-Type': 'application/json' },
@@ -823,7 +862,11 @@
823862
// mini-game 邀请的用户级 toggle;后端 _maybe_deliver_mini_game_invite
824863
// 与 source-driven sources 解耦,不进 enabled_modes 数组。
825864
mini_game_invite_enabled: !!S.proactiveMiniGameInviteEnabled,
826-
i18n_language: i18nLanguage
865+
i18n_language: i18nLanguage,
866+
// 屏幕专注态后端会按 [0, 0.5×base] 注入间隔抖动,需要知道
867+
// 当前用户配置的 baseInterval。后端 propensity 非屏幕专注态
868+
// 时忽略此字段。
869+
base_interval_seconds: S.proactiveChatInterval
827870
};
828871

829872
// 独立计时器:确保 vision/window 模式的屏幕感知间隔不低于 proactiveVisionInterval
@@ -984,6 +1027,20 @@
9841027

9851028
var result = await response.json();
9861029

1030+
// 同步下一轮调度模式:后端在 propensity=restricted_screen_only
1031+
// 时会把这个字段置 true,前端 scheduleProactiveChat 据此跳过
1032+
// tier backoff、按 baseInterval 等间隔触发。
1033+
//
1034+
// 字段缺席 ≠ 模式应该回退。短路响应路径(409 try_start_proactive
1035+
// 冲突、voice fast path、game-route active 早退)都不走 _end_proactive,
1036+
// 也就拿不到这个字段——但用户的活动状态没变,把模式硬重置成 false
1037+
// 会让一次并发冲突就把客户端踢出 fixed 模式、被 tier backoff 吞几轮。
1038+
// 改为:只有显式收到 boolean 才同步,缺席时保留旧状态。
1039+
// Codex P2 review: PR #1327。
1040+
if (typeof result.next_schedule_fixed_mode === 'boolean') {
1041+
S.proactiveFixedScheduleMode = result.next_schedule_fixed_mode;
1042+
}
1043+
9871044
if (result.success) {
9881045
if (result.action === 'chat') {
9891046
console.log('主动搭话已发送:', result.message, result.source_mode ? '(来源: ' + result.source_mode + ')' : '');

static/app-state.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@
140140
mergeMessagesEnabled: false,
141141
proactiveChatTimer: null,
142142
proactiveChatBackoffLevel: 0,
143+
// 屏幕专注态(gaming / focused_work,后端 propensity=restricted_screen_only)
144+
// 切到「固定间隔 + 后端抖动」调度:跳过 3-tier 退避,按 baseInterval
145+
// 等间隔触发,后端 /proactive_chat 入口注入 [0, 0.5×base] 的 sleep
146+
// 把实际间隔抹成 [base, 1.5×base] 均匀分布。由 /proactive_chat 响应里的
147+
// next_schedule_fixed_mode 字段控制开关;默认 false(即走常规退避)。
148+
proactiveFixedScheduleMode: false,
143149
_voiceProactiveNoResponseCount: 0,
144150
_voiceSessionInitialTimer: null,
145151
isProactiveChatRunning: false,

tests/test_activity_tracker_followup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,8 +450,10 @@ def test_skip_probability_defaults():
450450
assert derive_skip_probability('chatting') == 0.0
451451
assert derive_skip_probability('idle') == 0.0
452452

453-
# Gaming defaults
454-
assert derive_skip_probability('gaming', game_intensity='competitive') == pytest.approx(0.3)
453+
# Gaming defaults — competitive intentionally dropped to 0 (was 0.3).
454+
# 屏幕专注态的安静感现在由 /proactive_chat 的 base-interval×1.25 + 后端
455+
# 抖动机制承担,skip_probability 只留 immersive_horror 这一例外。
456+
assert derive_skip_probability('gaming', game_intensity='competitive') == 0.0
455457
assert derive_skip_probability(
456458
'gaming', game_intensity='immersive', game_genre='horror',
457459
) == pytest.approx(0.3)

0 commit comments

Comments
 (0)