Files
yunke/yunke-server/app/storage/first_reply_facts.py
T
浅念 58d274096d fix(management): first_reply_fact 同毫秒并列主键冲突+定序抖动——reply_msg_id 进 PK/按(ts,msg_id)排序
评审修正(代码质量评审 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>
2026-09-09 13:18:08 +08:00

196 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""first_reply_fact 维护层(管理域,批次A)——跨日精确串算法。
区别于 app.storage.summary._thread_stats 的日切近似(v2 §1.1 #1):
按会话**全历史**计算,跨夜串(客户 23:00 → 员工次日 9:00 回)不再丢失。
**不挂写钩子**(v2 §3.1):由回填 CLI / 4h 滚动 / 夜间全量重算,写钩子
零改动。只处理 1:1(friend)会话——群串语义不成立。
compute_conversation 同时输出每日串聚合(threads/answered/fr_sum_ms,
按串末条归属日)——employee_metric_daily 的串类列与 fact 行**同源**
(回复率分子分母一致,v2 §1.1 #5)。
时序确定性:同毫秒并列(ts 相同的 F/T 消息)按 msg_id 定序(wechat
msgSvrId 服务端单调,近似真实到达序)——SQL 等键返回顺序未定义,不做
定序会导致串归属随查询抖动;fact PK 含 reply_msg_id,同毫秒 F,T,F,T
交换的两个样本不撞主键。
"""
from __future__ import annotations
import logging
from collections import namedtuple
from collections.abc import Iterable
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import FirstReplyFact, Message, MsgDailyStats
from app.storage.summary import day_key
logger = logging.getLogger(__name__)
# 一个被回串的首响样本;day = 串末条客户消息所在上海自然日,
# reply_msg_id = 闭合该串的员工回复消息 msg_id(PK 并列去重 + 下钻证据)
FactSample = namedtuple("FactSample", "thread_end_ts reply_ts fr_ms day reply_msg_id")
# 某会话某日的串聚合(分子分母同源)
DayAgg = namedtuple("DayAgg", "threads answered fr_sum_ms")
class _DayAcc:
__slots__ = ("answered", "fr_sum_ms", "threads")
def __init__(self) -> None:
self.threads = 0
self.answered = 0
self.fr_sum_ms = 0
def compute_conversation(rows: list) -> tuple[list[FactSample], dict[int, DayAgg]]:
"""跨日精确串算法。rows = [(ts, is_send, msg_id)] 该会话全历史,
**须按 (ts, msg_id) 升序**(load_conversation 负责排序;同毫秒并列以
msg_id 定序,见模块 docstring)。
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断的
段;is_send=None 跳过(不打断也不起串)——与 _thread_stats 判定一致。
- 每个被回串一个样本:fr_ms = 员工首条回复 ts − 串**末条**客户消息 ts,
归属 day = 串末条所在上海自然日(跨夜串不再丢失);reply_msg_id =
该回复消息 msg_id——一条回复恰好闭合一个串,同毫秒 F,T,F,T 交换的
(thread_end_ts, reply_ts) 并列靠它去重,兼作下钻证据。
- 会话末尾仍未闭合的串:计入当日 threads(回复率分母),不产生样本。
"""
facts: list[FactSample] = []
acc: dict[int, _DayAcc] = {}
in_thread = False
end_ts: int | None = None
for ts, is_send, msg_id in rows:
if is_send is False:
in_thread = True
end_ts = int(ts)
elif is_send is True:
if in_thread and end_ts is not None:
fr = int(ts) - end_ts
d = day_key(end_ts)
facts.append(FactSample(end_ts, int(ts), fr, d, msg_id))
a = acc.setdefault(d, _DayAcc())
a.threads += 1
a.answered += 1
a.fr_sum_ms += fr
in_thread = False
end_ts = None
# is_send=None:跳过
if in_thread and end_ts is not None: # 会话末尾未闭合串
a = acc.setdefault(day_key(end_ts), _DayAcc())
a.threads += 1
return facts, {d: DayAgg(a.threads, a.answered, a.fr_sum_ms)
for d, a in acc.items()}
async def load_conversation(
session: AsyncSession, channel: str, owner: str, peer: str
) -> list:
"""拉取该 1:1 会话全历史 (timestamp, is_send, msg_id),Python 侧按
(ts, msg_id) 排序——串算法需严格时序且同毫秒并列须定序(SQL 等键顺序
未定义,不排序会随查询抖动;与 summary.recompute_days 同惯例,避免
依赖 SQL 排序)。friend 过滤口径与 summary._peer_filter 的 friend 分支
同源:talker_id 原值精确匹配 + room_id 为空(peer_id 与 talker_id 同源
同形态,不做截断/归一化)。
公有命名:employee_metrics 宽表生成复用同一加载口径(同源原则)。"""
rows = (
await session.execute(
sa.select(
Message.timestamp, Message.is_send, Message.msg_id
).where(
Message.channel == channel,
Message.owner_account_id == owner,
Message.talker_id == peer,
sa.or_(Message.room_id.is_(None), Message.room_id == ""),
Message.timestamp.is_not(None),
)
)
).all()
return sorted(rows, key=lambda r: (r[0], r[2]))
async def recompute_pairs(
session: AsyncSession, pairs: list[tuple[str, str, str]]
) -> int:
"""重算给定 friend 会话的 fact 行:按会话 delete+insert,幂等。
pairs = [(channel, owner_account_id, peer_id)]。返回会话数。每 500 个
会话 flush 一次约束 session 内存(与 summary.recompute_all 同惯例——
全量回填百万级 fact 行不能攒到最后),同时打进度日志(回填分钟级,
可观测)。commit 由调用方负责。
"""
n = 0
for channel, owner, peer in sorted(pairs):
rows = await load_conversation(session, channel, owner, peer)
facts, _agg = compute_conversation(rows)
await session.execute(
sa.delete(FirstReplyFact).where(
FirstReplyFact.channel == channel,
FirstReplyFact.owner_account_id == owner,
FirstReplyFact.peer_id == peer,
)
)
for f in facts:
session.add(
FirstReplyFact(
channel=channel,
owner_account_id=owner,
peer_id=peer,
thread_end_ts=f.thread_end_ts,
reply_ts=f.reply_ts,
reply_msg_id=f.reply_msg_id,
day=f.day,
fr_ms=f.fr_ms,
)
)
n += 1
if n % 500 == 0:
await session.flush()
logger.info("first_reply_facts: 已重算 %d 个会话", n)
return n
async def all_friend_pairs(session: AsyncSession) -> list[tuple[str, str, str]]:
"""全部 1:1 会话(friend 行 distinct)——全量回填用。
依赖 msg_daily_stats 已回填(先 stats 后 facts):friend 会话清单取自
stats 表而非 message 全表 distinct——写路径钩子保证 stats 常新,代价
远低于扫 message。
"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(MsgDailyStats.peer_kind == "friend")
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def pairs_active_in_days(
session: AsyncSession, days: Iterable[int]
) -> list[tuple[str, str, str]]:
"""窗口内有消息的 friend 会话(4h 滚动重算用;批次F 接调度)。"""
rows = (
await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.owner_account_id,
MsgDailyStats.peer_id,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.day.in_(list(days)),
)
)
).all()
return sorted({(r[0], r[1], r[2]) for r in rows})
async def recompute_all(session: AsyncSession) -> int:
"""全量重建(回填/夜间自愈,幂等)。"""
return await recompute_pairs(session, await all_friend_pairs(session))