import asyncio import os import time from datetime import datetime, timedelta import logging logger = logging.getLogger("cs_agent") async def unreplied_followup_loop(client): """定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。""" if not client.enable_agent or not client.agent: return while client.running: try: await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90")))) await scan_and_send_unreplied_followups(client) except asyncio.CancelledError: break except Exception as e: client._activity_log("unreplied_followup_loop_error", error=str(e)) async def scan_and_send_unreplied_followups(client): from db import chat_log_db as cdb try: idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12"))) max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180"))) followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600"))) limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40"))) except Exception: idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40 now = datetime.now() window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S") conn = None try: conn = cdb._get_conn() rows = conn.execute( cdb._sql( """ SELECT acc_id, customer_id, MAX(id) AS last_id FROM chat_logs WHERE timestamp >= ? GROUP BY acc_id, customer_id ORDER BY MAX(id) DESC LIMIT ? """ ), (window_start, limit * 6), ).fetchall() sessions = [dict(r) for r in rows] sent = 0 for s in sessions: if sent >= limit: break acc_id = str(s.get("acc_id", "") or "") cid = str(s.get("customer_id", "") or "") if not acc_id or not cid: continue ckey = f"{acc_id}:{cid}" if not client._is_owned_by_this_worker(ckey): continue last = conn.execute( cdb._sql( """ SELECT id, direction, message, timestamp, customer_name, acc_id, platform FROM chat_logs WHERE acc_id = ? AND customer_id = ? ORDER BY id DESC LIMIT 1 """ ), (acc_id, cid), ).fetchone() if not last: continue last = dict(last) if str(last.get("direction", "")) != "in": continue last_ts = last.get("timestamp") if isinstance(last_ts, datetime): last_dt = last_ts else: last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S") idle_s = (now - last_dt).total_seconds() if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60: continue now_mono = time.monotonic() if (now_mono - client._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd: continue last_msg = str(last.get("message", "") or "").strip().lower() if last_msg in {"好的", "好", "ok", "收到", "嗯", "哦"}: continue followup = await compose_ai_scene_reply( client, original_msg={ "acc_id": acc_id, "from_id": cid, "from_name": client.to_chinese(last.get("customer_name", "") or cid), "acc_type": str(last.get("platform", "") or "AliWorkbench"), "msg": str(last.get("message", "") or ""), }, scene="unreplied_followup", intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。", fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。", ) fake = { "acc_id": acc_id, "from_id": cid, "from_name": client.to_chinese(last.get("customer_name", "") or cid), "cy_id": cid, "cy_name": client.to_chinese(last.get("customer_name", "") or cid), "acc_type": str(last.get("platform", "") or "AliWorkbench"), "msg": str(last.get("message", "") or ""), "msg_type": 0, } await client.send_reply(fake, followup) client._unreplied_followup_sent[ckey] = now_mono sent += 1 client._activity_log( "unreplied_followup_sent", acc_id=acc_id, customer_id=cid, idle_seconds=int(idle_s), last_msg=str(last.get("message", "") or "")[:120], reply=followup, ) finally: try: if conn: conn.close() except Exception: logger.debug("关闭数据库连接失败", exc_info=True) async def compose_ai_scene_reply(client, *, original_msg: dict, scene: str, intent_hint: str, fallback: str) -> str: """场景化 AI 直接生成回复(不依赖固定模板)。""" if not client.enable_agent or not client.agent or not client.AgentDeps: return fallback try: deps = client.AgentDeps( msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"), acc_id=str(original_msg.get("acc_id", "") or ""), from_id=str(original_msg.get("from_id", "") or ""), platform=str(original_msg.get("acc_type", "") or ""), ) customer_msg = client.to_chinese(str(original_msg.get("msg", "") or "")) prompt = ( "你是淘宝客服,直接生成一条发给客户的话。\n" f"场景: {scene}\n" f"意图: {intent_hint}\n" f"客户原话: {customer_msg}\n" "要求: 1-2句,自然口语,不要模板腔,不要新增价格/承诺;只输出最终回复。\n" ) result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[]) out = str(getattr(result, "output", "") or "").strip() if not out: return fallback if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out: return fallback client._activity_log( "ai_scene_reply_generated", acc_id=str(original_msg.get("acc_id", "") or ""), customer_id=str(original_msg.get("from_id", "") or ""), scene=scene, generated=out[:160], ) return out except Exception as e: client._activity_log( "ai_scene_reply_error", acc_id=str(original_msg.get("acc_id", "") or ""), customer_id=str(original_msg.get("from_id", "") or ""), scene=scene, error=str(e), ) return fallback