2026-09-09 12:53:33 +08:00
|
|
|
|
"""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)。
|
2026-09-09 13:18:08 +08:00
|
|
|
|
|
|
|
|
|
|
时序确定性:同毫秒并列(ts 相同的 F/T 消息)按 msg_id 定序(wechat
|
|
|
|
|
|
msgSvrId 服务端单调,近似真实到达序)——SQL 等键返回顺序未定义,不做
|
|
|
|
|
|
定序会导致串归属随查询抖动;fact PK 含 reply_msg_id,同毫秒 F,T,F,T
|
|
|
|
|
|
交换的两个样本不撞主键。
|
2026-09-09 12:53:33 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from collections import namedtuple
|
2026-09-09 13:18:08 +08:00
|
|
|
|
from collections.abc import Iterable
|
2026-09-09 12:53:33 +08:00
|
|
|
|
|
|
|
|
|
|
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__)
|
|
|
|
|
|
|
2026-09-09 13:18:08 +08:00
|
|
|
|
# 一个被回串的首响样本;day = 串末条客户消息所在上海自然日,
|
|
|
|
|
|
# reply_msg_id = 闭合该串的员工回复消息 msg_id(PK 并列去重 + 下钻证据)
|
|
|
|
|
|
FactSample = namedtuple("FactSample", "thread_end_ts reply_ts fr_ms day reply_msg_id")
|
2026-09-09 12:53:33 +08:00
|
|
|
|
# 某会话某日的串聚合(分子分母同源)
|
|
|
|
|
|
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]]:
|
2026-09-09 13:18:08 +08:00
|
|
|
|
"""跨日精确串算法。rows = [(ts, is_send, msg_id)] 该会话全历史,
|
|
|
|
|
|
**须按 (ts, msg_id) 升序**(load_conversation 负责排序;同毫秒并列以
|
|
|
|
|
|
msg_id 定序,见模块 docstring)。
|
2026-09-09 12:53:33 +08:00
|
|
|
|
|
|
|
|
|
|
- 串 = 客户连续消息(is_send=False)不被员工消息(is_send=True)打断的
|
|
|
|
|
|
段;is_send=None 跳过(不打断也不起串)——与 _thread_stats 判定一致。
|
|
|
|
|
|
- 每个被回串一个样本:fr_ms = 员工首条回复 ts − 串**末条**客户消息 ts,
|
2026-09-09 13:18:08 +08:00
|
|
|
|
归属 day = 串末条所在上海自然日(跨夜串不再丢失);reply_msg_id =
|
|
|
|
|
|
该回复消息 msg_id——一条回复恰好闭合一个串,同毫秒 F,T,F,T 交换的
|
|
|
|
|
|
(thread_end_ts, reply_ts) 并列靠它去重,兼作下钻证据。
|
2026-09-09 12:53:33 +08:00
|
|
|
|
- 会话末尾仍未闭合的串:计入当日 threads(回复率分母),不产生样本。
|
|
|
|
|
|
"""
|
|
|
|
|
|
facts: list[FactSample] = []
|
|
|
|
|
|
acc: dict[int, _DayAcc] = {}
|
|
|
|
|
|
in_thread = False
|
|
|
|
|
|
end_ts: int | None = None
|
2026-09-09 13:18:08 +08:00
|
|
|
|
for ts, is_send, msg_id in rows:
|
2026-09-09 12:53:33 +08:00
|
|
|
|
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)
|
2026-09-09 13:18:08 +08:00
|
|
|
|
facts.append(FactSample(end_ts, int(ts), fr, d, msg_id))
|
2026-09-09 12:53:33 +08:00
|
|
|
|
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:
|
2026-09-09 13:18:08 +08:00
|
|
|
|
"""拉取该 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 同源
|
|
|
|
|
|
同形态,不做截断/归一化)。
|
2026-09-09 12:53:33 +08:00
|
|
|
|
|
|
|
|
|
|
公有命名:employee_metrics 宽表生成复用同一加载口径(同源原则)。"""
|
|
|
|
|
|
rows = (
|
|
|
|
|
|
await session.execute(
|
2026-09-09 13:18:08 +08:00
|
|
|
|
sa.select(
|
|
|
|
|
|
Message.timestamp, Message.is_send, Message.msg_id
|
|
|
|
|
|
).where(
|
2026-09-09 12:53:33 +08:00
|
|
|
|
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()
|
2026-09-09 13:18:08 +08:00
|
|
|
|
return sorted(rows, key=lambda r: (r[0], r[2]))
|
2026-09-09 12:53:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def recompute_pairs(
|
|
|
|
|
|
session: AsyncSession, pairs: list[tuple[str, str, str]]
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""重算给定 friend 会话的 fact 行:按会话 delete+insert,幂等。
|
|
|
|
|
|
|
2026-09-09 13:18:08 +08:00
|
|
|
|
pairs = [(channel, owner_account_id, peer_id)]。返回会话数。每 500 个
|
|
|
|
|
|
会话 flush 一次约束 session 内存(与 summary.recompute_all 同惯例——
|
|
|
|
|
|
全量回填百万级 fact 行不能攒到最后),同时打进度日志(回填分钟级,
|
|
|
|
|
|
可观测)。commit 由调用方负责。
|
2026-09-09 12:53:33 +08:00
|
|
|
|
"""
|
2026-09-09 13:18:08 +08:00
|
|
|
|
n = 0
|
2026-09-09 12:53:33 +08:00
|
|
|
|
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,
|
2026-09-09 13:18:08 +08:00
|
|
|
|
reply_msg_id=f.reply_msg_id,
|
2026-09-09 12:53:33 +08:00
|
|
|
|
day=f.day,
|
|
|
|
|
|
fr_ms=f.fr_ms,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-09-09 13:18:08 +08:00
|
|
|
|
n += 1
|
|
|
|
|
|
if n % 500 == 0:
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
logger.info("first_reply_facts: 已重算 %d 个会话", n)
|
|
|
|
|
|
return n
|
2026-09-09 12:53:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def all_friend_pairs(session: AsyncSession) -> list[tuple[str, str, str]]:
|
2026-09-09 13:18:08 +08:00
|
|
|
|
"""全部 1:1 会话(friend 行 distinct)——全量回填用。
|
|
|
|
|
|
|
|
|
|
|
|
依赖 msg_daily_stats 已回填(先 stats 后 facts):friend 会话清单取自
|
|
|
|
|
|
stats 表而非 message 全表 distinct——写路径钩子保证 stats 常新,代价
|
|
|
|
|
|
远低于扫 message。
|
|
|
|
|
|
"""
|
2026-09-09 12:53:33 +08:00
|
|
|
|
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(
|
2026-09-09 13:18:08 +08:00
|
|
|
|
session: AsyncSession, days: Iterable[int]
|
2026-09-09 12:53:33 +08:00
|
|
|
|
) -> 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))
|