312 lines
11 KiB
Python
312 lines
11 KiB
Python
# -*- 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,
|
||
}
|
||
|