Files
Neo-ZQYY/apps/backend/app/services/note_reclassifier.py
2026-03-15 10:15:02 +08:00

312 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_uptask_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,
}