Files
yunke/yunke-server/tests/test_first_reply_facts.py
T

142 lines
5.6 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 一致)。
"""
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), (_ts(2026, 9, 2, 9, 0), True)]
)
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), (_ts(2026, 9, 1, 20, 0), False),
(_ts(2026, 9, 2, 9, 0), False)]
)
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), (_ts(2026, 9, 1, 10, 5), False),
(_ts(2026, 9, 1, 10, 30), True), # 回复1:fr=30-10:05=25min
(_ts(2026, 9, 1, 11, 0), False), (_ts(2026, 9, 1, 12, 0), True)]
)
assert len(facts) == 2
assert facts[0].fr_ms == 25 * 60 * 1000
assert agg[20260901].threads == 2 and agg[20260901].answered == 2
def test_unknown_direction_ignored():
facts, _agg = compute_conversation(
[(_ts(2026, 9, 1, 10, 0), False), (_ts(2026, 9, 1, 10, 10), None),
(_ts(2026, 9, 1, 10, 20), True)]
)
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), (_ts(2026, 9, 1, 10, 0), True)]
)
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__
# ---------------------------- 落库 ----------------------------
async def _seed_conversation(session, *, channel="wechat", owner="wx1",
peer="p1", rows):
for i, (ts, is_send) in enumerate(rows):
session.add(Message(msg_id=f"{peer}-{i}", 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), (_ts(2026, 9, 2, 9, 0), True)])
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
# 幂等:重跑不重复
await recompute_pairs(s, [("wechat", "wx1", "p1")])
await s.commit()
assert len((await s.execute(sa.select(FirstReplyFact))).scalars().all()) == 1
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), (_ts(2026, 9, 1, 11, 0), True)])
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_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")]