58d274096d
评审修正(代码质量评审 Critical#1 + Important#2/#3): - reply_msg_id 进复合 PK(模型+migration b7c8d9e0f1a2 原地改,本地 down/up 重建,表空未部署):同毫秒 F,T,F,T 交换的两个样本 (thread_end_ts, reply_ts) 完全并列不再撞主键(原会中断整个 recompute_all 事务);兼作下钻证据定位回复消息。 - load_conversation 增查 msg_id 并按 (ts, msg_id) 排序, compute_conversation 协议改 3 元组——SQL 等键顺序未定义,原实现 同毫秒 [F,T] 归属随查询抖动(0/1 answered 摇摆)。 - recompute_pairs 每 500 会话显式 flush+进度日志(内存上界对齐 summary.recompute_all 惯例,不再依赖隐式 autoflush)。 - 测试:旧 9 个更新协议;新增同毫秒 F,T,F,T 回归/并列确定性/ 空会话幂等清空,fr_sum 总量断言并入双串测试; test_management_models 构造补新主键列。 Co-Authored-By: Claude Code <noreply@anthropic.com>
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
"""first_reply_fact 跨日精确串算法判别测试(批次A Task3)。
|
|
|
|
四个核心判据(v2 §1.1 #1/#5、§9):
|
|
1. 跨夜串必须产生样本(日切口径丢失的场景);
|
|
2. 多日未回串只计一次、归属末条日(日切会按天重复计——分母膨胀);
|
|
3. 同源:threads_answered ≡ fact 行数(按归属日);
|
|
4. is_send=None 不参与串判定(与 _thread_stats 一致)。
|
|
|
|
评审修正补判据:
|
|
5. 同毫秒 F,T,F,T 交换 → reply_msg_id 进 PK 去重,不撞主键(原崩溃回归);
|
|
6. 同毫秒并列按 (ts, msg_id) 定序,结果确定不随 SQL 等键序抖动。
|
|
"""
|
|
import datetime as dt
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from app.db.models import FirstReplyFact, Message, MsgDailyStats
|
|
from app.storage.first_reply_facts import (
|
|
all_friend_pairs,
|
|
compute_conversation,
|
|
recompute_pairs,
|
|
)
|
|
|
|
_SH = ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
def _ts(*args) -> int:
|
|
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
|
|
|
|
|
|
# ---------------------------- 纯函数 ----------------------------
|
|
|
|
def test_overnight_thread_recovered():
|
|
"""客户 23:00 发→员工次日 9:00 回:fr_ms≈10h,归属 D1(v2 §1.1 #1)。"""
|
|
facts, agg = compute_conversation(
|
|
[(_ts(2026, 9, 1, 23, 0), False, "f1"),
|
|
(_ts(2026, 9, 2, 9, 0), True, "t1")]
|
|
)
|
|
assert len(facts) == 1
|
|
assert facts[0].fr_ms == 10 * 3600 * 1000
|
|
assert facts[0].day == 20260901
|
|
assert agg[20260901].answered == 1
|
|
|
|
|
|
def test_multiday_open_thread_counts_once_at_end_day():
|
|
"""客户消息跨 D1/D2 无回复:1 个串归属 D2;日切口径会 D1、D2 各计 1。"""
|
|
facts, agg = compute_conversation(
|
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 20, 0), False, "f2"),
|
|
(_ts(2026, 9, 2, 9, 0), False, "f3")]
|
|
)
|
|
assert facts == []
|
|
assert 20260901 not in agg
|
|
assert agg[20260902].threads == 1 and agg[20260902].answered == 0
|
|
|
|
|
|
def test_reply_closes_thread_and_opens_new():
|
|
"""员工回复后客户再发=新串;串内多条客户消息取末条为 end。"""
|
|
facts, agg = compute_conversation(
|
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 5), False, "f2"),
|
|
(_ts(2026, 9, 1, 10, 30), True, "t1"), # 回复1:fr=30-10:05=25min
|
|
(_ts(2026, 9, 1, 11, 0), False, "f3"),
|
|
(_ts(2026, 9, 1, 12, 0), True, "t2")]) # 回复2:fr=12:00-11:00=60min
|
|
assert len(facts) == 2
|
|
assert facts[0].fr_ms == 25 * 60 * 1000
|
|
assert agg[20260901].threads == 2 and agg[20260901].answered == 2
|
|
assert agg[20260901].fr_sum_ms == 85 * 60 * 1000 # 25min + 60min
|
|
|
|
|
|
def test_unknown_direction_ignored():
|
|
facts, _agg = compute_conversation(
|
|
[(_ts(2026, 9, 1, 10, 0), False, "f1"), (_ts(2026, 9, 1, 10, 10), None, "u1"),
|
|
(_ts(2026, 9, 1, 10, 20), True, "t1")]
|
|
)
|
|
assert len(facts) == 1 # None 不打断串:仍视为对 10:00 串的回复
|
|
|
|
|
|
def test_staff_message_outside_thread_is_noop():
|
|
facts, agg = compute_conversation(
|
|
[(_ts(2026, 9, 1, 9, 0), True, "t1"), (_ts(2026, 9, 1, 10, 0), True, "t2")]
|
|
)
|
|
assert facts == [] and agg == {}
|
|
|
|
|
|
def test_unsorted_rows_raise_or_documented():
|
|
"""调用方契约:rows 须升序。乱序输入行为未定义——文档断言 + 防御排序
|
|
在 load_conversation 里做(与 summary.py 惯例一致)。"""
|
|
# 纯函数不做防御(性能);落库路径负责排序。此处仅锁定契约注释。
|
|
import app.storage.first_reply_facts as m
|
|
assert "升序" in m.compute_conversation.__doc__
|
|
|
|
|
|
def test_tie_break_by_msg_id_deterministic():
|
|
"""同毫秒 [F(m1), T(m2)]:按 (ts, msg_id) 定序 F 在前 → 1 个被回串;
|
|
同一 multiset 无论入参顺序,排序后结果一致(SQL 等键序未定义,不能
|
|
随查询抖动)。"""
|
|
same = _ts(2026, 9, 1, 10, 0)
|
|
rows = [(same, False, "m1"), (same, True, "m2")]
|
|
ordered = sorted(rows, key=lambda r: (r[0], r[2]))
|
|
assert ordered[0][2] == "m1" # F(m1) 先于 T(m2)
|
|
facts, agg = compute_conversation(ordered)
|
|
assert len(facts) == 1 and facts[0].reply_msg_id == "m2"
|
|
assert agg[20260901].answered == 1
|
|
shuffled = [rows[1], rows[0]] # 同一 multiset,入参顺序相反
|
|
facts2, agg2 = compute_conversation(
|
|
sorted(shuffled, key=lambda r: (r[0], r[2])))
|
|
assert facts2 == facts and agg2 == agg
|
|
|
|
|
|
# ---------------------------- 落库 ----------------------------
|
|
|
|
async def _seed_conversation(session, *, channel="wechat", owner="wx1",
|
|
peer="p1", rows):
|
|
for ts, is_send, msg_id in rows:
|
|
session.add(Message(msg_id=msg_id, channel=channel,
|
|
owner_account_id=owner, talker_id=peer,
|
|
timestamp=ts, is_send=is_send, type="text",
|
|
text="x"))
|
|
session.add(MsgDailyStats(channel=channel, owner_account_id=owner,
|
|
peer_kind="friend", peer_id=peer,
|
|
day=20260901, send_cnt=1, recv_cnt=1))
|
|
await session.commit()
|
|
|
|
|
|
async def test_recompute_pairs_persists_and_idempotent(make_session):
|
|
s = make_session
|
|
await _seed_conversation(
|
|
s, rows=[(_ts(2026, 9, 1, 23, 0), False, "p1-f0"),
|
|
(_ts(2026, 9, 2, 9, 0), True, "p1-t1")])
|
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
assert n == 1
|
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
|
assert len(rows) == 1 and rows[0].fr_ms == 10 * 3600 * 1000
|
|
assert rows[0].reply_msg_id == "p1-t1"
|
|
# 幂等:重跑不重复
|
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
assert len((await s.execute(sa.select(FirstReplyFact))).scalars().all()) == 1
|
|
|
|
|
|
async def test_same_ms_ftft_two_distinct_facts(make_session):
|
|
"""同毫秒 F,T,F,T 交换:两个样本 (thread_end_ts, reply_ts) 完全并列
|
|
——reply_msg_id 进 PK 去重后恰好 2 行(评审复现的主键冲突回归)。"""
|
|
s = make_session
|
|
same = _ts(2026, 9, 1, 10, 0)
|
|
await _seed_conversation(s, rows=[
|
|
(same, False, "m1"), (same, True, "m2"),
|
|
(same, False, "m3"), (same, True, "m4"),
|
|
])
|
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
assert n == 1
|
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
|
assert len(rows) == 2
|
|
assert {r.reply_msg_id for r in rows} == {"m2", "m4"}
|
|
|
|
|
|
async def test_recompute_pairs_replaces_stale(make_session):
|
|
"""会话追加消息后重算,旧样本被替换(delete+insert 语义)。"""
|
|
s = make_session
|
|
await _seed_conversation(
|
|
s, rows=[(_ts(2026, 9, 1, 10, 0), False, "p1-f0"),
|
|
(_ts(2026, 9, 1, 11, 0), True, "p1-t1")])
|
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
s.add(Message(msg_id="p1-new", channel="wechat", owner_account_id="wx1",
|
|
talker_id="p1", timestamp=_ts(2026, 9, 1, 12, 0),
|
|
is_send=False, type="text", text="y"))
|
|
await s.commit()
|
|
await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
rows = (await s.execute(sa.select(FirstReplyFact))).scalars().all()
|
|
assert len(rows) == 1 # 新串未回,只剩旧样本
|
|
|
|
|
|
async def test_recompute_pairs_empty_conversation(make_session):
|
|
"""stats 行在但 message 无合格行(空会话/全被过滤):删旧行不插新——
|
|
delete+insert 语义对空会话同样成立(幂等清空)。"""
|
|
s = make_session
|
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="p1", day=20260901))
|
|
# 预置历史残留 fact,重算后应被清掉
|
|
s.add(FirstReplyFact(channel="wechat", owner_account_id="wx1", peer_id="p1",
|
|
thread_end_ts=_ts(2026, 9, 1, 10, 0),
|
|
reply_ts=_ts(2026, 9, 1, 11, 0), reply_msg_id="stale",
|
|
day=20260901, fr_ms=3600 * 1000))
|
|
await s.commit()
|
|
n = await recompute_pairs(s, [("wechat", "wx1", "p1")])
|
|
await s.commit()
|
|
assert n == 1
|
|
assert (await s.execute(sa.select(FirstReplyFact))).scalars().all() == []
|
|
|
|
|
|
async def test_all_friend_pairs_only_friend(make_session):
|
|
s = make_session
|
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="room", peer_id="r1", day=20260901))
|
|
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
|
|
peer_kind="friend", peer_id="p1", day=20260901))
|
|
await s.commit()
|
|
assert await all_friend_pairs(s) == [("wechat", "wx1", "p1")]
|