feat(analytics): 首响时长+回复率数据管道——消息串算法/日汇总扩展列/员工聚合

批1(A1+A2):管理层要看「每个人的响应质量」。

- 迁移 d9e0f1a2b3c4:msg_daily_stats/stat_daily_staff 各加
  cust_threads/threads_answered/fr_sum_ms(NOT NULL server_default 0,
  幂等守卫照 9b0d0c133035 惯例;models.py 双声明同步)
- summary.py _thread_stats:串=客户连续消息不被员工消息打断的段,
  is_send=None 不参与串判定;fr=员工首条回复−串末条客户消息;
  日切近似 v1(日末未闭合串计 threads 不计 answered,口径写进 docstring)
- recompute_days/recompute_all 都先按 ts 排序再算串(乱序防护)
- staff_summary 聚合三列按账号→员工累加(fr_cnt≡threads_answered 不单列)
- 测试 +10:串算法矩阵 9 例+staff 聚合传递;全量 589 绿
- 本地 86 万行回填验证:daily-stats 60s/staff 10s,rollup 总和与
  明细完全一致,随机 5 行 SQL 独立复算全对

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 14:13:09 +08:00
parent 39791b2a49
commit feb3b98665
6 changed files with 359 additions and 5 deletions
+153 -1
View File
@@ -1,6 +1,6 @@
"""Tests for the msg_daily_stats 汇总层(日粒度会话统计)。
三个被钉死的行为:
四个被钉死的行为:
1. **日桶口径**:day 一律 Asia/Shanghai 自然日(Python 计算,不用 SQL 日期
函数——旧实现 from_unixtime 跟随 MySQL 会话时区,UTC 容器下和今日数
@@ -10,6 +10,8 @@
入口,一处挂全。幂等:同一批重放不产生重复/漂移。
3. **全量重算**::func:`app.storage.summary.recompute_all` 从 message 表
重建全部日行(夜间自愈/部署回填用),幂等。
4. **消息串口径**(2026-09-07 批1 首响/回复率):cust_threads/
threads_answered/fr_sum_ms 三列的串算法矩阵——见下方测试组。
fixtures 用内存 SQLite(测试对 message 的所有写入都走 upsert_messages,
从而同时覆盖钩子行为)。
@@ -183,3 +185,153 @@ async def test_recompute_all_rebuilds_and_is_idempotent(session):
await recompute_all(session) # 幂等(重建后是全新 ORM 对象,比字段值不比对象)
assert await _stats_rows(session) == rows
# ── 消息串口径:首响/回复率三列(2026-09-07 批1)────────────────────────── #
#
# 口径(定案):串=客户连续消息(is_send=False)不被员工消息(is_send=True)
# 打断的段;is_send=None 不参与串判定(跳过,不打断也不起串)。每个被回串
# 一个样本 fr_ms=员工首条回复 ts−串末条客户消息 ts。日切近似:日末未闭合
# 串计 threads 不计 answered,跨夜的串在次日段重新起算。
async def _thread_rows(session):
rows = (await session.execute(select(MsgDailyStats))).scalars().all()
return {
(r.peer_kind, r.peer_id, r.day): (r.cust_threads, r.threads_answered, r.fr_sum_ms)
for r in rows
}
async def _seed_thread(session, msgs) -> dict:
"""写一批消息(走钩子→recompute_days),返回三列断言字典。"""
await upsert_messages(session, msgs)
await session.commit()
return await _thread_rows(session)
async def test_thread_single_answered(session):
"""单串被回:fr=回复−串首条客户消息(此处串只有一条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
# (cust_threads, threads_answered, fr_sum_ms)
assert rows[("friend", "t1", 20260903)] == (1, 1, 5 * 60 * 1000)
async def test_thread_fr_takes_last_customer_msg(session):
"""多条客户消息串内:fr 取「末条」客户消息(非首条)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("c3", _sh(2026, 9, 3, 10, 7), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 9)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 2 * 60 * 1000) # 10:09−10:07
async def test_two_threads_only_one_answered(session):
"""两串只回一串:threads=2/answered=1,fr 只含被回串。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False), # 新串,日末未闭合
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_thread_not_broken_by_unknown(session):
"""unknown(is_send=None)消息夹在串中:不打断串,fr 仍取末条客户消息。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 1, 3 * 60 * 1000) # 10:05−10:02
async def test_thread_open_at_day_end_counts_unanswered(session):
"""日末未闭合串:计 threads 不计 answered(日切近似,fr 不含它)。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c2", _sh(2026, 9, 3, 23, 50), is_send=False), # 日末未回
],
)
assert rows[("friend", "t1", 20260903)] == (2, 1, 5 * 60 * 1000)
async def test_staff_msg_first_is_not_a_thread(session):
"""员工先发不算串:开场白式员工消息不产生 threads/answered。"""
rows = await _seed_thread(
session,
[
_msg("s0", _sh(2026, 9, 3, 9, 0)),
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
async def test_thread_closed_then_reopened_and_multi_fr(session):
"""闭合后再起串+多样本:fr_sum 累加各被回串的样本时长。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)), # 串1被回,fr=5min
_msg("s2", _sh(2026, 9, 3, 10, 8)), # 串已闭合,追加员工消息无效
_msg("c2", _sh(2026, 9, 3, 11, 0), is_send=False),
_msg("c3", _sh(2026, 9, 3, 11, 4), is_send=False),
_msg("s3", _sh(2026, 9, 3, 11, 10)), # 串2被回,fr=6min(11:10−11:04)
],
)
assert rows[("friend", "t1", 20260903)] == (2, 2, 11 * 60 * 1000)
async def test_thread_cross_day_restarts_next_day(session):
"""日切近似:23:50 的串在当日计 open;次日段客户续发+被回在次日重新起算。"""
rows = await _seed_thread(
session,
[
_msg("c1", _sh(2026, 9, 3, 23, 50), is_send=False), # 当日未回
_msg("c2", _sh(2026, 9, 4, 9, 0), is_send=False), # 次日新串(同串跨夜近似)
_msg("s1", _sh(2026, 9, 4, 9, 3)),
],
)
assert rows[("friend", "t1", 20260903)] == (1, 0, 0)
assert rows[("friend", "t1", 20260904)] == (1, 1, 3 * 60 * 1000) # 9:03−9:00
async def test_recompute_all_carries_thread_stats(session):
"""全量重算路径(逐会话全史分桶)同样计算三值,且与钩子路径一致。"""
msgs = [
_msg("c1", _sh(2026, 9, 3, 10, 0), is_send=False),
_msg("u1", _sh(2026, 9, 3, 10, 1), is_send=None),
_msg("c2", _sh(2026, 9, 3, 10, 2), is_send=False),
_msg("s1", _sh(2026, 9, 3, 10, 5)),
_msg("c3", _sh(2026, 9, 3, 11, 0), is_send=False),
]
await upsert_messages(session, msgs)
await session.commit()
hooked = await _thread_rows(session)
await recompute_all(session)
assert await _thread_rows(session) == hooked
assert hooked[("friend", "t1", 20260903)] == (2, 1, 3 * 60 * 1000)