"""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))