423baff73b
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
AI 工作流服务 (Dify BaaS 版本)
|
|
事件驱动的异步 AI 任务:从客户沟通日志中自动提取标签和生成跟进待办。
|
|
|
|
架构要点:
|
|
- 此函数由 FastAPI BackgroundTasks 调用,运行在独立的后台线程中
|
|
- 必须使用独立的 DB Session 生命周期,严禁与主请求共享 Session
|
|
- AI 调用通过 Dify 平台 API(非直连大模型)
|
|
- AI 解析失败时静默降级(记录日志),绝不抛出异常导致主线程崩溃
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
|
|
from app.core.dify_client import dify_client
|
|
from app.core.database import AsyncSessionLocal
|
|
from app.models.crm_business import CustomerTag, FollowUpToDo
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("ai_workflow")
|
|
|
|
|
|
async def process_log_with_ai(
|
|
log_id: uuid.UUID,
|
|
content: str,
|
|
customer_id: uuid.UUID,
|
|
) -> None:
|
|
"""
|
|
后台 AI 任务:分析沟通日志,提取标签和待办。
|
|
|
|
*** 关键约束 ***
|
|
此函数在 BackgroundTasks 中执行,必须:
|
|
1. 使用独立的 AsyncSession(不与主请求共享)
|
|
2. 内部捕获所有异常,不向外抛出
|
|
3. AI 返回格式异常时静默降级
|
|
"""
|
|
logger.info("开始 AI 处理: log_id=%s, customer_id=%s", log_id, customer_id)
|
|
|
|
if not settings.DIFY_LOG_APP_API_KEY:
|
|
logger.error("DIFY_LOG_APP_API_KEY 未配置,跳过 AI 处理")
|
|
return
|
|
|
|
# ---- 独立的 DB Session 生命周期 ----
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
# Step 1: 调用 Dify 日志分析 Workflow App
|
|
# *** inputs 的键名必须与 Dify 后台配置的变量名对齐 ***
|
|
# 在 Dify 后台的 Workflow 编排中,需定义输入变量 "log_content"
|
|
workflow_outputs = await dify_client.call_workflow(
|
|
api_key=settings.DIFY_LOG_APP_API_KEY,
|
|
inputs={"log_content": content},
|
|
)
|
|
|
|
if not workflow_outputs:
|
|
logger.warning("Dify 返回空响应,跳过入库 (log_id=%s)", log_id)
|
|
return
|
|
|
|
# Workflow 返回的是 dict,序列化为 JSON 字符串以兼容现有解析管线
|
|
if isinstance(workflow_outputs, dict):
|
|
raw_response = json.dumps(workflow_outputs, ensure_ascii=False)
|
|
else:
|
|
raw_response = str(workflow_outputs)
|
|
|
|
logger.debug("Dify Workflow 原始返回: %s", raw_response[:500])
|
|
|
|
# Step 2: 解析 JSON 响应 (容错)
|
|
json_str = _extract_json(raw_response)
|
|
result = json.loads(json_str)
|
|
|
|
tags: list[str] = result.get("tags", [])
|
|
next_task: str = result.get("next_task", "")
|
|
|
|
# Step 3: 写入标签 (最多 3 个)
|
|
if tags:
|
|
for tag_name in tags[:3]:
|
|
tag_name = tag_name.strip()
|
|
if not tag_name:
|
|
continue
|
|
tag = CustomerTag(
|
|
customer_id=customer_id,
|
|
tag_name=tag_name,
|
|
)
|
|
db.add(tag)
|
|
logger.info("写入 %d 个标签: %s", len(tags[:3]), tags[:3])
|
|
|
|
# Step 4: 写入跟进待办
|
|
if next_task and next_task.strip():
|
|
todo = FollowUpToDo(
|
|
customer_id=customer_id,
|
|
task_desc=next_task.strip(),
|
|
status="pending",
|
|
)
|
|
db.add(todo)
|
|
logger.info("写入待办: %s", next_task.strip()[:100])
|
|
|
|
await db.commit()
|
|
logger.info("AI 处理完成: log_id=%s", log_id)
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.error(
|
|
"Dify 返回 JSON 解析失败 (log_id=%s): %s | 原始响应: %s",
|
|
log_id, e, raw_response[:300],
|
|
)
|
|
await db.rollback()
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"AI 后台任务异常 (log_id=%s): %s",
|
|
log_id, e, exc_info=True,
|
|
)
|
|
await db.rollback()
|
|
|
|
|
|
def _extract_json(text: str) -> str:
|
|
"""
|
|
从 Dify/LLM 响应中提取 JSON 字符串。
|
|
处理常见的"包裹"行为:
|
|
- 直接返回 JSON
|
|
- 用 ```json ... ``` 包裹
|
|
- 在 JSON 前后加解释文字
|
|
"""
|
|
text = text.strip()
|
|
|
|
# 尝试提取 ```json ... ``` 代码块
|
|
if "```json" in text:
|
|
start = text.index("```json") + len("```json")
|
|
end = text.index("```", start)
|
|
return text[start:end].strip()
|
|
|
|
if "```" in text:
|
|
start = text.index("```") + len("```")
|
|
end = text.index("```", start)
|
|
return text[start:end].strip()
|
|
|
|
# 尝试找到第一个 { 和最后一个 }
|
|
first_brace = text.find("{")
|
|
last_brace = text.rfind("}")
|
|
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
|
return text[first_brace:last_brace + 1]
|
|
|
|
# 原样返回,让 json.loads 报错触发容错
|
|
return text
|