Files
Neo-ZQYY/apps/backend/app/services/recall_detector.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

352 lines
12 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.
# AI_CHANGELOG
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | _process_site() 中 fdw_etl.v_dwd_assistant_service_log
# 改为直连 ETL 库查询 app.v_dwd_assistant_service_log。使用 fdw_queries._fdw_context()。
# -*- coding: utf-8 -*-
"""
召回完成检测器Recall Completion Detector
ETL 数据更新后,直连 ETL 库读取助教服务记录,
匹配活跃任务标记为 completed记录 completed_at 和 completed_task_type 快照,
触发 recall_completed 事件通知备注回溯重分类器。
由 trigger_jobs 中的 recall_completion_check 配置驱动event: etl_data_updated
"""
import json
import logging
from app.trace.decorators import trace_service
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,
),
)
@trace_service(description_zh="执行维客检测", description_en="Run recall detection")
def run(payload: dict | None = None, job_id: int | None = None) -> dict:
"""
召回完成检测主流程。
1. 从 trigger_jobs 读取 last_run_at 作为增量过滤基准
2. 获取所有 distinct site_id从 active 任务中)
3. 对每个 site_idSET LOCAL app.current_site_id 后
通过 FDW 读取 v_dwd_assistant_service_log 中 service_time > last_run_at 的新增服务记录
4. 对每条服务记录,查找 biz.coach_tasks 中匹配的
(site_id, assistant_id, member_id) 且 status='active' 的任务
5. 将匹配任务标记为 completed
- status = 'completed'
- completed_at = 服务时间
- completed_task_type = 当前 task_type快照
6. 记录 coach_task_history
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
参数:
payload: 事件载荷event 触发时由 trigger_scheduler 传入)
job_id: 触发器 job ID由 trigger_scheduler 传入),用于在最终事务中
更新 last_run_at保证 handler 数据变更与 last_run_at 原子提交
返回: {"completed_count": int}
"""
completed_count = 0
conn = _get_connection()
try:
# ── 1. 读取 last_run_at ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT last_run_at
FROM biz.trigger_jobs
WHERE job_name = 'recall_completion_check'
"""
)
row = cur.fetchone()
last_run_at = row[0] if row else None
conn.commit()
# ── 2. 获取所有有 active 任务的 distinct site_id ──
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT site_id
FROM biz.coach_tasks
WHERE status = 'active'
"""
)
site_ids = [r[0] for r in cur.fetchall()]
conn.commit()
# ── 3. 逐 site_id 读取新增服务记录 ──
for site_id in site_ids:
try:
count = _process_site(conn, site_id, last_run_at)
completed_count += count
except Exception:
logger.exception(
"处理门店召回检测失败: site_id=%s", site_id
)
conn.rollback()
# ── 事务安全T5handler 成功后更新 last_run_at ──
# job_id 由 trigger_scheduler 传入,在 handler 最终事务中更新
# 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()
finally:
conn.close()
logger.info("召回完成检测完成: completed_count=%d", completed_count)
return {"completed_count": completed_count}
def _process_site(conn, site_id: int, last_run_at) -> int:
"""
处理单个门店的召回完成检测。
直连 ETL 库读取新增服务记录,匹配 active 任务并标记 completed。
返回本门店完成的任务数。
"""
completed = 0
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dwd_assistant_service_log → app.v_dwd_assistant_service_log
# intent: 修复 RLS 门店隔离失效postgres_fdw 不传递 GUC 参数)
# assumptions: _fdw_context 内部管理 ETL 连接conn 仅用于后续业务库操作
from app.services.fdw_queries import _fdw_context
with _fdw_context(conn, site_id) as cur:
if last_run_at is not None:
# 列名映射: FDW 外部表 assistant_id/member_id/service_time
# → RLS 视图 site_assistant_id/tenant_member_id/create_time
cur.execute(
"""
SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
FROM app.v_dwd_assistant_service_log
WHERE create_time > %s
ORDER BY create_time ASC
""",
(last_run_at,),
)
else:
# 首次运行,读取所有服务记录
cur.execute(
"""
SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
FROM app.v_dwd_assistant_service_log
ORDER BY create_time ASC
"""
)
service_records = cur.fetchall()
# ── 4-7. 逐条服务记录匹配并处理 ──
for assistant_id, member_id, service_time in service_records:
# 散客过滤member_id ≤ 0 不参与任务系统)
if member_id is None or member_id <= 0:
continue
try:
count = _process_service_record(
conn, site_id, assistant_id, member_id, service_time
)
completed += count
except Exception:
logger.exception(
"处理服务记录失败: site_id=%s, assistant_id=%s, member_id=%s",
site_id,
assistant_id,
member_id,
)
conn.rollback()
return completed
def _process_service_record(
conn,
site_id: int,
assistant_id: int,
member_id: int,
service_time,
) -> int:
"""
处理单条服务记录:匹配 active 任务并标记 completed + 生成回访任务。
CHANGE 2026-03-30 | 回访任务直接在此生成(不再依赖 note_reclassifier 事件链)。
规则:
- 有 active 召回任务 → 标记 completed然后生成回访任务
- 有 active 回访任务 → 关闭旧回访,生成新回访(重置 48h 倒计时)
- 无任何 active 召回/回访 → 直接生成回访任务
每条服务记录独立事务,失败不影响其他。
返回本次完成的任务数。
"""
completed = 0
with conn.cursor() as cur:
cur.execute("BEGIN")
# ── 1. 查找匹配的 active 召回类任务 ──
cur.execute(
"""
SELECT id, task_type
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND member_id = %s
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall')
""",
(site_id, assistant_id, member_id),
)
active_recall_tasks = cur.fetchall()
has_active_recall = len(active_recall_tasks) > 0
# 将所有匹配的 active 召回任务标记为 completed
for task_id, task_type in active_recall_tasks:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'completed',
completed_at = %s,
completed_task_type = %s,
updated_at = NOW()
WHERE id = %s AND status = 'active'
""",
(service_time, task_type, task_id),
)
_insert_history(
cur,
task_id,
action="completed",
old_status="active",
new_status="completed",
old_task_type=task_type,
new_task_type=task_type,
detail={
"service_time": str(service_time),
"completed_task_type": task_type,
},
)
completed += 1
# ── 2. 生成回访任务CHANGE 2026-03-30 ──
# 如果还有 active 召回任务(其他助教的),不生成回访
# 注意:上面已经把当前助教的召回任务标记为 completed 了
# 这里检查的是当前助教-客户对是否还有未完成的召回任务(不应该有了)
# 关闭已有的 active 回访任务
cur.execute(
"""
SELECT id 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'
""",
(site_id, assistant_id, member_id),
)
old_follow_ups = cur.fetchall()
for (old_id,) in old_follow_ups:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
WHERE id = %s
""",
(old_id,),
)
_insert_history(
cur, old_id,
action="superseded_by_new_visit",
old_status="active", new_status="inactive",
old_task_type="follow_up_visit", new_task_type="follow_up_visit",
detail={"reason": "new_service_record", "service_time": str(service_time)},
)
# 创建新的回访任务48h 过期)
from datetime import timedelta
expires_at = service_time + timedelta(hours=48) if hasattr(service_time, '__add__') else None
cur.execute(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status, expires_at, created_at, updated_at)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, NOW(), NOW())
RETURNING id
""",
(site_id, assistant_id, member_id, expires_at),
)
new_follow_up_id = cur.fetchone()[0]
_insert_history(
cur, new_follow_up_id,
action="created",
old_status=None, new_status="active",
new_task_type="follow_up_visit",
detail={
"reason": "service_record_detected",
"service_time": str(service_time),
"had_recall": has_active_recall,
},
)
conn.commit()
# ── 3. 触发 recall_completed 事件(仅当有召回任务被完成时) ──
if has_active_recall:
try:
from app.services.trigger_scheduler import fire_event
fire_event(
"recall_completed",
{
"site_id": site_id,
"assistant_id": assistant_id,
"member_id": member_id,
"service_time": str(service_time),
},
)
except Exception:
logger.exception(
"触发 recall_completed 事件失败: site_id=%s, assistant_id=%s, member_id=%s",
site_id, assistant_id, member_id,
)
return completed