Files
Neo-ZQYY/apps/backend/app/services/task_generator.py

484 lines
15 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 -*-
"""
任务生成器Task Generator
每日 4:00 运行,基于 WBI/NCI/RS 指数为每个助教生成/更新任务。
本模块包含:
- TaskPriority 枚举:任务类型优先级定义
- TASK_TYPE_PRIORITY 映射task_type 字符串 → 优先级
- IndexData 数据类:客户-助教对的指数数据
- determine_task_type():根据指数确定任务类型(纯函数)
- should_replace_task():判断是否应替换现有任务(纯函数)
- compute_heart_icon():根据 RS 指数计算爱心 icon 档位(纯函数)
"""
from decimal import Decimal
from dataclasses import dataclass
from enum import IntEnum
class TaskPriority(IntEnum):
"""任务类型优先级,数值越小优先级越高。"""
HIGH_PRIORITY_RECALL = 0
PRIORITY_RECALL = 0
FOLLOW_UP_VISIT = 1
RELATIONSHIP_BUILDING = 2
TASK_TYPE_PRIORITY: dict[str, TaskPriority] = {
"high_priority_recall": TaskPriority.HIGH_PRIORITY_RECALL,
"priority_recall": TaskPriority.PRIORITY_RECALL,
"follow_up_visit": TaskPriority.FOLLOW_UP_VISIT,
"relationship_building": TaskPriority.RELATIONSHIP_BUILDING,
}
@dataclass
class IndexData:
"""某客户-助教对的指数数据。"""
site_id: int
assistant_id: int
member_id: int
wbi: Decimal # 流失回赢指数
nci: Decimal # 新客转化指数
rs: Decimal # 关系强度指数
has_active_recall: bool # 是否有活跃召回任务
has_follow_up_note: bool # 召回完成后是否有回访备注
def determine_task_type(index_data: IndexData) -> str | None:
"""
根据指数数据确定应生成的任务类型。
优先级规则(高 → 低):
1. max(WBI, NCI) > 7 → high_priority_recall
2. max(WBI, NCI) > 5 → priority_recall
3. RS < 6 → relationship_building
4. 不满足任何条件 → None不生成任务
返回: task_type 字符串或 None
"""
priority_score = max(index_data.wbi, index_data.nci)
if priority_score > 7:
return "high_priority_recall"
if priority_score > 5:
return "priority_recall"
if index_data.rs < 6:
return "relationship_building"
return None
def should_replace_task(existing_type: str, new_type: str) -> bool:
"""
判断新任务类型是否应替换现有任务类型。
规则:类型不同即替换,相同类型不替换。
"""
if existing_type == new_type:
return False
return True
def compute_heart_icon(rs_score: Decimal) -> str:
"""
根据 RS 指数计算爱心 icon 档位。
档位规则:
- RS > 8.5 → 💖
- 7 < RS ≤ 8.5 → 🧡
- 5 < RS ≤ 7 → 💛
- RS ≤ 5 → 💙
"""
if rs_score > Decimal("8.5"):
return "💖"
if rs_score > Decimal("7"):
return "🧡"
if rs_score > Decimal("5"):
return "💛"
return "💙"
# ── run() 主流程 ──────────────────────────────────────────────
import logging
logger = logging.getLogger(__name__)
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
return get_connection()
def run() -> dict:
"""
任务生成器主流程。
1. 通过 auth.user_assistant_binding 获取所有已绑定助教
2. 对每个助教-客户对,通过 FDW 读取 WBI/NCI/RS 指数
3. 调用 determine_task_type() 确定任务类型
4. 检查已存在的 active 任务:相同 task_type → 跳过;
不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
5. 处理 follow_up_visit 的 48 小时滞留机制expires_at 填充)
6. 更新 trigger_jobs 时间戳
返回: {"created": int, "replaced": int, "skipped": int}
"""
stats = {"created": 0, "replaced": 0, "skipped": 0}
conn = _get_connection()
try:
# ── 1. 获取所有已绑定助教 ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT site_id, assistant_id
FROM auth.user_assistant_binding
WHERE assistant_id IS NOT NULL
"""
)
bindings = cur.fetchall()
conn.commit()
# ── 2. 逐助教处理 ──
for site_id, assistant_id in bindings:
try:
_process_assistant(conn, site_id, assistant_id, stats)
except Exception:
logger.exception(
"处理助教失败: site_id=%s, assistant_id=%s",
site_id,
assistant_id,
)
conn.rollback()
# ── 6. 更新 trigger_jobs 时间戳 ──
with conn.cursor() as cur:
cur.execute(
"""
UPDATE biz.trigger_jobs
SET last_run_at = NOW()
WHERE job_name = 'task_generator'
"""
)
conn.commit()
finally:
conn.close()
logger.info(
"任务生成器完成: created=%d, replaced=%d, skipped=%d",
stats["created"],
stats["replaced"],
stats["skipped"],
)
return stats
def _process_assistant(
conn, site_id: int, assistant_id: int, stats: dict
) -> None:
"""处理单个助教下所有客户-助教对的任务生成。"""
# 通过 FDW 读取该助教关联的客户指数数据
# 需要 SET LOCAL app.current_site_id 以启用 RLS
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"SET LOCAL app.current_site_id = %s", (str(site_id),)
)
# 读取 WBI流失回赢指数
cur.execute(
"""
SELECT member_id, COALESCE(display_score, 0)
FROM fdw_etl.v_dws_member_winback_index
"""
)
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
# 读取 NCI新客转化指数
cur.execute(
"""
SELECT member_id, COALESCE(display_score, 0)
FROM fdw_etl.v_dws_member_newconv_index
"""
)
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
# 读取 RS关系强度指数— 按 assistant_id 过滤
cur.execute(
"""
SELECT member_id, COALESCE(rs_display, 0)
FROM fdw_etl.v_dws_member_assistant_relation_index
WHERE assistant_id = %s
""",
(assistant_id,),
)
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
conn.commit()
# 合并所有涉及的 member_id
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())
# 逐客户处理,每对独立事务
for member_id in all_member_ids:
try:
_process_member_task(
conn,
site_id,
assistant_id,
member_id,
wbi_map.get(member_id, Decimal("0")),
nci_map.get(member_id, Decimal("0")),
rs_map.get(member_id, Decimal("0")),
stats,
)
except Exception:
logger.exception(
"处理客户任务失败: site_id=%s, assistant_id=%s, member_id=%s",
site_id,
assistant_id,
member_id,
)
conn.rollback()
def _process_member_task(
conn,
site_id: int,
assistant_id: int,
member_id: int,
wbi: Decimal,
nci: Decimal,
rs: Decimal,
stats: dict,
) -> None:
"""
处理单个客户-助教对的任务生成/更新。
每对独立事务,失败不影响其他。
"""
index_data = IndexData(
site_id=site_id,
assistant_id=assistant_id,
member_id=member_id,
wbi=wbi,
nci=nci,
rs=rs,
# follow_up_visit 条件由外部传入;当前简化:不自动生成 follow_up_visit
# follow_up_visit 由召回完成检测器触发,不在 task_generator 主动生成)
has_active_recall=True,
has_follow_up_note=True,
)
new_task_type = determine_task_type(index_data)
if new_task_type is None:
# 不满足任何条件 → 检查是否有需要填充 expires_at 的 follow_up_visit
_handle_no_task_condition(conn, site_id, assistant_id, member_id)
stats["skipped"] += 1
return
priority_score = max(wbi, nci)
with conn.cursor() as cur:
cur.execute("BEGIN")
# ── 4. 检查已存在的 active 任务 ──
cur.execute(
"""
SELECT id, task_type, expires_at, created_at
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND member_id = %s
AND status = 'active'
ORDER BY created_at DESC
""",
(site_id, assistant_id, member_id),
)
existing_tasks = cur.fetchall()
# 检查是否已有相同 task_type 的 active 任务
same_type_exists = any(row[1] == new_task_type for row in existing_tasks)
if same_type_exists:
# 相同 task_type → 跳过
conn.commit()
stats["skipped"] += 1
return
# 不同 task_type 的 active 任务 → 关闭旧任务 + 创建新任务
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
if should_replace_task(old_type, new_task_type):
# 特殊处理:旧任务是 follow_up_visit → 填充 expires_at 而非直接 inactive
if old_type == "follow_up_visit" and old_expires_at is None:
# 需求 5.2: follow_up_visit 被高优先级任务顶替时,
# 填充 expires_at = created_at + 48h保持 active
cur.execute(
"""
UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours',
updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_insert_history(
cur,
task_id,
action="expires_at_filled",
old_status="active",
new_status="active",
old_task_type=old_type,
new_task_type=old_type,
detail={"reason": "higher_priority_task_created"},
)
else:
# 关闭旧任务
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_insert_history(
cur,
task_id,
action="type_change_close",
old_status="active",
new_status="inactive",
old_task_type=old_type,
new_task_type=new_task_type,
)
stats["replaced"] += 1
# ── 创建新任务 ──
# follow_up_visit 生成时 expires_at = NULL需求 4.1
expires_at_val = None
# 需求 4.4: 若新任务是 follow_up_visit 且已存在有 expires_at 的旧 follow_up_visit
# → 旧任务已在上面被标记为 inactive新任务 expires_at = NULL
cur.execute(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
priority_score, expires_at, parent_task_id)
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
RETURNING id
""",
(
site_id,
assistant_id,
member_id,
new_task_type,
float(priority_score),
expires_at_val,
# parent_task_id: 关联最近被关闭的旧任务(如有)
existing_tasks[0][0] if existing_tasks else None,
),
)
new_task_id = cur.fetchone()[0]
_insert_history(
cur,
new_task_id,
action="created",
old_status=None,
new_status="active",
old_task_type=existing_tasks[0][1] if existing_tasks else None,
new_task_type=new_task_type,
)
stats["created"] += 1
conn.commit()
def _handle_no_task_condition(
conn, site_id: int, assistant_id: int, member_id: int
) -> None:
"""
当不满足任何任务生成条件时,检查是否有 follow_up_visit 需要填充 expires_at。
需求 4.2: 当 follow_up_visit 的触发条件不再满足时,
填充 expires_at = created_at + 48h。
"""
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
SELECT id, expires_at
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND member_id = %s
AND task_type = 'follow_up_visit'
AND status = 'active'
AND expires_at IS NULL
""",
(site_id, assistant_id, member_id),
)
rows = cur.fetchall()
for task_id, _ in rows:
cur.execute(
"""
UPDATE biz.coach_tasks
SET expires_at = created_at + INTERVAL '48 hours',
updated_at = NOW()
WHERE id = %s
""",
(task_id,),
)
_insert_history(
cur,
task_id,
action="expires_at_filled",
old_status="active",
new_status="active",
detail={"reason": "condition_no_longer_met"},
)
conn.commit()
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 中记录变更。"""
import json
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,
),
)