# -*- coding: utf-8 -*- """ 备注回溯重分类器(Note Reclassifier) 召回完成后,回溯检查是否有普通备注需重分类为回访备注,并创建回访任务。 流程: 1. 查找 service_time 之后的第一条 normal 备注 2. 若找到 → 重分类为 follow_up,任务状态 = completed(回溯完成) 3. 若未找到 → 任务状态 = active(等待备注) 4. 冲突检查:已有 completed → 跳过;已有 active → 顶替;否则正常创建 5. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段 由 trigger_jobs 中的 note_reclassify_backfill 配置驱动(event: recall_completed)。 """ import json import logging logger = logging.getLogger(__name__) def _get_connection(): """延迟导入 get_connection,避免模块级导入失败。""" from app.database import get_connection return get_connection() def _insert_history( cur, task_id: int, action: str, old_status: str | None = None, new_status: str | None = None, old_task_type: str | None = None, new_task_type: str | None = None, detail: dict | None = None, ) -> None: """在 coach_task_history 中记录变更。""" cur.execute( """ INSERT INTO biz.coach_task_history (task_id, action, old_status, new_status, old_task_type, new_task_type, detail) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( task_id, action, old_status, new_status, old_task_type, new_task_type, json.dumps(detail) if detail else None, ), ) def ai_analyze_note(note_id: int) -> int | None: """ AI 应用 6 备注分析接口(占位)。 P5 AI 集成层实现后替换此占位函数。 当前返回 None 表示 AI 未就绪,跳过评分逻辑。 """ return None def run(payload: dict | None = None, job_id: int | None = None) -> dict: """ 备注回溯主流程。 payload 包含: {site_id, assistant_id, member_id, service_time} 流程: 1. 查找 service_time 之后的第一条 normal 备注 → note_id 2. 若 note_id 存在:重分类为 follow_up,task_status = 'completed'(回溯完成) 3. 若 note_id 不存在:task_status = 'active'(等待备注) 4. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段 5. 冲突检查(T3): - 已有 completed → 跳过创建 - 已有 active → 旧任务标记 inactive + superseded 历史,创建新任务 - 不存在(或仅 inactive/abandoned)→ 正常创建 6. 创建 follow_up_visit 任务 参数: payload: 事件载荷(由 trigger_scheduler 传入) job_id: 触发器 job ID(由 trigger_scheduler 传入),用于在最终事务中 更新 last_run_at,保证 handler 数据变更与 last_run_at 原子提交 返回: {"reclassified_count": int, "tasks_created": int} """ if not payload: return {"reclassified_count": 0, "tasks_created": 0} site_id = payload.get("site_id") assistant_id = payload.get("assistant_id") member_id = payload.get("member_id") service_time = payload.get("service_time") if not all([site_id, assistant_id, member_id, service_time]): logger.warning("备注回溯缺少必要参数: %s", payload) return {"reclassified_count": 0, "tasks_created": 0} reclassified_count = 0 tasks_created = 0 conn = _get_connection() try: # ── 1. 查找 service_time 之后的第一条 normal 备注 ── note_id = None with conn.cursor() as cur: cur.execute( """ SELECT id FROM biz.notes WHERE site_id = %s AND target_type = 'member' AND target_id = %s AND type = 'normal' AND created_at > %s ORDER BY created_at ASC LIMIT 1 """, (site_id, member_id, service_time), ) row = cur.fetchone() if row: note_id = row[0] conn.commit() # ── 2. 根据是否找到备注确定任务状态(T4) ── if note_id is not None: # 找到备注 → 重分类为 follow_up with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.notes SET type = 'follow_up', updated_at = NOW() WHERE id = %s AND type = 'normal' """, (note_id,), ) conn.commit() reclassified_count = 1 # 保留 AI 占位调用,返回值仅用于更新 ai_score 字段 ai_score = ai_analyze_note(note_id) if ai_score is not None: with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.notes SET ai_score = %s, updated_at = NOW() WHERE id = %s """, (ai_score, note_id), ) conn.commit() # 有备注 → 回溯完成 task_status = "completed" else: # 未找到备注 → 等待备注 logger.info( "未找到符合条件的 normal 备注: site_id=%s, member_id=%s", site_id, member_id, ) ai_score = None task_status = "active" # ── 3. 冲突检查(T3):查询已有 follow_up_visit 任务 ── with conn.cursor() as cur: cur.execute( """ SELECT id, status FROM biz.coach_tasks WHERE site_id = %s AND assistant_id = %s AND member_id = %s AND task_type = 'follow_up_visit' AND status IN ('active', 'completed') ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END LIMIT 1 """, (site_id, assistant_id, member_id), ) existing = cur.fetchone() conn.commit() if existing: existing_id, existing_status = existing if existing_status == "completed": # 已完成 → 跳过创建(回访完成语义已满足) logger.info( "已存在 completed 回访任务 id=%s,跳过创建: " "site_id=%s, assistant_id=%s, member_id=%s", existing_id, site_id, assistant_id, member_id, ) # 事务安全(T5):即使跳过创建,handler 仍成功,更新 last_run_at if job_id is not None: from app.services.trigger_scheduler import ( update_job_last_run_at, ) with conn.cursor() as cur: cur.execute("BEGIN") update_job_last_run_at(cur, job_id) conn.commit() return { "reclassified_count": reclassified_count, "tasks_created": 0, } elif existing_status == "active": # 顶替:旧任务 → inactive + superseded 历史 with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.coach_tasks SET status = 'inactive', updated_at = NOW() WHERE id = %s AND status = 'active' """, (existing_id,), ) _insert_history( cur, existing_id, action="superseded", old_status="active", new_status="inactive", detail={ "reason": "new_reclassify_task_supersedes", "source": "note_reclassifier", }, ) conn.commit() logger.info( "顶替旧 active 回访任务 id=%s → inactive: " "site_id=%s, assistant_id=%s, member_id=%s", existing_id, site_id, assistant_id, member_id, ) # ── 4. 创建 follow_up_visit 任务 ── with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ INSERT INTO biz.coach_tasks (site_id, assistant_id, member_id, task_type, status, completed_at, completed_task_type) VALUES ( %s, %s, %s, 'follow_up_visit', %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END, CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END ) RETURNING id """, ( site_id, assistant_id, member_id, task_status, task_status, task_status, ), ) new_task_row = cur.fetchone() new_task_id = new_task_row[0] # 记录任务创建历史 _insert_history( cur, new_task_id, action="created_by_reclassify", old_status=None, new_status=task_status, old_task_type=None, new_task_type="follow_up_visit", detail={ "note_id": note_id, "ai_score": ai_score, "source": "note_reclassifier", }, ) # 事务安全(T5):在最终 commit 前更新 last_run_at if job_id is not None: from app.services.trigger_scheduler import update_job_last_run_at update_job_last_run_at(cur, job_id) conn.commit() tasks_created = 1 except Exception: logger.exception( "备注回溯失败: site_id=%s, member_id=%s", site_id, member_id, ) conn.rollback() finally: conn.close() logger.info( "备注回溯完成: reclassified_count=%d, tasks_created=%d", reclassified_count, tasks_created, ) return { "reclassified_count": reclassified_count, "tasks_created": tasks_created, }