feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录): - AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生) audit: 2026-04-20__ai-module-complete.md - admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager audit: 2026-04-21__admin-web-ai-management-suite.md - App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance) audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md - App2 prewarm 全过滤器 + AI 触发器 cron reschedule audit: 2026-04-21__app2-finance-prewarm-all-filters.md migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql - AppType 联合类型对齐 + adminAiAppTypes.test.ts audit: 2026-04-30__admin_web_ai_app_type_alignment.md - DashScope tokens_used 提取修复 audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md - App3 线索完整详情 prompt audit: 2026-05-01__backend_app3_full_detail_prompt.md - Runtime Context 沙箱(5-1~5-2 主线): - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts - migration: 20260501__runtime_context_sandbox.sql - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py - database/changes: 7 份 sandbox_* 验证报告 - 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整 + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py) 合规: - .gitignore 启用 tmp/ 排除 - 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留) 待验证清单: - docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md 每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# AI 应用子模块:app1_chat ~ app8_consolidation
|
||||
@@ -1,274 +0,0 @@
|
||||
"""应用 1:通用对话(SSE 流式)。
|
||||
|
||||
每次进入 chat 页面新建 ai_conversations 记录(不复用),
|
||||
首条消息注入页面上下文,流式返回 AI 回复。
|
||||
|
||||
app_id = "app1_chat"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import build_page_text
|
||||
from app.ai.schemas import SSEEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app1_chat"
|
||||
|
||||
# system prompt 总字符数上限
|
||||
_MAX_SYSTEM_PROMPT_LEN = 4000
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
page_context: dict | None = None,
|
||||
screen_content: str | None = None,
|
||||
client: DashScopeClient,
|
||||
conv_svc: ConversationService,
|
||||
) -> AsyncGenerator[SSEEvent, None]:
|
||||
"""流式对话入口,返回 SSEEvent 异步生成器。
|
||||
|
||||
流程:
|
||||
1. 创建 conversation 记录
|
||||
2. 写入 user message
|
||||
3. 构建 system prompt(注入页面上下文)
|
||||
4. 调用 bailian.chat_stream 流式获取回复
|
||||
5. 逐 chunk yield SSEEvent(type="chunk")
|
||||
6. 完成后写入 assistant message,yield SSEEvent(type="done")
|
||||
7. 异常时 yield SSEEvent(type="error")
|
||||
"""
|
||||
conversation_id: int | None = None
|
||||
|
||||
try:
|
||||
# 1. 每次新建 conversation(不复用)
|
||||
source_ctx = _build_source_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
source_context=source_ctx,
|
||||
)
|
||||
logger.info(
|
||||
"App1 新建对话: conversation_id=%s user_id=%s site_id=%s",
|
||||
conversation_id, user_id, site_id,
|
||||
)
|
||||
|
||||
# 2. 立即写入 user message
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message,
|
||||
)
|
||||
|
||||
# 3. 构建消息列表(system prompt + user message)
|
||||
messages = await _build_messages(
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
|
||||
# 4-5. 流式调用百炼,逐 chunk yield
|
||||
full_reply_parts: list[str] = []
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
full_reply_parts.append(chunk)
|
||||
yield SSEEvent(type="chunk", content=chunk)
|
||||
|
||||
# 6. 流式完成,拼接完整回复并写入 assistant message
|
||||
full_reply = "".join(full_reply_parts)
|
||||
# 百炼流式模式不返回 tokens_used,按字符数估算(粗略)
|
||||
estimated_tokens = len(full_reply)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=full_reply,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
yield SSEEvent(
|
||||
type="done",
|
||||
conversation_id=conversation_id,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"App1 对话异常: conversation_id=%s error=%s",
|
||||
conversation_id, e,
|
||||
exc_info=True,
|
||||
)
|
||||
yield SSEEvent(type="error", message=str(e))
|
||||
|
||||
|
||||
async def _build_messages(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> list[dict]:
|
||||
"""构建发送给百炼的消息列表。
|
||||
|
||||
首条 system 消息注入页面上下文和用户信息。
|
||||
"""
|
||||
system_content = await _build_system_prompt(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
# system prompt 总字符数控制
|
||||
if len(content_str) > _MAX_SYSTEM_PROMPT_LEN:
|
||||
# 截断 page_context 中的 data_text
|
||||
pc = system_content.get("page_context", {})
|
||||
dt = pc.get("data_text", "")
|
||||
if dt and len(dt) > 500:
|
||||
pc["data_text"] = dt[:500] + "…(已截断)"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
|
||||
|
||||
async def _build_system_prompt(
|
||||
*,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON。
|
||||
|
||||
通过 biz_params.user_prompt_params 传入用户信息,
|
||||
注入页面上下文供 AI 理解当前场景。
|
||||
"""
|
||||
prompt: dict = {
|
||||
"task": (
|
||||
"你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"
|
||||
"当 page_context 中包含 memberNickname、contextId 或 data_text 时,"
|
||||
"你必须直接使用这些信息回答问题,不要再向用户索要已有的信息。"
|
||||
"例如用户在客户详情页提问时,直接基于该客户的数据回答,无需要求提供会员 ID。"
|
||||
),
|
||||
"biz_params": {
|
||||
"user_prompt_params": {
|
||||
"User_ID": str(user_id),
|
||||
"Role": role,
|
||||
"Nickname": nickname,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 注入页面上下文(首条消息)
|
||||
page_ctx = await _build_page_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
site_id=site_id,
|
||||
)
|
||||
if page_ctx:
|
||||
prompt["page_context"] = page_ctx
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
async def _build_page_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
site_id: int,
|
||||
) -> dict:
|
||||
"""构建页面上下文信息。
|
||||
|
||||
根据 source_page(contextType)调用 build_page_text 获取结构化文本,
|
||||
看板类页面从 page_context 提取筛选参数传入 filters。
|
||||
contextType 为空或未识别时返回空 dict(跳过注入)。
|
||||
"""
|
||||
ctx: dict = {}
|
||||
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
|
||||
# 从 page_context 提取 contextId 和筛选参数
|
||||
context_id = None
|
||||
filters: dict = {}
|
||||
if page_context:
|
||||
context_id = page_context.get("contextId")
|
||||
# 看板类页面筛选参数透传
|
||||
for key in ("timeDimension", "areaFilter", "dimension", "typeFilter", "projectFilter"):
|
||||
if key in page_context:
|
||||
filters[key] = page_context[key]
|
||||
|
||||
# 调用 data_fetcher 获取页面数据文本
|
||||
try:
|
||||
data_text = await build_page_text(
|
||||
source_page=source_page,
|
||||
context_id=context_id,
|
||||
site_id=site_id,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
if data_text:
|
||||
ctx["data_text"] = data_text
|
||||
except Exception:
|
||||
logger.warning("页面上下文文本化失败: source_page=%s", source_page, exc_info=True)
|
||||
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_source_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict | None:
|
||||
"""构建存入 ai_conversations.source_context 的 JSON。"""
|
||||
ctx: dict = {}
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
return ctx if ctx else None
|
||||
@@ -1,210 +0,0 @@
|
||||
"""应用 2:财务洞察。
|
||||
|
||||
8 个时间维度独立调用,每次调用结果写入 ai_cache,
|
||||
同时创建 ai_conversations + ai_messages 记录。
|
||||
|
||||
营业日分界点:每日 08:00(BUSINESS_DAY_START_HOUR 环境变量,默认 8)。
|
||||
|
||||
app_id = "app2_finance"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app2_finance_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app2_finance"
|
||||
|
||||
# 8 个时间维度编码
|
||||
TIME_DIMENSIONS = (
|
||||
"this_month",
|
||||
"last_month",
|
||||
"this_week",
|
||||
"last_week",
|
||||
"last_3_months",
|
||||
"this_quarter",
|
||||
"last_quarter",
|
||||
"last_6_months",
|
||||
)
|
||||
|
||||
|
||||
def get_business_date() -> date:
|
||||
"""根据营业日分界点计算当前营业日。
|
||||
|
||||
分界点前(如 07:59)视为前一天营业日,
|
||||
分界点及之后(如 08:00)视为当天营业日。
|
||||
"""
|
||||
hour = int(os.environ.get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
now = datetime.now()
|
||||
if now.hour < hour:
|
||||
return (now - timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
def compute_time_range(dimension: str, business_date: date) -> tuple[date, date]:
|
||||
"""计算时间维度对应的日期范围 [start, end](闭区间)。
|
||||
|
||||
Args:
|
||||
dimension: 时间维度编码
|
||||
business_date: 当前营业日
|
||||
|
||||
Returns:
|
||||
(start_date, end_date) 元组
|
||||
"""
|
||||
y, m, d = business_date.year, business_date.month, business_date.day
|
||||
|
||||
if dimension == "this_month":
|
||||
start = date(y, m, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_month":
|
||||
prev = _month_offset(y, m, -1)
|
||||
start = date(prev[0], prev[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_week":
|
||||
# 周一起算
|
||||
weekday = business_date.weekday() # 0=周一
|
||||
start = business_date - timedelta(days=weekday)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_week":
|
||||
weekday = business_date.weekday()
|
||||
this_monday = business_date - timedelta(days=weekday)
|
||||
last_monday = this_monday - timedelta(days=7)
|
||||
last_sunday = this_monday - timedelta(days=1)
|
||||
return last_monday, last_sunday
|
||||
|
||||
if dimension == "last_3_months":
|
||||
# 当前月 - 3 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -3)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
# end = 上月最后一天
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
start = date(y, q_start_month, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
# 上季度结束 = 本季度第一天 - 1
|
||||
this_q_start = date(y, q_start_month, 1)
|
||||
end = this_q_start - timedelta(days=1)
|
||||
# 上季度开始
|
||||
ly, lm = end.year, end.month
|
||||
lq_start_month = ((lm - 1) // 3) * 3 + 1
|
||||
start = date(ly, lq_start_month, 1)
|
||||
return start, end
|
||||
|
||||
if dimension == "last_6_months":
|
||||
# 当前月 - 6 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -6)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
raise ValueError(f"未知时间维度: {dimension}")
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App2 财务洞察调用。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, time_dimension, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(insights 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
time_dimension = context["time_dimension"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 构建 Prompt
|
||||
prompt_context = {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"current_data": context.get("current_data", {}),
|
||||
"previous_data": context.get("previous_data", {}),
|
||||
}
|
||||
messages = build_prompt(prompt_context)
|
||||
|
||||
# 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"time_dimension": time_dimension},
|
||||
)
|
||||
|
||||
# 写入 system prompt 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
# 写入 user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP2_FINANCE.value,
|
||||
site_id=site_id,
|
||||
target_id=time_dimension,
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App2 财务洞察完成: site_id=%s dimension=%s conversation_id=%s tokens=%d",
|
||||
site_id, time_dimension, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _month_offset(year: int, month: int, offset: int) -> tuple[int, int]:
|
||||
"""计算月份偏移,返回 (year, month)。"""
|
||||
# 转为 0-based 计算
|
||||
total = (year * 12 + (month - 1)) + offset
|
||||
return total // 12, total % 12 + 1
|
||||
@@ -1,263 +0,0 @@
|
||||
"""应用 3:客户数据维客线索分析(骨架)。
|
||||
|
||||
客户新增消费时自动触发,通过 AI 分析客户数据提取维客线索。
|
||||
线索 category 限定 3 个枚举值:客户基础、消费习惯、玩法偏好。
|
||||
线索提供者统一标记为"系统"。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
app_id = "app3_clue"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app3_clue"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
从 data_fetchers 获取真实消费数据,失败时降级为空值。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, nickname 等
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", "content": ...}, {"role": "user", ...}]
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 获取消费数据(失败时降级)
|
||||
data_fetch_failed = False
|
||||
try:
|
||||
member_data = await fetch_member_consumption_data(site_id, member_id)
|
||||
except Exception:
|
||||
logger.warning("App3 消费数据获取失败,使用默认空值: site_id=%s member_id=%s", site_id, member_id, exc_info=True)
|
||||
member_data = _default_member_data()
|
||||
data_fetch_failed = True
|
||||
|
||||
# 构建 reference:App6 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
member_nickname = member_data.get("member_nickname", "")
|
||||
consumption_records = member_data.get("consumption_records", [])
|
||||
|
||||
# 空数据标注
|
||||
if not consumption_records:
|
||||
if data_fetch_failed:
|
||||
consumption_records = "⚠ 消费数据获取失败,该客户暂无消费记录可供分析"
|
||||
else:
|
||||
consumption_records = "该客户暂无消费记录"
|
||||
|
||||
system_content = {
|
||||
"task": "分析客户消费数据,提取维客线索。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": ["客户基础", "消费习惯", "玩法偏好"],
|
||||
"providers": "系统",
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
},
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(客户基础/消费习惯/玩法偏好)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
]
|
||||
},
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"member_nickname": member_nickname,
|
||||
"main_data": {
|
||||
"consumption_records": consumption_records,
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
# Token 预算控制:截断 consumption_records
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["main_data"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["main_data"]["consumption_records"] = records[:5]
|
||||
system_content["main_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请分析会员 {member_id} 的消费数据,提取维客线索。"
|
||||
"每条线索包含 category、summary、detail、emoji 四个字段。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App6 备注分析线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App6 备注分析线索
|
||||
app6_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, target_id,
|
||||
)
|
||||
if app6_latest:
|
||||
reference["app6_note_clues"] = {
|
||||
"result_json": app6_latest.get("result_json"),
|
||||
"generated_at": app6_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App3 客户数据维客线索分析。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP3_CLUE.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App3 线索分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,300 +0,0 @@
|
||||
"""应用 4:关系分析/任务建议(骨架)。
|
||||
|
||||
助教参与新结算或被分配召回任务时自动触发,
|
||||
生成关系分析和任务建议。
|
||||
|
||||
Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at)。
|
||||
缓存不存在时 reference 传空对象,标注"暂无历史线索"。
|
||||
|
||||
app_id = "app4_analysis"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app4_analysis"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
并发获取助教信息、服务历史、客户消费数据、备注,部分失败不阻断。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 并发获取 4 类数据,部分失败不阻断
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 降级处理
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App4 助教信息获取失败: %s", results[0])
|
||||
assistant_info = {}
|
||||
fetch_errors.append("助教信息获取失败")
|
||||
else:
|
||||
assistant_info = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App4 服务历史获取失败: %s", results[1])
|
||||
service_history: list = []
|
||||
fetch_errors.append("服务历史获取失败")
|
||||
else:
|
||||
service_history = results[1]
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
logger.warning("App4 消费数据获取失败: %s", results[2])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
if isinstance(results[3], Exception):
|
||||
logger.warning("App4 备注获取失败: %s", results[3])
|
||||
notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes = results[3]
|
||||
|
||||
# 构建 reference:App8 最新 + 最近 2 套历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content: dict = {
|
||||
"task": "分析助教与客户的关系,生成任务建议。",
|
||||
"app_id": APP_ID,
|
||||
"output_format": {
|
||||
"task_description": "任务描述文本",
|
||||
"action_suggestions": ["建议1", "建议2"],
|
||||
"one_line_summary": "一句话总结",
|
||||
},
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
|
||||
"service_history": service_history if service_history else "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"system_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
},
|
||||
"notes": notes if notes else "暂无备注",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
# 优先截断 service_history
|
||||
sh = system_content.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
system_content["service_history"] = sh[:5]
|
||||
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["customer_data"]["notes"] = n[:10]
|
||||
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
# 缓存不存在时在 user prompt 中标注
|
||||
no_history_hint = ""
|
||||
if not reference:
|
||||
no_history_hint = "(暂无历史线索,请基于现有信息分析)"
|
||||
|
||||
user_content = (
|
||||
f"请分析助教 {assistant_id} 与会员 {member_id} 的关系,"
|
||||
f"生成任务建议。{no_history_hint}"
|
||||
"返回 task_description、action_suggestions、one_line_summary 三个字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App4 关系分析。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(task_description, action_suggestions, one_line_summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP4_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App4 关系分析完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,288 +0,0 @@
|
||||
"""应用 5:话术参考(骨架)。
|
||||
|
||||
App4 完成后自动联动触发,接收 App4 完整返回结果
|
||||
作为 Prompt 中的 task_suggestion 字段。
|
||||
|
||||
Prompt reference 包含最近 2 套 App8 历史(附 generated_at)。
|
||||
|
||||
app_id = "app5_tactics"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app5_tactics"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
复用 App4 的数据获取逻辑(并发获取助教信息、服务历史、消费数据、备注),
|
||||
额外从 context["app4_result"] 获取 task_suggestion。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id, app4_result(dict)
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
# App4 结果作为 task_suggestion,缺失时设为空对象
|
||||
task_suggestion = context.get("app4_result") or {}
|
||||
|
||||
# 并发获取 4 类数据,部分失败不阻断
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 降级处理
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App5 助教信息获取失败: %s", results[0])
|
||||
assistant_info = {}
|
||||
fetch_errors.append("助教信息获取失败")
|
||||
else:
|
||||
assistant_info = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App5 服务历史获取失败: %s", results[1])
|
||||
service_history: list = []
|
||||
fetch_errors.append("服务历史获取失败")
|
||||
else:
|
||||
service_history = results[1]
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
logger.warning("App5 消费数据获取失败: %s", results[2])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
if isinstance(results[3], Exception):
|
||||
logger.warning("App5 备注获取失败: %s", results[3])
|
||||
notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes = results[3]
|
||||
|
||||
# 构建 reference:最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content: dict = {
|
||||
"task": (
|
||||
"基于关系分析和任务建议,生成沟通话术参考。"
|
||||
"输出必须严格遵循 output_format 中定义的 JSON 结构,"
|
||||
"每条话术必须包含 scenario(场景描述)和 script(话术内容)两个字段,"
|
||||
"禁止使用 content 或其他字段名替代。"
|
||||
),
|
||||
"app_id": APP_ID,
|
||||
"task_suggestion": task_suggestion,
|
||||
"output_format": {
|
||||
"tactics": [
|
||||
{"scenario": "场景描述", "script": "话术内容"}
|
||||
]
|
||||
},
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
|
||||
"service_history": service_history if service_history else "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"system_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
},
|
||||
"notes": notes if notes else "暂无备注",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
sh = system_content.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
system_content["service_history"] = sh[:5]
|
||||
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["customer_data"]["notes"] = n[:10]
|
||||
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。"
|
||||
"返回 tactics 数组,每条包含 scenario 和 script 字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含最近 2 套 App8 历史(附 generated_at)。
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App5 话术参考。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id, app4_result(dict)
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(tactics 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP5_TACTICS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App5 话术参考完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,289 +0,0 @@
|
||||
"""应用 6:备注分析(骨架)。
|
||||
|
||||
助教提交备注后自动触发,通过 AI 分析备注内容,
|
||||
提取维客线索并评分。
|
||||
|
||||
返回 score(1-10)+ clues 数组。
|
||||
评分规则:6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分。
|
||||
线索 category 限定 6 个枚举值。
|
||||
线索提供者标记为当前备注提供人(context.noted_by_name)。
|
||||
|
||||
app_id = "app6_note"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app6_note"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
并发获取消费数据和备注,失败时降级为空值。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, note_content, noted_by_name
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
note_content = context.get("note_content", "")
|
||||
noted_by_name = context.get("noted_by_name", "")
|
||||
noted_by_created_at = context.get("noted_by_created_at", "")
|
||||
|
||||
# 并发获取消费数据和备注
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App6 消费数据获取失败: %s", results[0])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App6 备注获取失败: %s", results[1])
|
||||
all_notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
all_notes = results[1]
|
||||
|
||||
# 构建 reference:App3 线索 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
# 将消费数据和备注注入 reference
|
||||
reference["member_nickname"] = member_data.get("member_nickname", "")
|
||||
reference["consumption_data"] = {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
}
|
||||
reference["all_notes"] = all_notes if all_notes else []
|
||||
|
||||
system_content: dict = {
|
||||
"task": "分析备注内容,提取维客线索并评分。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"providers": noted_by_name,
|
||||
"scoring": "6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分",
|
||||
"score_range": "1-10",
|
||||
},
|
||||
"output_format": {
|
||||
"score": "1-10 整数",
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
],
|
||||
},
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"current_note": {
|
||||
"content": note_content,
|
||||
"recorded_by": noted_by_name,
|
||||
"created_at": noted_by_created_at,
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["reference"].get("consumption_data", {}).get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["reference"]["consumption_data"]["consumption_records"] = records[:5]
|
||||
system_content["reference"]["consumption_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["reference"].get("all_notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["reference"]["all_notes"] = n[:10]
|
||||
system_content["reference"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请分析以下备注内容,提取维客线索并评分。\n"
|
||||
f"备注提供人:{noted_by_name}\n"
|
||||
f"备注内容:{note_content}\n"
|
||||
"返回 score(1-10 整数)和 clues 数组。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App3 客户数据线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App3 客户数据线索
|
||||
app3_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, target_id,
|
||||
)
|
||||
if app3_latest:
|
||||
reference["app3_clues"] = {
|
||||
"result_json": app3_latest.get("result_json"),
|
||||
"generated_at": app3_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App6 备注分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, note_content, noted_by_name
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(score + clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(score 存入 ai_cache.score)
|
||||
score = result.get("score")
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP6_NOTE_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
score=score,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App6 备注分析完成: site_id=%s member_id=%s score=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, score, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,282 +0,0 @@
|
||||
"""应用 7:客户分析(骨架)。
|
||||
|
||||
消费事件链中 App8 完成后串行触发,生成客户全量分析与运营建议。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】。
|
||||
|
||||
app_id = "app7_customer"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app7_customer"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
并发获取消费数据和备注,备注标注来源信息。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 并发获取消费数据和备注
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App7 消费数据获取失败: %s", results[0])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App7 备注获取失败: %s", results[1])
|
||||
notes_raw: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes_raw = results[1]
|
||||
|
||||
# 备注标注来源信息
|
||||
if notes_raw:
|
||||
subjective_notes = []
|
||||
for note in notes_raw:
|
||||
recorded_by = note.get("recorded_by", "未知")
|
||||
annotated = dict(note)
|
||||
annotated["content"] = f"{note.get('content', '')}【来源:{recorded_by},请甄别信息真实性】"
|
||||
subjective_notes.append(annotated)
|
||||
else:
|
||||
subjective_notes = "该客户暂无主观备注信息"
|
||||
|
||||
member_nickname = member_data.get("member_nickname", "")
|
||||
|
||||
# 构建 reference:最新 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content: dict = {
|
||||
"task": "综合分析客户数据,生成运营策略建议。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
"subjective_info_label": "对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】",
|
||||
},
|
||||
"output_format": {
|
||||
"strategies": [
|
||||
{"title": "策略标题", "content": "策略内容"}
|
||||
],
|
||||
"summary": "一句话总结",
|
||||
},
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"member_id": member_id,
|
||||
"member_nickname": member_nickname,
|
||||
"objective_data": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"subjective_data": {
|
||||
"notes": subjective_notes,
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["objective_data"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["objective_data"]["consumption_records"] = records[:5]
|
||||
system_content["objective_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["subjective_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["subjective_data"]["notes"] = n[:10]
|
||||
system_content["subjective_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。"
|
||||
"返回 strategies 数组(每条含 title 和 content)和 summary 字段。"
|
||||
"对来自备注的主观信息,请标注【来源:XXX,请甄别信息真实性】。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App7 客户分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(strategies 数组 + summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App7 客户分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,211 +0,0 @@
|
||||
"""应用 8:维客线索整理。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的线索,
|
||||
通过百炼 AI 整合去重,然后全量替换写入 member_retention_clue 表。
|
||||
|
||||
app_id = "app8_consolidation"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app8_consolidation_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app8_consolidation"
|
||||
|
||||
|
||||
class ClueWriter:
|
||||
"""维客线索全量替换写入器。
|
||||
|
||||
DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)。
|
||||
人工线索(source='manual')不受影响。
|
||||
"""
|
||||
|
||||
def replace_ai_clues(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
clues: list[dict],
|
||||
) -> int:
|
||||
"""全量替换该客户的 AI 来源线索,返回写入数量。
|
||||
|
||||
在单个事务中执行 DELETE + INSERT,失败时回滚保留原有线索。
|
||||
|
||||
字段映射:
|
||||
- category → category
|
||||
- emoji + " " + summary → summary(如 "📅 偏好周末下午时段消费")
|
||||
- detail → detail
|
||||
- providers → recorded_by_name
|
||||
- source: 根据 providers 判断(见 _determine_source)
|
||||
- recorded_by_assistant_id: NULL(系统触发)
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 删除该客户所有 AI 来源线索
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
AND source IN ('ai_consumption', 'ai_note')
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
|
||||
# 2. 插入新线索
|
||||
for clue in clues:
|
||||
emoji = clue.get("emoji", "")
|
||||
raw_summary = clue.get("summary", "")
|
||||
summary = f"{emoji} {raw_summary}" if emoji else raw_summary
|
||||
source = _determine_source(clue.get("providers", ""))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO member_retention_clue
|
||||
(member_id, site_id, category, summary, detail,
|
||||
source, recorded_by_name, recorded_by_assistant_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NULL)
|
||||
""",
|
||||
(
|
||||
member_id,
|
||||
site_id,
|
||||
clue.get("category", ""),
|
||||
summary,
|
||||
clue.get("detail", ""),
|
||||
source,
|
||||
clue.get("providers", ""),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return len(clues)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _determine_source(providers: str) -> str:
|
||||
"""根据 providers 判断 source 值。
|
||||
|
||||
- 纯 App3(providers 仅含"系统")→ ai_consumption
|
||||
- 纯 App6(providers 不含"系统")→ ai_note
|
||||
- 混合来源 → ai_consumption
|
||||
"""
|
||||
if not providers:
|
||||
return "ai_consumption"
|
||||
provider_list = [p.strip() for p in providers.split(",")]
|
||||
has_system = "系统" in provider_list
|
||||
has_human = any(p != "系统" for p in provider_list if p)
|
||||
if has_system and not has_human:
|
||||
# 纯 App3(系统自动分析)
|
||||
return "ai_consumption"
|
||||
elif has_human and not has_system:
|
||||
# 纯 App6(人工备注分析)
|
||||
return "ai_note"
|
||||
else:
|
||||
# 混合来源
|
||||
return "ai_consumption"
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App8 维客线索整理。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. ClueWriter 全量替换 member_retention_clue
|
||||
6. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, app3_clues, app6_clues,
|
||||
app3_generated_at, app6_generated_at
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
# 6. 全量替换 member_retention_clue
|
||||
clues = result.get("clues", [])
|
||||
if clues:
|
||||
writer = ClueWriter()
|
||||
written = writer.replace_ai_clues(member_id, site_id, clues)
|
||||
logger.info(
|
||||
"App8 线索写入完成: site_id=%s member_id=%s written=%d",
|
||||
site_id, member_id, written,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App8 线索整理完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -18,6 +18,12 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.database import get_connection
|
||||
from app.services.runtime_context import (
|
||||
LIVE_INSTANCE_ID,
|
||||
MODE_LIVE,
|
||||
MODE_SANDBOX,
|
||||
get_runtime_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +45,14 @@ CACHE_MAX_PER_APP = 20_000
|
||||
class AICacheService:
|
||||
"""AI 缓存读写服务。"""
|
||||
|
||||
@staticmethod
|
||||
def _runtime_scope(site_id: int, target_id: str, conn) -> tuple[str, str, str]:
|
||||
"""返回运行模式、实例 ID 和实际 cache target_id。"""
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
if ctx.is_sandbox and ctx.sandbox_instance_id:
|
||||
return MODE_SANDBOX, ctx.sandbox_instance_id, f"{ctx.sandbox_instance_id}:{target_id}"
|
||||
return MODE_LIVE, LIVE_INSTANCE_ID, target_id
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
cache_type: str,
|
||||
@@ -52,6 +66,9 @@ class AICacheService:
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
runtime_mode, sandbox_instance_id, scoped_target_id = self._runtime_scope(
|
||||
site_id, target_id, conn
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -60,12 +77,14 @@ class AICacheService:
|
||||
created_at, expires_at, status
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
AND COALESCE(runtime_mode, 'live') = %s
|
||||
AND COALESCE(sandbox_instance_id, 'live') = %s
|
||||
AND (status = 'valid' OR status IS NULL)
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(cache_type, site_id, target_id),
|
||||
(cache_type, site_id, scoped_target_id, runtime_mode, sandbox_instance_id),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
row = cur.fetchone()
|
||||
@@ -88,6 +107,9 @@ class AICacheService:
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
runtime_mode, sandbox_instance_id, scoped_target_id = self._runtime_scope(
|
||||
site_id, target_id, conn
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -96,10 +118,12 @@ class AICacheService:
|
||||
created_at, expires_at
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
AND COALESCE(runtime_mode, 'live') = %s
|
||||
AND COALESCE(sandbox_instance_id, 'live') = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(cache_type, site_id, target_id, limit),
|
||||
(cache_type, site_id, scoped_target_id, runtime_mode, sandbox_instance_id, limit),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
@@ -128,23 +152,29 @@ class AICacheService:
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
runtime_mode, sandbox_instance_id, scoped_target_id = self._runtime_scope(
|
||||
site_id, target_id, conn
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_cache
|
||||
(cache_type, site_id, target_id, result_json,
|
||||
triggered_by, score, expires_at, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'valid')
|
||||
triggered_by, score, expires_at, status,
|
||||
runtime_mode, sandbox_instance_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'valid', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
cache_type,
|
||||
site_id,
|
||||
target_id,
|
||||
scoped_target_id,
|
||||
json.dumps(result_json, ensure_ascii=False),
|
||||
triggered_by,
|
||||
score,
|
||||
expires_at,
|
||||
runtime_mode,
|
||||
sandbox_instance_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -158,7 +188,7 @@ class AICacheService:
|
||||
|
||||
# 写入成功后清理超限记录
|
||||
try:
|
||||
deleted = self._cleanup_excess(cache_type, site_id, target_id)
|
||||
deleted = self._cleanup_excess(cache_type, site_id, scoped_target_id)
|
||||
if deleted > 0:
|
||||
logger.info(
|
||||
"清理超限缓存: cache_type=%s site_id=%s target_id=%s 删除=%d",
|
||||
@@ -183,15 +213,19 @@ class AICacheService:
|
||||
"""写入 generating 状态占位记录,返回 id。完成后调用 finalize_cache 更新。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
runtime_mode, sandbox_instance_id, scoped_target_id = self._runtime_scope(
|
||||
site_id, target_id, conn
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_cache
|
||||
(cache_type, site_id, target_id, result_json, status, triggered_by)
|
||||
VALUES (%s, %s, %s, '{}', 'generating', %s)
|
||||
(cache_type, site_id, target_id, result_json, status, triggered_by,
|
||||
runtime_mode, sandbox_instance_id)
|
||||
VALUES (%s, %s, %s, '{}', 'generating', %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(cache_type, site_id, target_id, triggered_by),
|
||||
(cache_type, site_id, scoped_target_id, triggered_by, runtime_mode, sandbox_instance_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
@@ -28,6 +28,44 @@ from app.ai.exceptions import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _field_value(source: Any, key: str, default: Any = None) -> Any:
|
||||
"""兼容 dict、DashScope DictMixin 和普通对象取字段。"""
|
||||
if isinstance(source, dict):
|
||||
return source.get(key, default)
|
||||
return getattr(source, key, default)
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
"""把 token 字段安全转换为 int,异常值按 0 处理。"""
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_tokens_used(usage: Any) -> int:
|
||||
"""从 DashScope usage 多种结构中提取 tokens_used。"""
|
||||
if not usage:
|
||||
return 0
|
||||
|
||||
models = _field_value(usage, "models")
|
||||
if models:
|
||||
total = 0
|
||||
for model_usage in models:
|
||||
total += _safe_int(_field_value(model_usage, "input_tokens"))
|
||||
total += _safe_int(_field_value(model_usage, "output_tokens"))
|
||||
return total
|
||||
|
||||
total_tokens = _field_value(usage, "total_tokens")
|
||||
if total_tokens is not None:
|
||||
return _safe_int(total_tokens)
|
||||
|
||||
return (
|
||||
_safe_int(_field_value(usage, "input_tokens"))
|
||||
+ _safe_int(_field_value(usage, "output_tokens"))
|
||||
)
|
||||
|
||||
|
||||
class DashScopeClient:
|
||||
"""DashScope Application API 统一封装层。
|
||||
|
||||
@@ -54,22 +92,28 @@ class DashScopeClient:
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
biz_params: dict | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""App1 流式调用。
|
||||
) -> AsyncGenerator[tuple[str, str | None], None]:
|
||||
"""App1 流式调用,支持 multi-turn session_id 透传。
|
||||
|
||||
在线程中消费同步迭代器,通过 asyncio.Queue 桥接到 async generator。
|
||||
错误通过 queue 传递给调用方。
|
||||
每个 yield 返回 (text_chunk, session_id_or_none) 元组:
|
||||
- 首次调用(传入 session_id=None)时,百炼在流中会返回新 session_id,
|
||||
应由调用方在流结束后回写 DB。
|
||||
- 后续调用传入 DB 中的 session_id 后,百炼自动关联历史上下文,
|
||||
返回的 session_id 通常一致。
|
||||
|
||||
Args:
|
||||
app_id: 百炼应用 ID
|
||||
prompt: 用户输入
|
||||
session_id: 百炼 session_id(多轮对话)
|
||||
session_id: 百炼 session_id;首次对话传 None
|
||||
biz_params: 业务参数(如 user_prompt_params)
|
||||
|
||||
Yields:
|
||||
文本 chunk
|
||||
(text_chunk, session_id_or_none) 元组。
|
||||
text_chunk 为空字符串时(例如仅承载 session_id 的心跳 chunk),
|
||||
调用方应忽略文本但保留 session_id。
|
||||
"""
|
||||
queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue()
|
||||
queue: asyncio.Queue[tuple[str, str | None] | BaseException | None] = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _consume_in_thread() -> None:
|
||||
@@ -91,10 +135,17 @@ class DashScopeClient:
|
||||
response = Application.call(**call_kwargs)
|
||||
for chunk in response:
|
||||
if chunk.status_code == 200:
|
||||
text = chunk.output.get("text", "")
|
||||
if text:
|
||||
output = chunk.output if hasattr(chunk, "output") else {}
|
||||
if isinstance(output, dict):
|
||||
text = output.get("text", "") or ""
|
||||
new_sid = output.get("session_id")
|
||||
else:
|
||||
text = getattr(output, "text", "") or ""
|
||||
new_sid = getattr(output, "session_id", None)
|
||||
# 文本或 session_id 任一非空都推入(心跳 chunk 也传出 session_id)
|
||||
if text or new_sid:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
queue.put(text), loop
|
||||
queue.put((text, new_sid)), loop
|
||||
)
|
||||
else:
|
||||
# 非 200 状态码,构造异常传递给调用方
|
||||
@@ -180,16 +231,12 @@ class DashScopeClient:
|
||||
raw_text = output.text or ""
|
||||
|
||||
# 提取 tokens_used
|
||||
# DashScope Application.call() 返回的 usage 实际结构(2026-04 验证):
|
||||
# ApplicationUsage(models=[ApplicationModelUsage(model_id, input_tokens, output_tokens)])
|
||||
# 旧代码只处理 dict / total_tokens 两种分支,导致该嵌套结构下 tokens_used 恒为 0
|
||||
tokens_used = 0
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage = response.usage
|
||||
if isinstance(usage, dict):
|
||||
# input_tokens + output_tokens
|
||||
tokens_used = usage.get("input_tokens", 0) + usage.get(
|
||||
"output_tokens", 0
|
||||
)
|
||||
elif hasattr(usage, "total_tokens"):
|
||||
tokens_used = usage.total_tokens or 0
|
||||
tokens_used = _extract_tokens_used(response.usage)
|
||||
|
||||
# 提取 new_session_id
|
||||
new_session_id: str | None = None
|
||||
|
||||
@@ -58,10 +58,16 @@ def _fetch_assistant_info_sync(site_id: int, assistant_id: int) -> dict[str, Any
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
# CHANGE 2026-05-02 | 同时下发 app.current_business_date,供 RLS 视图业务日上界裁剪
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today
|
||||
_ref_date = _rt_today(site_id)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_business_date = %s", (_ref_date.isoformat(),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
@@ -86,11 +92,12 @@ def _fetch_assistant_info_sync(site_id: int, assistant_id: int) -> dict[str, Any
|
||||
level = row[1] or ""
|
||||
hire_date = row[2]
|
||||
|
||||
# 计算工龄
|
||||
# 计算工龄(CHANGE 2026-05-02 | 用 business_date 替代 today,沙箱按当时工龄)
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref_date = as_runtime_today_param(site_id)
|
||||
tenure_months = 0
|
||||
if hire_date and isinstance(hire_date, date):
|
||||
today = date.today()
|
||||
tenure_months = (today.year - hire_date.year) * 12 + (today.month - hire_date.month)
|
||||
tenure_months = (ref_date.year - hire_date.year) * 12 + (ref_date.month - hire_date.month)
|
||||
|
||||
# 绩效数据
|
||||
# ⚠️ 列名映射: monthly_customers 不存在(用 0 占位),performance_tier→tier_name
|
||||
@@ -184,10 +191,16 @@ def _fetch_service_history_sync(
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
# CHANGE 2026-05-02 | 同时下发 app.current_business_date,供 RLS 视图业务日上界裁剪
|
||||
from app.services.runtime_context import as_runtime_today_param as _rt_today2
|
||||
_ref_date_outer = _rt_today2(site_id)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_business_date = %s", (_ref_date_outer.isoformat(),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
@@ -197,6 +210,9 @@ def _fetch_service_history_sync(
|
||||
# is_trash=false→is_delete=0, service_date→create_time,
|
||||
# duration_minutes→real_use_seconds/60, items_sum→ledger_amount,
|
||||
# room_name→site_table_id, is_pd→(order_assistant_type=1)
|
||||
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE,沙箱不读「未来」服务记录
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
ref_date = as_runtime_today_param(site_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -209,10 +225,11 @@ def _fetch_service_history_sync(
|
||||
WHERE site_assistant_id = %s
|
||||
AND tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
AND create_time >= (%s::date - (INTERVAL '1 month' * %s))
|
||||
AND create_time < (%s::date + INTERVAL '1 day')
|
||||
ORDER BY create_time DESC
|
||||
""",
|
||||
(assistant_id, member_id, months),
|
||||
(assistant_id, member_id, ref_date, months, ref_date),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
|
||||
@@ -63,16 +63,27 @@ def _fetch_member_consumption_data_sync(
|
||||
member_id: int,
|
||||
months: int,
|
||||
) -> dict[str, Any]:
|
||||
"""同步实现:在单个 FDW 连接上串行执行多个查询。"""
|
||||
"""同步实现:在单个 FDW 连接上串行执行多个查询。
|
||||
|
||||
CHANGE 2026-05-02 | 所有窗口查询都按业务日上界裁剪,
|
||||
sandbox 模式下不再读取 sandbox_date 之后的真实消费 / 到店。
|
||||
"""
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
ref_date = as_runtime_today_param(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
# CHANGE 2026-05-02 | 同时下发 app.current_business_date,供 RLS 视图业务日上界裁剪
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_business_date = %s", (ref_date.isoformat(),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",), # 毫秒
|
||||
@@ -82,7 +93,7 @@ def _fetch_member_consumption_data_sync(
|
||||
nickname = _query_member_nickname(conn, member_id)
|
||||
|
||||
# 2. 消费记录(台桌结账 + 商城订单)
|
||||
records, total_count = _query_consumption_records(conn, member_id, months)
|
||||
records, total_count = _query_consumption_records(conn, member_id, months, ref_date)
|
||||
|
||||
# 3. 会员卡明细
|
||||
cards = _query_member_cards(conn, member_id)
|
||||
@@ -91,7 +102,7 @@ def _fetch_member_consumption_data_sync(
|
||||
balance_info = _query_balance_summary(conn, member_id)
|
||||
|
||||
# 5. 到店数据
|
||||
visit_info = _query_visit_info(conn, member_id)
|
||||
visit_info = _query_visit_info(conn, member_id, ref_date)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"member_nickname": nickname,
|
||||
@@ -145,7 +156,7 @@ def _query_member_nickname(conn: Any, member_id: int) -> str:
|
||||
|
||||
|
||||
def _query_consumption_records(
|
||||
conn: Any, member_id: int, months: int
|
||||
conn: Any, member_id: int, months: int, ref_date: date
|
||||
) -> tuple[list[dict], int]:
|
||||
"""从 app.v_dwd_settlement_head + app.v_dwd_table_fee_log 获取消费记录。
|
||||
|
||||
@@ -153,6 +164,7 @@ def _query_consumption_records(
|
||||
⚠️ 费用拆分字段(table_charge_money, assistant_pd/cx_money)在 settlement_head 上。
|
||||
⚠️ table_fee_log 提供台桌时长(real_table_use_seconds)和桌台ID(site_table_id)。
|
||||
⚠️ 列名映射: settle_date→create_time, settle_id→order_settle_id, sale_amount→ledger_amount。
|
||||
CHANGE 2026-05-02 | 用 ref_date(业务日)替代 CURRENT_DATE,沙箱不读「未来」消费。
|
||||
返回 (records, total_count)。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
@@ -163,9 +175,10 @@ def _query_consumption_records(
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
AND sh.create_time >= (%s::date - (INTERVAL '1 month' * %s))
|
||||
AND sh.create_time < (%s::date + INTERVAL '1 day')
|
||||
""",
|
||||
(member_id, months),
|
||||
(member_id, ref_date, months, ref_date),
|
||||
)
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
@@ -208,11 +221,12 @@ def _query_consumption_records(
|
||||
) coaches ON true
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
AND sh.create_time >= (%s::date - (INTERVAL '1 month' * %s))
|
||||
AND sh.create_time < (%s::date + INTERVAL '1 day')
|
||||
ORDER BY sh.create_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(member_id, months, MAX_CONSUMPTION_RECORDS),
|
||||
(member_id, ref_date, months, ref_date, MAX_CONSUMPTION_RECORDS),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
@@ -294,9 +308,10 @@ def _query_balance_summary(conn: Any, member_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
def _query_visit_info(conn: Any, member_id: int, ref_date: date) -> dict:
|
||||
"""从 app.v_dws_member_visit_detail 获取到店数据,推算预计到店日期。
|
||||
⚠️ 列名映射: last_visit_date→MAX(visit_date), avg_visit_interval_days 需从明细计算。
|
||||
CHANGE 2026-05-02 | 仅取 ref_date 及之前的到店明细,days_since 按 ref_date 计算。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
# 获取最近到店日期和平均到店间隔
|
||||
@@ -307,6 +322,7 @@ def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
LAG(visit_date) OVER (ORDER BY visit_date) AS prev_visit
|
||||
FROM app.v_dws_member_visit_detail
|
||||
WHERE member_id = %s
|
||||
AND visit_date <= %s
|
||||
)
|
||||
SELECT
|
||||
MAX(visit_date) AS last_visit_date,
|
||||
@@ -314,7 +330,7 @@ def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
FROM visits
|
||||
WHERE prev_visit IS NOT NULL
|
||||
""",
|
||||
(member_id,),
|
||||
(member_id, ref_date),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
@@ -323,8 +339,7 @@ def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
|
||||
last_visit = row[0]
|
||||
avg_interval = row[1]
|
||||
today = date.today()
|
||||
days_since = (today - last_visit).days if isinstance(last_visit, date) else None
|
||||
days_since = (ref_date - last_visit).days if isinstance(last_visit, date) else None
|
||||
|
||||
expected = None
|
||||
if avg_interval and last_visit:
|
||||
|
||||
@@ -352,7 +352,9 @@ def _text_board_finance(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# 简化查询:获取汇总数据
|
||||
# 简化查询:获取汇总数据(CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE)
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -361,8 +363,10 @@ def _text_board_finance(
|
||||
COALESCE(AVG(items_sum), 0) AS avg_revenue
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE settle_type IN (1, 3)
|
||||
AND settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
AND settle_date >= (%s::date - INTERVAL '1 month')
|
||||
AND settle_date <= %s::date
|
||||
""",
|
||||
(_ref_date, _ref_date),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
@@ -399,7 +403,9 @@ def _text_board_customer(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# Top 10 客户
|
||||
# Top 10 客户(CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE)
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -410,11 +416,13 @@ def _text_board_customer(
|
||||
ON dm.member_id = sh.member_id AND dm.scd2_is_current = 1
|
||||
WHERE sh.settle_type IN (1, 3)
|
||||
AND sh.member_id > 0
|
||||
AND sh.settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
AND sh.settle_date >= (%s::date - INTERVAL '1 month')
|
||||
AND sh.settle_date <= %s::date
|
||||
GROUP BY dm.nickname
|
||||
ORDER BY total_consumption DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(_ref_date, _ref_date),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
@@ -452,6 +460,9 @@ def _text_board_coach(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -462,11 +473,13 @@ def _text_board_coach(
|
||||
JOIN app.v_dim_assistant da
|
||||
ON da.assistant_id = sl.site_assistant_id
|
||||
WHERE sl.is_delete = 0
|
||||
AND sl.create_time >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
AND sl.create_time >= (%s::date - INTERVAL '1 month')
|
||||
AND sl.create_time < (%s::date + INTERVAL '1 day')
|
||||
GROUP BY da.nickname
|
||||
ORDER BY service_count DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(_ref_date, _ref_date),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
@@ -590,6 +603,9 @@ def _text_customer_service_records(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# CHANGE 2026-05-02 | 仅取业务日及之前的服务记录,沙箱不读「未来」
|
||||
from app.services.runtime_context import as_runtime_today_param
|
||||
_ref_date = as_runtime_today_param(site_id)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -599,10 +615,11 @@ def _text_customer_service_records(
|
||||
site_table_id
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s AND is_delete = 0
|
||||
AND create_time < (%s::date + INTERVAL '1 day')
|
||||
ORDER BY create_time DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(member_id,),
|
||||
(member_id, _ref_date),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
@@ -207,6 +207,25 @@ class AIDispatcher:
|
||||
|
||||
# 内存 trigger_job 计数器(DB 迁移完成后改为 INSERT RETURNING id)
|
||||
self._next_job_id = 1
|
||||
self._running_tasks: dict[int, asyncio.Task] = {}
|
||||
self._running_task_sites: dict[int, int] = {}
|
||||
|
||||
def _forget_running_task(self, job_id: int) -> None:
|
||||
self._running_tasks.pop(job_id, None)
|
||||
self._running_task_sites.pop(job_id, None)
|
||||
|
||||
def cancel_running(self, site_id: int) -> int:
|
||||
"""取消当前进程内指定门店未完成的 AI 调用链。"""
|
||||
cancelled = 0
|
||||
for job_id, task in list(self._running_tasks.items()):
|
||||
if self._running_task_sites.get(job_id) != site_id:
|
||||
continue
|
||||
if task.done():
|
||||
self._forget_running_task(job_id)
|
||||
continue
|
||||
task.cancel()
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
# ── 统一事件入口 ─────────────────────────────────────
|
||||
|
||||
@@ -242,7 +261,10 @@ class AIDispatcher:
|
||||
self._dedup_set.add(dedup_key)
|
||||
|
||||
# 后台异步执行调用链,不阻塞返回
|
||||
asyncio.create_task(self._execute_chain(job_id, event))
|
||||
task = asyncio.create_task(self._execute_chain(job_id, event))
|
||||
self._running_tasks[job_id] = task
|
||||
self._running_task_sites[job_id] = event.site_id
|
||||
task.add_done_callback(lambda _task, _job_id=job_id: self._forget_running_task(_job_id))
|
||||
return job_id
|
||||
|
||||
# ── 调用链分发 ───────────────────────────────────────
|
||||
@@ -278,6 +300,10 @@ class AIDispatcher:
|
||||
await asyncio.wait_for(handler(event), timeout=chain_timeout)
|
||||
logger.info("调用链完成: job_id=%d event_type=%s", job_id, event.event_type)
|
||||
_update_trigger_job_status(job_id, "completed", set_finished=True)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("调用链已取消: job_id=%d event_type=%s", job_id, event.event_type)
|
||||
_update_trigger_job_status(job_id, "cancelled", error_message="业务运行上下文切换取消", set_finished=True)
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("调用链超时: job_id=%d event_type=%s", job_id, event.event_type)
|
||||
_update_trigger_job_status(job_id, "failed", error_message="调用链超时", set_finished=True)
|
||||
|
||||
123
apps/backend/app/ai/event_bus.py
Normal file
123
apps/backend/app/ai/event_bus.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""AI 事件广播总线(in-process pub/sub)。
|
||||
|
||||
支持按 site_id 订阅的异步事件分发,用于:
|
||||
- Phase 1.4:AI 缓存主动失效 / 更新通知 → admin-web、小程序刷新
|
||||
- Phase 3.1:AI 告警实时推送(告警发生 / 确认 / 忽略)
|
||||
|
||||
设计要点:
|
||||
- 仿 TaskExecutor.subscribe/unsubscribe 模式(单进程共享)
|
||||
- 每个订阅者独立 asyncio.Queue,互不干扰
|
||||
- 订阅必须指定 site_id(全局订阅需显式 site_id=None)
|
||||
- publish 异步写入所有订阅者 queue;端点侧通过 get() 消费
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIEvent:
|
||||
"""统一事件结构。
|
||||
|
||||
type 示例:
|
||||
- cache_updated — 新缓存写入
|
||||
- cache_invalidated — 缓存主动失效
|
||||
- alert_created — 新告警(Phase 3.1)
|
||||
- alert_updated — 告警状态变更(Phase 3.1)
|
||||
"""
|
||||
|
||||
type: str
|
||||
site_id: int | None
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""单进程事件广播总线。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# {site_id | None: [queue, ...]} None 表示全局订阅(收所有 site 事件)
|
||||
self._subscribers: dict[int | None, list[asyncio.Queue[AIEvent | None]]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(self, site_id: int | None) -> asyncio.Queue[AIEvent | None]:
|
||||
"""订阅事件流,返回独立 asyncio.Queue。
|
||||
|
||||
site_id=None 表示订阅全部门店事件(admin-web 全局监控用)。
|
||||
site_id=<int> 表示仅订阅该门店事件(小程序或单门店后台)。
|
||||
|
||||
unsubscribe 时需将返回的 queue 作为参数传入。
|
||||
"""
|
||||
queue: asyncio.Queue[AIEvent | None] = asyncio.Queue()
|
||||
async with self._lock:
|
||||
self._subscribers.setdefault(site_id, []).append(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(
|
||||
self, site_id: int | None, queue: asyncio.Queue[AIEvent | None]
|
||||
) -> None:
|
||||
"""解除订阅,从订阅者列表移除 queue。"""
|
||||
async with self._lock:
|
||||
subs = self._subscribers.get(site_id, [])
|
||||
try:
|
||||
subs.remove(queue)
|
||||
except ValueError:
|
||||
pass
|
||||
if not subs:
|
||||
self._subscribers.pop(site_id, None)
|
||||
|
||||
def publish(self, event: AIEvent) -> int:
|
||||
"""同步 publish 事件,返回送达的订阅者数。
|
||||
|
||||
可从任意线程 / sync 上下文调用(如 dispatcher._write_cache)。
|
||||
内部使用 run_coroutine_threadsafe 线程安全写入 queue。
|
||||
"""
|
||||
targets = self._collect_targets(event.site_id)
|
||||
sent = 0
|
||||
for queue in targets:
|
||||
try:
|
||||
# 优先同步调用 put_nowait(最常见:同一 running loop)
|
||||
queue.put_nowait(event)
|
||||
sent += 1
|
||||
except RuntimeError:
|
||||
# 无 running loop 场景极少,跳过
|
||||
logger.debug("publish 无 running loop:跳过 queue")
|
||||
return sent
|
||||
|
||||
def _collect_targets(self, site_id: int | None) -> list[asyncio.Queue[AIEvent | None]]:
|
||||
"""收集要推送的订阅者列表:该 site_id 的订阅者 + 全局订阅者。"""
|
||||
targets: list[asyncio.Queue[AIEvent | None]] = []
|
||||
if site_id is not None:
|
||||
targets.extend(self._subscribers.get(site_id, []))
|
||||
targets.extend(self._subscribers.get(None, []))
|
||||
return targets
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""结束时给所有订阅者发哨兵 None,通知连接关闭。"""
|
||||
async with self._lock:
|
||||
all_queues = [q for subs in self._subscribers.values() for q in subs]
|
||||
self._subscribers.clear()
|
||||
for q in all_queues:
|
||||
try:
|
||||
q.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 单例 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
_bus: EventBus | None = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""获取全局 EventBus 单例。进程启动时按需创建。"""
|
||||
global _bus
|
||||
if _bus is None:
|
||||
_bus = EventBus()
|
||||
return _bus
|
||||
@@ -1,145 +1,873 @@
|
||||
"""应用 2 财务洞察 Prompt 模板。
|
||||
"""应用 2 财务洞察 Prompt 拼装。
|
||||
|
||||
构建包含当期和上期收入结构的完整 Prompt,供百炼 API 生成财务洞察。
|
||||
cron 每日 10:00 预热触发,对所有筛选组合(时间 × 区域)生成洞察。
|
||||
- 数据源:board_service.get_finance_board(time, area, compare=1, site_id)
|
||||
- 筛选维度:8 个时间维度 × 9 个区域 = 72 组合
|
||||
- 输出字段:insights 数组(seq + title + body)
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
收入字段映射(严格遵守 items_sum 口径):
|
||||
- table_fee = table_charge_money(台费)
|
||||
- assistant_pd = assistant_pd_money(陪打费)
|
||||
- assistant_cx = assistant_cx_money(超休费)
|
||||
- goods = goods_money(商品收入)
|
||||
- recharge = 充值 pay_amount settle_type=5(充值收入)
|
||||
|
||||
禁止使用 consume_money,统一使用:
|
||||
items_sum = table_charge_money + goods_money + assistant_pd_money
|
||||
+ assistant_cx_money + electricity_money
|
||||
Prompt 中 board_data 字段名会自动翻译为中文(KEY_TRANSLATIONS),
|
||||
目的:减少 AI 理解英文变量的成本,生成的洞察正文可读性更强。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.board_service import get_finance_board, _calc_date_range, _calc_prev_range
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App2 财务洞察 Prompt 消息列表。
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int,门店 ID
|
||||
- time_dimension: str,时间维度编码
|
||||
- current_data: dict,当期数据
|
||||
- previous_data: dict,上期数据
|
||||
# App2 时间维度 → board_service 时间枚举
|
||||
DIMENSION_MAP: dict[str, str] = {
|
||||
"this_month": "month",
|
||||
"last_month": "lastMonth",
|
||||
"this_week": "week",
|
||||
"last_week": "lastWeek",
|
||||
"this_quarter": "quarter",
|
||||
"last_quarter": "lastQuarter",
|
||||
"last_3_months": "last_3m",
|
||||
"last_6_months": "last_6m",
|
||||
}
|
||||
|
||||
Returns:
|
||||
messages 列表(system + user),供 BailianClient.chat_json 调用
|
||||
"""
|
||||
site_id = context.get("site_id", 0)
|
||||
time_dimension = context.get("time_dimension", "")
|
||||
current_data = context.get("current_data", {})
|
||||
previous_data = context.get("previous_data", {})
|
||||
|
||||
system_content = _build_system_content(
|
||||
site_id=site_id,
|
||||
time_dimension=time_dimension,
|
||||
current_data=current_data,
|
||||
previous_data=previous_data,
|
||||
)
|
||||
|
||||
user_content = (
|
||||
f"请根据以上数据,为门店 {site_id} 生成 {_dimension_label(time_dimension)} 的财务洞察分析。"
|
||||
"以 JSON 格式返回,包含 insights 数组,每项含 seq(序号)、title(标题)、body(正文)。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_content(
|
||||
*,
|
||||
site_id: int,
|
||||
time_dimension: str,
|
||||
current_data: dict,
|
||||
previous_data: dict,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON 结构。"""
|
||||
return {
|
||||
"task": (
|
||||
"你是台球门店的财务分析 AI 助手。"
|
||||
"根据提供的当期和上期经营数据,生成结构化的财务洞察。"
|
||||
"分析维度包括:收入结构变化、各收入项占比、环比趋势、异常波动。"
|
||||
"输出 JSON 格式:{\"insights\": [{\"seq\": 1, \"title\": \"...\", \"body\": \"...\"}]}"
|
||||
),
|
||||
"data": {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"time_dimension_label": _dimension_label(time_dimension),
|
||||
"current_period": _build_period_data(current_data),
|
||||
"previous_period": _build_period_data(previous_data),
|
||||
},
|
||||
"reference": {
|
||||
"field_mapping": {
|
||||
"items_sum": (
|
||||
"table_charge_money + goods_money + assistant_pd_money"
|
||||
" + assistant_cx_money + electricity_money"
|
||||
),
|
||||
"table_fee": "table_charge_money(台费收入)",
|
||||
"assistant_pd": "assistant_pd_money(陪打费)",
|
||||
"assistant_cx": "assistant_cx_money(超休费)",
|
||||
"goods": "goods_money(商品收入)",
|
||||
"recharge": "充值 pay_amount(settle_type=5,充值收入)",
|
||||
"electricity": "electricity_money(电费,当前未启用,全为 0)",
|
||||
},
|
||||
"rules": [
|
||||
"统一使用 items_sum 口径计算营收总额",
|
||||
"助教费用必须拆分为 assistant_pd_money(陪打)和 assistant_cx_money(超休)",
|
||||
"支付渠道恒等式:balance_amount = recharge_card_amount + gift_card_amount",
|
||||
"金额单位:元(CNY),保留两位小数",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_period_data(data: dict) -> dict:
|
||||
"""构建单期数据结构,确保字段名遵守 items_sum 口径。"""
|
||||
return {
|
||||
# 收入结构(items_sum 口径)
|
||||
"table_charge_money": data.get("table_charge_money", 0),
|
||||
"goods_money": data.get("goods_money", 0),
|
||||
"assistant_pd_money": data.get("assistant_pd_money", 0),
|
||||
"assistant_cx_money": data.get("assistant_cx_money", 0),
|
||||
"electricity_money": data.get("electricity_money", 0),
|
||||
# 充值收入
|
||||
"recharge_income": data.get("recharge_income", 0),
|
||||
# 储值资产
|
||||
"balance_pay": data.get("balance_pay", 0),
|
||||
"recharge_card_pay": data.get("recharge_card_pay", 0),
|
||||
"gift_card_pay": data.get("gift_card_pay", 0),
|
||||
# 费用汇总
|
||||
"discount_amount": data.get("discount_amount", 0),
|
||||
"adjust_amount": data.get("adjust_amount", 0),
|
||||
# 平台结算
|
||||
"platform_settlement_amount": data.get("platform_settlement_amount", 0),
|
||||
"groupbuy_pay_amount": data.get("groupbuy_pay_amount", 0),
|
||||
# 汇总
|
||||
"order_count": data.get("order_count", 0),
|
||||
"member_count": data.get("member_count", 0),
|
||||
}
|
||||
|
||||
|
||||
# 时间维度编码 → 中文标签
|
||||
_DIMENSION_LABELS: dict[str, str] = {
|
||||
DIMENSION_LABELS: dict[str, str] = {
|
||||
"this_month": "本月",
|
||||
"last_month": "上月",
|
||||
"this_week": "本周",
|
||||
"last_week": "上周",
|
||||
"last_3_months": "近三个月",
|
||||
"this_quarter": "本季度",
|
||||
"last_quarter": "上季度",
|
||||
"last_6_months": "近六个月",
|
||||
"last_3_months": "近三个月(不含本月)",
|
||||
"last_6_months": "近六个月(不含本月)",
|
||||
}
|
||||
|
||||
# 区域枚举与中文标签(与 miniprogram/board-finance.ts areaOptions 对齐)
|
||||
AREA_OPTIONS: tuple[str, ...] = (
|
||||
"all", "hall", "hallA", "hallB", "hallC",
|
||||
"vip", "snooker", "mahjong", "ktv",
|
||||
)
|
||||
|
||||
AREA_LABELS: dict[str, str] = {
|
||||
"all": "全部区域",
|
||||
"hall": "大厅",
|
||||
"hallA": "A区",
|
||||
"hallB": "B区",
|
||||
"hallC": "C区",
|
||||
"vip": "台球包厢",
|
||||
"snooker": "斯诺克",
|
||||
"mahjong": "麻将房",
|
||||
"ktv": "团建房",
|
||||
}
|
||||
|
||||
# 业务字段 → 中文名。覆盖 board_service 返回的所有层级字段。
|
||||
# 只做键名翻译,不改变值与结构;未命中的键原样保留。
|
||||
KEY_TRANSLATIONS: dict[str, str] = {
|
||||
# 顶层板块
|
||||
"overview": "经营一览",
|
||||
"recharge": "预收资产",
|
||||
"revenue": "应计收入确认",
|
||||
"cashflow": "现金流入",
|
||||
"expense": "现金流出",
|
||||
"coach_analysis": "助教分析",
|
||||
|
||||
# 经营一览
|
||||
"occurrence": "发生额",
|
||||
"discount": "总优惠",
|
||||
"discount_rate": "优惠率",
|
||||
"confirmed_revenue": "成交收入",
|
||||
"cash_in": "现金流入",
|
||||
"cash_out": "现金流出",
|
||||
"cash_balance": "现金结余",
|
||||
"balance_rate": "结余率",
|
||||
|
||||
# 预收资产
|
||||
"actual_income": "储值卡充值实收",
|
||||
"first_charge": "首充",
|
||||
"renew_charge": "续费",
|
||||
"consumed": "储值卡消耗",
|
||||
"card_balance": "储值卡总余额",
|
||||
"all_card_balance": "全类别卡余额合计",
|
||||
"gift_rows": "赠送卡矩阵",
|
||||
"liquor": "酒水卡",
|
||||
"table_fee": "台费卡",
|
||||
"voucher": "抵用券",
|
||||
|
||||
# 应计收入确认
|
||||
"total_occurrence": "发生额合计",
|
||||
"discount_total": "优惠合计",
|
||||
"confirmed_total": "确认收入合计",
|
||||
"structure_rows": "收入结构",
|
||||
"price_items": "价目明细",
|
||||
"discount_items": "优惠明细",
|
||||
"channel_items": "渠道明细",
|
||||
"booked": "入账金额",
|
||||
"booked_compare": "入账环比",
|
||||
|
||||
# 现金流入/流出
|
||||
"consume_items": "消费收款项",
|
||||
"recharge_items": "充值收款项",
|
||||
"operation_items": "运营支出",
|
||||
"fixed_items": "固定支出",
|
||||
"coach_items": "助教支出",
|
||||
"platform_items": "平台支出",
|
||||
|
||||
# 助教分析
|
||||
"basic": "基础助教",
|
||||
"incentive": "激励助教",
|
||||
"total_pay": "合计薪酬",
|
||||
"total_share": "合计分成",
|
||||
"avg_hourly": "平均时薪",
|
||||
"level": "级别",
|
||||
"pay": "薪酬",
|
||||
"share": "分成",
|
||||
"hourly": "时薪",
|
||||
"rows": "明细",
|
||||
|
||||
# 通用元素
|
||||
"label": "名称",
|
||||
"amount": "金额",
|
||||
"desc": "说明",
|
||||
"total": "合计",
|
||||
"value": "数值",
|
||||
"compare": "环比",
|
||||
"id": "编号",
|
||||
|
||||
# 环比后缀(小程序约定)
|
||||
"occurrence_compare": "发生额环比",
|
||||
"occurrence_down": "发生额是否下降",
|
||||
"occurrence_flat": "发生额是否持平",
|
||||
"discount_compare": "总优惠环比",
|
||||
"discount_down": "总优惠是否下降",
|
||||
"discount_flat": "总优惠是否持平",
|
||||
"discount_rate_compare": "优惠率环比",
|
||||
"discount_rate_down": "优惠率是否下降",
|
||||
"discount_rate_flat": "优惠率是否持平",
|
||||
"confirmed_revenue_compare": "成交收入环比",
|
||||
"confirmed_revenue_down": "成交收入是否下降",
|
||||
"confirmed_revenue_flat": "成交收入是否持平",
|
||||
"cash_in_compare": "现金流入环比",
|
||||
"cash_in_down": "现金流入是否下降",
|
||||
"cash_in_flat": "现金流入是否持平",
|
||||
"cash_out_compare": "现金流出环比",
|
||||
"cash_out_down": "现金流出是否下降",
|
||||
"cash_out_flat": "现金流出是否持平",
|
||||
"cash_balance_compare": "现金结余环比",
|
||||
"cash_balance_down": "现金结余是否下降",
|
||||
"cash_balance_flat": "现金结余是否持平",
|
||||
"balance_rate_compare": "结余率环比",
|
||||
"balance_rate_down": "结余率是否下降",
|
||||
"balance_rate_flat": "结余率是否持平",
|
||||
"actual_income_compare": "储值卡充值实收环比",
|
||||
"actual_income_down": "储值卡充值实收是否下降",
|
||||
"first_charge_compare": "首充环比",
|
||||
"first_charge_down": "首充是否下降",
|
||||
"renew_charge_compare": "续费环比",
|
||||
"renew_charge_down": "续费是否下降",
|
||||
"consumed_compare": "储值卡消耗环比",
|
||||
"consumed_down": "储值卡消耗是否下降",
|
||||
"card_balance_compare": "储值卡总余额环比",
|
||||
"card_balance_down": "储值卡总余额是否下降",
|
||||
"all_card_balance_compare": "全类别卡余额合计环比",
|
||||
"all_card_balance_down": "全类别卡余额合计是否下降",
|
||||
"total_compare": "合计环比",
|
||||
"total_down": "合计是否下降",
|
||||
"total_flat": "合计是否持平",
|
||||
"total_pay_compare": "合计薪酬环比",
|
||||
"total_pay_down": "合计薪酬是否下降",
|
||||
"total_share_compare": "合计分成环比",
|
||||
"total_share_down": "合计分成是否下降",
|
||||
"avg_hourly_compare": "平均时薪环比",
|
||||
"avg_hourly_flat": "平均时薪是否持平",
|
||||
"pay_compare": "薪酬环比",
|
||||
"pay_down": "薪酬是否下降",
|
||||
"share_compare": "分成环比",
|
||||
"share_down": "分成是否下降",
|
||||
"hourly_compare": "时薪环比",
|
||||
"hourly_flat": "时薪是否持平",
|
||||
|
||||
# 赠送卡矩阵
|
||||
"wine": "酒水",
|
||||
"table": "台费",
|
||||
"coupon": "抵用券",
|
||||
|
||||
# 元数据
|
||||
"down": "是否下降",
|
||||
"flat": "是否持平",
|
||||
}
|
||||
|
||||
|
||||
def _dimension_label(dimension: str) -> str:
|
||||
"""将时间维度编码转为中文标签。"""
|
||||
return _DIMENSION_LABELS.get(dimension, dimension)
|
||||
# 裁剪时丢弃的"冗余"字段:_down / _flat 布尔元数据(*_compare 字符串已携带符号)
|
||||
_DROP_SUFFIX = ("_down", "_flat")
|
||||
|
||||
# 行级明细字段:展示用,AI 洞察不需要
|
||||
_DROP_DETAIL_KEYS = {
|
||||
"structure_rows", "price_items", "channel_items", "gift_rows",
|
||||
"discount_items", # 2026-04-22:升顶层"优惠构成"后,明细源从 revenue 里 drop 去重
|
||||
}
|
||||
|
||||
|
||||
def _is_drop_key(k: str) -> bool:
|
||||
if not isinstance(k, str):
|
||||
return False
|
||||
if k in _DROP_DETAIL_KEYS:
|
||||
return True
|
||||
return k.endswith(_DROP_SUFFIX)
|
||||
|
||||
|
||||
def _slim(data: Any) -> Any:
|
||||
"""递归裁剪:drop 明细 + _down/_flat + None 值。"""
|
||||
if isinstance(data, dict):
|
||||
out = {}
|
||||
for k, v in data.items():
|
||||
if _is_drop_key(k):
|
||||
continue
|
||||
slim_v = _slim(v)
|
||||
if slim_v is None:
|
||||
continue
|
||||
out[k] = slim_v
|
||||
return out if out else None
|
||||
if isinstance(data, list):
|
||||
return [_slim(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def _pct(numerator: float, denominator: float) -> float:
|
||||
"""百分比(小数),分母 0 返回 0。保留 4 位便于 AI 读取。"""
|
||||
if not denominator:
|
||||
return 0.0
|
||||
return round(numerator / denominator, 4)
|
||||
|
||||
|
||||
# 日粒度异常检测参数
|
||||
_ANOMALY_MIN_DAYS = 7 # 少于 7 天样本不检测(噪声太大)
|
||||
_ANOMALY_DEVIATION = 0.4 # 偏离"同星期均值" > 40% 标记为异常(2026-04-22 改为同星期基线)
|
||||
_ANOMALY_MAX_ITEMS = 10 # 最多保留 10 条(按 |偏离度| 降序截断,防 prompt 膨胀)
|
||||
_ANOMALY_MIN_SAME_WEEKDAY = 2 # 同星期至少 2 天样本才可作基线;不足时回退到整体均值
|
||||
|
||||
# 星期中文映射(0=Monday)
|
||||
_WEEKDAY_ZH = ("周一", "周二", "周三", "周四", "周五", "周六", "周日")
|
||||
|
||||
# 行业基线常量(综合商业球房)
|
||||
# 2026-04-22:移除各类警戒线/健康区间(各球房定位/地段/业态差异大,不宜一刀切)。
|
||||
# 仅保留"周中客流规律"这类行业普适的时间分布特征。
|
||||
INDUSTRY_BASELINES: dict[str, Any] = {
|
||||
"周中客流规律": "周五至周日旺季 / 周一最淡 / 周二至周四逐步回升",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_daily_series(
|
||||
site_id: int, start_date: str, end_date: str,
|
||||
) -> list[tuple] | None:
|
||||
"""查 [start, end] 日粒度财务流水,一次查完供多个分析函数复用。
|
||||
|
||||
返回字段顺序:(stat_date, gross, cash_in, order_count, member_order_count, confirmed)
|
||||
过滤全 0 停业日;样本不足时返回 None。
|
||||
"""
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
from app.database import get_connection
|
||||
|
||||
try:
|
||||
conn = get_connection()
|
||||
except Exception:
|
||||
logger.debug("日粒度查询连接失败", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT stat_date,
|
||||
COALESCE(gross_amount, 0) AS gross,
|
||||
COALESCE(cash_inflow_total, 0) AS cash_in,
|
||||
COALESCE(order_count, 0) AS order_count,
|
||||
COALESCE(member_order_count, 0) AS member_order_count,
|
||||
COALESCE(confirmed_income, 0) AS confirmed
|
||||
FROM app.v_dws_finance_daily_summary
|
||||
WHERE stat_date >= %s::date
|
||||
AND stat_date <= %s::date
|
||||
ORDER BY stat_date
|
||||
""",
|
||||
(start_date, end_date),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
logger.debug("日粒度数据查询失败: site_id=%s", site_id, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
active = [
|
||||
(r[0], float(r[1]), float(r[2]), int(r[3] or 0), int(r[4] or 0), float(r[5] or 0))
|
||||
for r in rows
|
||||
if float(r[1] or 0) > 0 or float(r[2] or 0) > 0
|
||||
]
|
||||
return active if active else None
|
||||
|
||||
|
||||
_WEEKDAY_MIN_DAYS = 14 # 月初场景:样本 < 14 天时,每个星期最多 1-2 天,"日均"接近单日值,不注入以免 AI 被误导
|
||||
|
||||
|
||||
def _aggregate_by_weekday(series: list[tuple] | None) -> dict | None:
|
||||
"""按星期聚合 7 段日均值(发生额/现金流入/订单数),供 AI 观察周中规律。
|
||||
|
||||
要求至少 14 天样本(保证每个星期至少有 2 天),否则返回 None;
|
||||
防止月初场景下单日值被包装成"日均"迷惑 AI 做周规律判断。
|
||||
"""
|
||||
if not series or len(series) < _WEEKDAY_MIN_DAYS:
|
||||
return None
|
||||
from collections import defaultdict
|
||||
buckets: dict[int, list[tuple]] = defaultdict(list)
|
||||
for row in series:
|
||||
buckets[row[0].weekday()].append(row)
|
||||
out: dict[str, dict] = {}
|
||||
for wd in range(7):
|
||||
rows = buckets.get(wd) or []
|
||||
if not rows:
|
||||
continue
|
||||
n = len(rows)
|
||||
out[_WEEKDAY_ZH[wd]] = {
|
||||
"日均发生额": round(sum(r[1] for r in rows) / n, 2),
|
||||
"日均现金流入": round(sum(r[2] for r in rows) / n, 2),
|
||||
"日均订单数": round(sum(r[3] for r in rows) / n, 1),
|
||||
"营业日数": n,
|
||||
}
|
||||
return out or None
|
||||
|
||||
|
||||
def _build_unit_economics(
|
||||
series: list[tuple] | None,
|
||||
prev_series: list[tuple] | None = None,
|
||||
) -> dict | None:
|
||||
"""单位经济派生:客单价 / 日均订单数 / 会员订单占比 / 散客订单占比。
|
||||
|
||||
口径:全期汇总后再算(避免日均 avg 失真)。
|
||||
客单价取两口径:
|
||||
- 按成交收入(去除优惠的真实收入单价) — 反映真实收入能力
|
||||
- 按发生额(含优惠的账单均值) — 反映顾客端认知的单次消费量级
|
||||
若 prev_series 可用,则附加 _环比 字段避免 AI 推测幻觉。
|
||||
"""
|
||||
if not series:
|
||||
return None
|
||||
total_orders = sum(r[3] for r in series)
|
||||
if total_orders <= 0:
|
||||
return None
|
||||
total_member_orders = sum(r[4] for r in series)
|
||||
total_confirmed = sum(r[5] for r in series)
|
||||
total_gross = sum(r[1] for r in series)
|
||||
days = len(series)
|
||||
|
||||
price_confirmed = total_confirmed / total_orders
|
||||
price_gross = total_gross / total_orders
|
||||
member_share = total_member_orders / total_orders
|
||||
daily_orders = total_orders / days
|
||||
|
||||
out: dict[str, Any] = {
|
||||
"总订单数": total_orders,
|
||||
"日均订单数": round(daily_orders, 1),
|
||||
"客单价_按成交收入": round(price_confirmed, 2),
|
||||
"客单价_按发生额": round(price_gross, 2),
|
||||
"会员订单数": total_member_orders,
|
||||
"会员订单占比": round(member_share, 4),
|
||||
"散客订单数": total_orders - total_member_orders,
|
||||
"散客订单占比": round((total_orders - total_member_orders) / total_orders, 4),
|
||||
}
|
||||
|
||||
if prev_series:
|
||||
prev_orders = sum(r[3] for r in prev_series)
|
||||
if prev_orders > 0:
|
||||
prev_days = len(prev_series)
|
||||
prev_confirmed = sum(r[5] for r in prev_series)
|
||||
prev_gross = sum(r[1] for r in prev_series)
|
||||
prev_member = sum(r[4] for r in prev_series)
|
||||
# 月初场景:上期样本 < 5 天时客单价环比噪声极大(单日波动主导),加标注供 AI 降权引用
|
||||
low_sample = prev_days < 5
|
||||
|
||||
def _pct_change(cur: float, prev: float) -> str:
|
||||
if prev <= 0:
|
||||
return "无上期数据"
|
||||
value = f"{(cur - prev) / prev * 100:+.1f}%"
|
||||
return f"{value}(上期仅 {prev_days} 天,样本不足仅供参考)" if low_sample else value
|
||||
|
||||
out["客单价_按成交收入_环比"] = _pct_change(price_confirmed, prev_confirmed / prev_orders)
|
||||
out["客单价_按发生额_环比"] = _pct_change(price_gross, prev_gross / prev_orders)
|
||||
out["日均订单数_环比"] = _pct_change(daily_orders, prev_orders / prev_days)
|
||||
out["会员订单占比_环比"] = _pct_change(member_share, prev_member / prev_orders)
|
||||
return out
|
||||
|
||||
|
||||
def _detect_anomaly_days(
|
||||
site_id: int, start_date: str, end_date: str,
|
||||
series: list[tuple] | None = None,
|
||||
) -> list[dict] | None:
|
||||
"""扫描日粒度财务数据,标记偏离同星期均值 > 40% 的异常日。
|
||||
|
||||
series 可由调用方传入复用,避免重复查 DB。
|
||||
"""
|
||||
if series is None:
|
||||
series = _fetch_daily_series(site_id, start_date, end_date)
|
||||
if not series or len(series) < _ANOMALY_MIN_DAYS:
|
||||
return None
|
||||
active = series
|
||||
|
||||
# 2026-04-22 改进:按"同星期均值"做基线,比"期均"更贴近业态(周一淡/周末旺)
|
||||
# 同星期样本 < _ANOMALY_MIN_SAME_WEEKDAY 天时回退到整体均值
|
||||
from collections import defaultdict
|
||||
|
||||
def _scan(idx: int, label: str) -> list[dict]:
|
||||
vals = [row[idx] for row in active]
|
||||
global_mean = sum(vals) / len(vals)
|
||||
if global_mean <= 0:
|
||||
return []
|
||||
|
||||
# 按 weekday 分组统计均值
|
||||
by_weekday: dict[int, list[float]] = defaultdict(list)
|
||||
for d, *metrics in active:
|
||||
by_weekday[d.weekday()].append(metrics[idx - 1])
|
||||
weekday_mean: dict[int, float] = {
|
||||
wd: (sum(xs) / len(xs)) for wd, xs in by_weekday.items()
|
||||
}
|
||||
|
||||
flagged: list[dict] = []
|
||||
for d, *metrics in active:
|
||||
v = metrics[idx - 1]
|
||||
wd = d.weekday()
|
||||
same_count = len(by_weekday.get(wd, []))
|
||||
# 基线选择:同星期样本 >= 2 用同星期均值,否则用整体均值
|
||||
if same_count >= _ANOMALY_MIN_SAME_WEEKDAY and weekday_mean[wd] > 0:
|
||||
base = weekday_mean[wd]
|
||||
base_label = f"同{_WEEKDAY_ZH[wd]}均值"
|
||||
else:
|
||||
base = global_mean
|
||||
base_label = "期均"
|
||||
|
||||
deviation = (v - base) / base
|
||||
if abs(deviation) >= _ANOMALY_DEVIATION:
|
||||
weekday_zh = _WEEKDAY_ZH[wd]
|
||||
flagged.append({
|
||||
"日期": f"{d} {weekday_zh}",
|
||||
"指标": label,
|
||||
"当日": round(v, 2),
|
||||
"基线": round(base, 2),
|
||||
"基线类型": base_label,
|
||||
"偏离": f"{deviation * 100:+.1f}%",
|
||||
"_abs_dev": abs(deviation),
|
||||
})
|
||||
return flagged
|
||||
|
||||
candidates: list[dict] = _scan(1, "发生额") + _scan(2, "现金流入")
|
||||
if not candidates:
|
||||
return None
|
||||
# 按绝对偏离排序,取 top N,去掉排序用辅助键
|
||||
candidates.sort(key=lambda x: x["_abs_dev"], reverse=True)
|
||||
out = []
|
||||
for c in candidates[:_ANOMALY_MAX_ITEMS]:
|
||||
c.pop("_abs_dev", None)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_card_balance_opening(site_id: int, start_date: str) -> float | None:
|
||||
"""取 start_date 前一日的储值卡总余额(作为本期期初余额)。
|
||||
|
||||
数据源:etl 库 app.v_dws_finance_recharge_summary(每日快照,total_card_balance 字段)。
|
||||
若前一日无数据(门店刚开业 / 数据缺失),返回 None。
|
||||
"""
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
from app.database import get_connection
|
||||
|
||||
try:
|
||||
conn = get_connection()
|
||||
except Exception:
|
||||
logger.debug("期初余额查询连接失败", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT total_card_balance
|
||||
FROM app.v_dws_finance_recharge_summary
|
||||
WHERE stat_date < %s::date
|
||||
ORDER BY stat_date DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(start_date,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except Exception:
|
||||
logger.debug("期初余额查询失败: site_id=%s", site_id, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
return float(row[0])
|
||||
|
||||
|
||||
def _aggregate_expense(expense: dict | None) -> dict | None:
|
||||
"""从 expense 四类明细聚合出顶层金额,便于 AI 直接看四大块支出占比。"""
|
||||
if not isinstance(expense, dict):
|
||||
return None
|
||||
def _sum(key: str) -> float:
|
||||
items = expense.get(key) or []
|
||||
if not isinstance(items, list):
|
||||
return 0.0
|
||||
return round(sum(float(x.get("amount", 0) or 0) for x in items if isinstance(x, dict)), 2)
|
||||
total = float(expense.get("total", 0) or 0)
|
||||
if total <= 0:
|
||||
return None # 全 0 数据对 AI 无意义,直接丢
|
||||
return {
|
||||
"合计": round(total, 2),
|
||||
"合计环比": expense.get("total_compare") or "持平",
|
||||
"运营支出": _sum("operation_items"),
|
||||
"固定支出": _sum("fixed_items"),
|
||||
"助教支出": _sum("coach_items"),
|
||||
"平台支出": _sum("platform_items"),
|
||||
}
|
||||
|
||||
|
||||
def _build_discount_kpi(revenue: dict | None, overview: dict | None) -> dict | None:
|
||||
"""把优惠拆成顶层 KPI + 派生指标(占比、贡献率)。
|
||||
|
||||
AI 数据挖掘视角:
|
||||
- 按金额排序展示,top1 一眼看出来
|
||||
- 每项带 amount / compare / share(占总优惠比)
|
||||
- 整体带优惠率(discount / occurrence)便于判断利润侵蚀程度
|
||||
"""
|
||||
if not isinstance(revenue, dict):
|
||||
return None
|
||||
items = revenue.get("discount_items") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
|
||||
total = round(sum(float(x.get("amount", 0) or 0) for x in items if isinstance(x, dict)), 2)
|
||||
breakdown = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
amt = float(it.get("amount", 0) or 0)
|
||||
row: dict[str, Any] = {
|
||||
"名称": it.get("label"),
|
||||
"金额": round(amt, 2),
|
||||
"占总优惠": _pct(amt, total),
|
||||
}
|
||||
if it.get("compare"):
|
||||
row["环比"] = it["compare"]
|
||||
breakdown.append(row)
|
||||
# 按金额从大到小排序 → AI 阅读顺序 = 重要度顺序
|
||||
breakdown.sort(key=lambda r: float(r.get("金额") or 0), reverse=True)
|
||||
|
||||
overview = overview or {}
|
||||
occurrence = float(overview.get("occurrence", 0) or 0)
|
||||
|
||||
kpi: dict[str, Any] = {
|
||||
"总优惠": total,
|
||||
"优惠率": _pct(total, occurrence), # 0.3796 表示 37.96%
|
||||
"占比排序": breakdown,
|
||||
}
|
||||
if breakdown:
|
||||
top = breakdown[0]
|
||||
kpi["最大优惠来源"] = f"{top.get('名称')}(金额 {top.get('金额')} 元,占总优惠 {int(float(top.get('占总优惠', 0))*100)}%)"
|
||||
return kpi
|
||||
|
||||
|
||||
def _build_cashflow_kpi(cashflow: dict | None) -> dict | None:
|
||||
"""消费收款拆三档(纸币/线上/团购)+ 充值到账,给 AI 直接看资金来源结构。"""
|
||||
if not isinstance(cashflow, dict):
|
||||
return None
|
||||
consume = cashflow.get("consume_items") or []
|
||||
recharge = cashflow.get("recharge_items") or []
|
||||
total = float(cashflow.get("total", 0) or 0)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
consume_map = {}
|
||||
for it in consume:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
consume_map[it.get("label")] = {
|
||||
"金额": round(float(it.get("amount", 0) or 0), 2),
|
||||
"环比": it.get("compare") or "持平",
|
||||
}
|
||||
|
||||
recharge_total = round(sum(float(x.get("amount", 0) or 0) for x in recharge if isinstance(x, dict)), 2)
|
||||
consume_total = round(sum(float(v.get("金额", 0) or 0) for v in consume_map.values()), 2)
|
||||
|
||||
return {
|
||||
"合计": round(total, 2),
|
||||
"合计环比": cashflow.get("total_compare") or "持平",
|
||||
"消费收款合计": consume_total,
|
||||
"消费收款占比": _pct(consume_total, total),
|
||||
"充值收款合计": recharge_total,
|
||||
"充值收款占比": _pct(recharge_total, total),
|
||||
"按渠道": consume_map,
|
||||
}
|
||||
|
||||
|
||||
def _build_coach_kpi(coach: dict | None) -> dict | None:
|
||||
"""助教成本压缩:只保留两档的合计薪酬+合计分成+平均时薪+3 级别薪酬分布。"""
|
||||
if not isinstance(coach, dict):
|
||||
return None
|
||||
def _slim_tier(t: dict | None) -> dict | None:
|
||||
if not isinstance(t, dict):
|
||||
return None
|
||||
rows = t.get("rows") or []
|
||||
# 只保留级别-薪酬-时薪 3 字段,作为分布快照
|
||||
tier_dist = [
|
||||
{"级别": r.get("level"), "薪酬": r.get("pay"), "时薪": r.get("hourly")}
|
||||
for r in rows if isinstance(r, dict)
|
||||
]
|
||||
total_pay = float(t.get("total_pay", 0) or 0)
|
||||
if total_pay <= 0:
|
||||
return None
|
||||
return {
|
||||
"合计薪酬": round(total_pay, 2),
|
||||
"合计薪酬环比": t.get("total_pay_compare") or "持平",
|
||||
"合计分成": round(float(t.get("total_share", 0) or 0), 2),
|
||||
"平均时薪": round(float(t.get("avg_hourly", 0) or 0), 2),
|
||||
"各级别分布": tier_dist,
|
||||
}
|
||||
basic = _slim_tier(coach.get("basic"))
|
||||
incentive = _slim_tier(coach.get("incentive"))
|
||||
if not basic and not incentive:
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
if basic:
|
||||
out["基础助教"] = basic
|
||||
if incentive:
|
||||
out["激励助教"] = incentive
|
||||
# 派生:人力成本占收入比(需要收入传进来,这里只给基础值)
|
||||
total_pay = (basic or {}).get("合计薪酬", 0) + (incentive or {}).get("合计薪酬", 0)
|
||||
if total_pay > 0:
|
||||
out["人力薪酬合计"] = round(total_pay, 2)
|
||||
return out
|
||||
|
||||
|
||||
def _build_derived_ratios(overview: dict | None, cashflow_kpi: dict | None,
|
||||
coach_kpi: dict | None, discount_kpi: dict | None) -> dict:
|
||||
"""数据挖掘视角:派生关键比率,让 AI 不用自己算。
|
||||
|
||||
- 储值卡贡献率:充值到账 / 总现金流入
|
||||
- 人力成本占收入比:助教薪酬合计 / 成交收入
|
||||
- 优惠侵蚀率:总优惠 / 发生额
|
||||
- 现金结余率:现金结余 / 现金流入
|
||||
"""
|
||||
ov = overview or {}
|
||||
confirmed = float(ov.get("confirmed_revenue", 0) or 0)
|
||||
occurrence = float(ov.get("occurrence", 0) or 0)
|
||||
cash_in = float(ov.get("cash_in", 0) or 0)
|
||||
cash_balance = float(ov.get("cash_balance", 0) or 0)
|
||||
total_pay = (coach_kpi or {}).get("人力薪酬合计", 0)
|
||||
recharge_in = (cashflow_kpi or {}).get("充值收款合计", 0)
|
||||
discount_total = (discount_kpi or {}).get("总优惠", 0)
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
if confirmed > 0 and total_pay:
|
||||
out["人力成本占成交收入比"] = _pct(total_pay, confirmed)
|
||||
if cash_in > 0 and recharge_in:
|
||||
out["储值卡充值占现金流入比"] = _pct(recharge_in, cash_in)
|
||||
if occurrence > 0 and discount_total:
|
||||
out["优惠侵蚀率"] = _pct(discount_total, occurrence)
|
||||
if cash_in > 0:
|
||||
out["现金结余率"] = _pct(cash_balance, cash_in)
|
||||
return out
|
||||
|
||||
|
||||
# 2026-04-22:异常检测由 AI 侧自行判断,后端只提供客观 KPI(不给规则结论)
|
||||
|
||||
|
||||
def _translate_keys(data: Any) -> Any:
|
||||
"""递归翻译 dict/list 中所有键为中文;值保持不变。
|
||||
|
||||
- dict: 键命中 KEY_TRANSLATIONS 则替换,未命中保留原键
|
||||
- list: 逐项递归
|
||||
- 其他类型(str/int/float/bool/None)原样返回
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
KEY_TRANSLATIONS.get(k, k): _translate_keys(v)
|
||||
for k, v in data.items()
|
||||
}
|
||||
if isinstance(data, list):
|
||||
return [_translate_keys(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: Any | None = None, # 兼容统一签名,App2 不用
|
||||
) -> str:
|
||||
"""构建 App2 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, time_dimension, area(可选,默认 all)
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串,所有 board 数据字段已翻译为中文。
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
time_dimension = context["time_dimension"]
|
||||
area = context.get("area", "all")
|
||||
|
||||
board_time = DIMENSION_MAP.get(time_dimension)
|
||||
if not board_time:
|
||||
raise ValueError(f"App2 不支持的时间维度: {time_dimension}")
|
||||
|
||||
if area not in AREA_LABELS:
|
||||
raise ValueError(f"App2 不支持的区域: {area}")
|
||||
|
||||
try:
|
||||
board_data = await get_finance_board(
|
||||
time=board_time, area=area, compare=1, site_id=site_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"App2 财务看板查询失败: site_id=%s dimension=%s area=%s",
|
||||
site_id, time_dimension, area, exc_info=True,
|
||||
)
|
||||
board_data = {}
|
||||
|
||||
# 2026-04-22 数据挖掘视角 prompt 结构化:
|
||||
# - 优惠/现金流/助教/支出 四大领域分别派生 KPI(带占比/排序/派生指标)
|
||||
# - 异常检测:规则法标注 AI 必看异常点
|
||||
# - 派生比率:人力成本占比/优惠侵蚀率/储值卡贡献率 等不用 AI 再算
|
||||
# - 原始财务数据经 _slim 裁剪后作为"原始指标"补充,避免 AI 失去追溯能力
|
||||
overview = board_data.get("overview") if isinstance(board_data, dict) else None
|
||||
revenue = board_data.get("revenue") if isinstance(board_data, dict) else None
|
||||
cashflow = board_data.get("cashflow") if isinstance(board_data, dict) else None
|
||||
expense = board_data.get("expense") if isinstance(board_data, dict) else None
|
||||
coach = board_data.get("coach_analysis") if isinstance(board_data, dict) else None
|
||||
|
||||
discount_kpi = _build_discount_kpi(revenue, overview)
|
||||
cashflow_kpi = _build_cashflow_kpi(cashflow)
|
||||
expense_kpi = _aggregate_expense(expense)
|
||||
coach_kpi = _build_coach_kpi(coach)
|
||||
ratios = _build_derived_ratios(overview, cashflow_kpi, coach_kpi, discount_kpi)
|
||||
|
||||
# 原始数据:slim 后再翻译,供 AI 追溯细节
|
||||
slim_data = _slim(board_data) or {}
|
||||
raw_cn = _translate_keys(slim_data)
|
||||
|
||||
# 对比口径说明:当期/对比期均为"同天数对齐",避免 AI 把环比误读为"当期部分 vs 上期整月"
|
||||
compare_caliber: dict[str, Any] | None = None
|
||||
try:
|
||||
from app.services.runtime_context import get_runtime_context
|
||||
|
||||
runtime_ctx = get_runtime_context(site_id)
|
||||
cur_start, cur_end = _calc_date_range(board_time, ref_date=runtime_ctx.business_date)
|
||||
prev_start, prev_end = _calc_prev_range(board_time, cur_start, cur_end)
|
||||
cur_days = (cur_end - cur_start).days + 1
|
||||
prev_days = (prev_end - prev_start).days + 1
|
||||
compare_caliber = {
|
||||
"当期范围": f"{cur_start} ~ {cur_end}({cur_days} 天)",
|
||||
"对比期范围": f"{prev_start} ~ {prev_end}({prev_days} 天)",
|
||||
"对齐方式": "上期同天数对齐(非整月/整周对比)",
|
||||
"说明": "所有 _环比 / _compare 字段均按上表口径计算;月中调用时对比期会自动截断到与当期相同天数",
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("对比口径字段生成失败(不影响主流程)", exc_info=True)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"当前时间": get_runtime_context(site_id).business_now.strftime("%Y-%m-%d %H:%M"),
|
||||
"门店编号": site_id,
|
||||
"时间维度": DIMENSION_LABELS.get(time_dimension, time_dimension),
|
||||
"区域": AREA_LABELS.get(area, area),
|
||||
# 0. 对比口径:让 AI 正确解读环比字段
|
||||
**({"对比口径": compare_caliber} if compare_caliber else {}),
|
||||
# 1. 核心 KPI:AI 洞察首要依据
|
||||
"核心KPI": {
|
||||
"发生额": float(overview.get("occurrence", 0)) if overview else 0,
|
||||
"发生额环比": (overview or {}).get("occurrence_compare") or "持平",
|
||||
"成交收入": float(overview.get("confirmed_revenue", 0)) if overview else 0,
|
||||
"成交收入环比": (overview or {}).get("confirmed_revenue_compare") or "持平",
|
||||
"现金流入": (overview or {}).get("cash_in"),
|
||||
"现金流入环比": (overview or {}).get("cash_in_compare") or "持平",
|
||||
"现金结余": (overview or {}).get("cash_balance"),
|
||||
"现金结余环比": (overview or {}).get("cash_balance_compare") or "持平",
|
||||
},
|
||||
# 2. 派生比率:不用 AI 再算
|
||||
"派生比率": ratios,
|
||||
}
|
||||
# 3. 优惠构成(带排序/占比/环比/最大来源提示)
|
||||
if discount_kpi:
|
||||
payload["优惠构成"] = discount_kpi
|
||||
# 4. 现金流入来源分布
|
||||
if cashflow_kpi:
|
||||
payload["现金流入来源"] = cashflow_kpi
|
||||
# 5. 支出概况(聚合到四大类,total=0 则不给 AI)
|
||||
if expense_kpi:
|
||||
payload["支出概况"] = expense_kpi
|
||||
# 6. 助教成本画像
|
||||
if coach_kpi:
|
||||
payload["助教成本"] = coach_kpi
|
||||
# 7. 储值卡余额变化:期初 + 期末 + 充值 + 消耗 + 其他调整(揭示"充值-消耗≠余额变化"的差异)
|
||||
# 避免 AI 在只看当期充值/消耗时对"余额为何涨"的矛盾自圆其说
|
||||
if area == "all" and isinstance(recharge := board_data.get("recharge"), dict):
|
||||
try:
|
||||
start_date_obj, _end = _calc_date_range(board_time)
|
||||
opening = _fetch_card_balance_opening(site_id, str(start_date_obj))
|
||||
closing = float(recharge.get("card_balance") or 0)
|
||||
period_recharge = float(recharge.get("actual_income") or 0)
|
||||
period_consume = float(recharge.get("consumed") or 0)
|
||||
if opening is not None and (opening > 0 or closing > 0):
|
||||
diff = closing - opening
|
||||
other_adj = round(diff - (period_recharge - period_consume), 2)
|
||||
payload["储值卡余额变化"] = {
|
||||
"期初余额": round(opening, 2),
|
||||
"期末余额": round(closing, 2),
|
||||
"余额变化": round(diff, 2),
|
||||
"本期充值": round(period_recharge, 2),
|
||||
"本期消耗": round(period_consume, 2),
|
||||
"其他调整": other_adj, # 含过期/赠送/退款/手动调整,非 0 时 AI 需要关注
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("储值卡余额变化注入失败", exc_info=True)
|
||||
# 8. 日粒度派生(仅 area=all,样本 ≥ 7 天):一次 DB 查询,三段派生
|
||||
# - 单位经济:客单价/订单数/会员占比(含环比,避免 AI 对客单走势推测幻觉)
|
||||
# - 按星期聚合:供 E 板块做周中规律宏观洞察
|
||||
# - 日粒度异常:同星期均值基线下的极端偏离
|
||||
if area == "all":
|
||||
try:
|
||||
start_date, end_date = _calc_date_range(board_time)
|
||||
series = _fetch_daily_series(site_id, str(start_date), str(end_date))
|
||||
# 上期序列(用于客单价环比)
|
||||
prev_series: list[tuple] | None = None
|
||||
try:
|
||||
prev_start, prev_end = _calc_prev_range(board_time, start_date, end_date)
|
||||
prev_series = _fetch_daily_series(site_id, str(prev_start), str(prev_end))
|
||||
except Exception:
|
||||
logger.debug("上期 series 查询失败,客单价环比字段将省略", exc_info=True)
|
||||
|
||||
if series:
|
||||
unit_econ = _build_unit_economics(series, prev_series=prev_series)
|
||||
if unit_econ:
|
||||
payload["单位经济"] = unit_econ
|
||||
by_weekday = _aggregate_by_weekday(series)
|
||||
if by_weekday:
|
||||
payload["按星期聚合"] = by_weekday
|
||||
anomalies = _detect_anomaly_days(
|
||||
site_id, str(start_date), str(end_date), series=series,
|
||||
)
|
||||
if anomalies:
|
||||
payload["日粒度异常"] = anomalies
|
||||
except Exception:
|
||||
logger.debug("日粒度派生字段注入失败(不影响主流程)", exc_info=True)
|
||||
# 9. 行业基线:AI 判断是否超警戒线的参照
|
||||
payload["行业基线"] = INDUSTRY_BASELINES
|
||||
# 10. 原始财务数据:供 AI 追溯(大部分 prompt 长度来自这里,已 slim)
|
||||
payload["原始指标"] = raw_cn
|
||||
|
||||
if not board_data:
|
||||
payload["数据缺失提示"] = "财务看板数据获取失败,请基于已有缓存或常识分析"
|
||||
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -396,7 +396,10 @@ async def build_prompt(
|
||||
# 对比口径(所有环比字段的前置依赖 · H1)
|
||||
compare_caliber: dict[str, Any] | None = None
|
||||
try:
|
||||
cur_start, cur_end = _calc_date_range(board_time)
|
||||
from app.services.runtime_context import get_runtime_context
|
||||
|
||||
runtime_ctx = get_runtime_context(site_id)
|
||||
cur_start, cur_end = _calc_date_range(board_time, ref_date=runtime_ctx.business_date)
|
||||
prev_start, prev_end = _calc_prev_range(board_time, cur_start, cur_end)
|
||||
cur_days = (cur_end - cur_start).days + 1
|
||||
prev_days = (prev_end - prev_start).days + 1
|
||||
@@ -419,7 +422,7 @@ async def build_prompt(
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"当前时间": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"当前时间": get_runtime_context(site_id).business_now.strftime("%Y-%m-%d %H:%M"),
|
||||
"门店编号": site_id,
|
||||
"时间维度": DIMENSION_LABELS.get(time_dimension, time_dimension),
|
||||
"区域": AREA_LABELS.get(area, area),
|
||||
|
||||
131
apps/backend/app/ai/prompts/app3_clue_prompt.py
Normal file
131
apps/backend/app/ai/prompts/app3_clue_prompt.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""应用 3 客户数据维客线索分析 Prompt 拼装。
|
||||
|
||||
消费事件触发,从客户消费数据提取维客线索。
|
||||
- 数据源:fetch_member_consumption_data(DWS)
|
||||
- 金额口径:items_sum(禁止 consume_money)
|
||||
- 线索 category:客户基础 / 消费习惯 / 玩法偏好(3 选 1)
|
||||
- 线索 providers 统一为"系统"
|
||||
- system prompt 在百炼控制台配置,本模块只拼数据上下文 JSON
|
||||
|
||||
返回:单个 prompt 字符串(直接传给 Application.call)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.services.runtime_context import as_runtime_business_now_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# prompt 观测阈值:历史上 4000 字会触发裁剪;现保留完整消费明细,仅用于测试/审计参考
|
||||
_MAX_PROMPT_LEN = 4000
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> str:
|
||||
"""构建 App3 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id
|
||||
cache_svc: 缓存服务,用于读取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 数据获取(失败降级)
|
||||
fetch_failed = False
|
||||
try:
|
||||
member_data = await fetch_member_consumption_data(site_id, member_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"App3 消费数据获取失败: site_id=%s member_id=%s",
|
||||
site_id, member_id, exc_info=True,
|
||||
)
|
||||
member_data = _default_member_data()
|
||||
fetch_failed = True
|
||||
|
||||
consumption_records = member_data.get("consumption_records") or []
|
||||
if not consumption_records:
|
||||
consumption_records = (
|
||||
"⚠ 消费数据获取失败,该客户暂无消费记录可供分析"
|
||||
if fetch_failed else "该客户暂无消费记录"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
|
||||
"member_id": member_id,
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
"main_data": {
|
||||
"consumption_records": consumption_records,
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"reference": _build_reference(site_id, member_id, cache_svc),
|
||||
}
|
||||
|
||||
# 完整明细策略:App3 需要尽量保留消费行为模式,不在本地裁剪消费记录。
|
||||
# 真实 App3 完整 100 条明细调用已验证可在 180s 单步超时内返回。
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
return text
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""组装参考字段:App6 备注线索最新 + App8 历史最近 2 条。"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
ref: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
app6_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, target_id,
|
||||
)
|
||||
if app6_latest:
|
||||
ref["app6_note_clues"] = {
|
||||
"result_json": app6_latest.get("result_json"),
|
||||
"generated_at": app6_latest.get("created_at"),
|
||||
}
|
||||
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
ref["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return ref
|
||||
177
apps/backend/app/ai/prompts/app4_analysis_prompt.py
Normal file
177
apps/backend/app/ai/prompts/app4_analysis_prompt.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""应用 4 关系分析 / 任务建议 Prompt 拼装。
|
||||
|
||||
助教被分配召回任务或参与新结算时触发。
|
||||
- 数据源:fetch_assistant_info + fetch_service_history + fetch_member_consumption_data + fetch_member_notes
|
||||
- 输出字段:task_description / action_suggestions / one_line_summary
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
返回:单个 prompt 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.services.runtime_context import as_runtime_business_now_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROMPT_LEN = 8000
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> str:
|
||||
"""构建 App4 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id
|
||||
cache_svc: 缓存服务,用于读取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
assistant_info = results[0] if not isinstance(results[0], Exception) else {}
|
||||
if isinstance(results[0], Exception):
|
||||
warnings.append("助教信息获取失败")
|
||||
logger.warning("App4 助教信息获取失败: %s", results[0])
|
||||
|
||||
service_history = results[1] if not isinstance(results[1], Exception) else []
|
||||
if isinstance(results[1], Exception):
|
||||
warnings.append("服务历史获取失败")
|
||||
logger.warning("App4 服务历史获取失败: %s", results[1])
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
member_data = _default_member_data()
|
||||
warnings.append("消费数据获取失败")
|
||||
logger.warning("App4 消费数据获取失败: %s", results[2])
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
notes = results[3] if not isinstance(results[3], Exception) else []
|
||||
if isinstance(results[3], Exception):
|
||||
warnings.append("备注获取失败")
|
||||
logger.warning("App4 备注获取失败: %s", results[3])
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"assistant_info": assistant_info or "⚠ 助教信息获取失败",
|
||||
"service_history": service_history or "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
"notes": notes or "暂无备注",
|
||||
},
|
||||
"reference": _build_reference(site_id, member_id, cache_svc),
|
||||
}
|
||||
|
||||
if warnings:
|
||||
payload["_data_warnings"] = warnings
|
||||
|
||||
return _truncate_payload(payload)
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""组装 App8 最新 + 最近 2 条历史。"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
ref: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if latest:
|
||||
ref["app8_latest"] = {
|
||||
"result_json": latest.get("result_json"),
|
||||
"generated_at": latest.get("created_at"),
|
||||
}
|
||||
|
||||
history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if history:
|
||||
ref["app8_history"] = [
|
||||
{"result_json": h.get("result_json"), "generated_at": h.get("created_at")}
|
||||
for h in history
|
||||
]
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
def _truncate_payload(payload: dict) -> str:
|
||||
"""按优先级截断 service_history → consumption_records → notes,控制 prompt 长度。"""
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) <= _MAX_PROMPT_LEN:
|
||||
return text
|
||||
|
||||
sh = payload.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
payload["service_history"] = sh[:5]
|
||||
payload["_truncated_service_history"] = f"服务记录已截断,原始 {len(sh)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
records = payload["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
payload["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
payload["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始 {len(records)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
n = payload["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
payload["customer_data"]["notes"] = n[:10]
|
||||
payload["customer_data"]["_truncated_notes"] = f"备注已截断,原始 {len(n)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
return text
|
||||
170
apps/backend/app/ai/prompts/app5_tactics_prompt.py
Normal file
170
apps/backend/app/ai/prompts/app5_tactics_prompt.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""应用 5 话术参考 Prompt 拼装。
|
||||
|
||||
App4 完成后串行触发,接收 App4 返回结果作为 task_suggestion。
|
||||
- 数据源:fetch_assistant_info + fetch_service_history + fetch_member_consumption_data + fetch_member_notes + context.app4_result
|
||||
- 输出字段:tactics 数组(每条含 scenario + script)
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
返回:单个 prompt 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.services.runtime_context import as_runtime_business_now_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROMPT_LEN = 8000
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> str:
|
||||
"""构建 App5 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id, app4_result(dict|None)
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
task_suggestion = context.get("app4_result") or {}
|
||||
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
assistant_info = results[0] if not isinstance(results[0], Exception) else {}
|
||||
if isinstance(results[0], Exception):
|
||||
warnings.append("助教信息获取失败")
|
||||
logger.warning("App5 助教信息获取失败: %s", results[0])
|
||||
|
||||
service_history = results[1] if not isinstance(results[1], Exception) else []
|
||||
if isinstance(results[1], Exception):
|
||||
warnings.append("服务历史获取失败")
|
||||
logger.warning("App5 服务历史获取失败: %s", results[1])
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
member_data = _default_member_data()
|
||||
warnings.append("消费数据获取失败")
|
||||
logger.warning("App5 消费数据获取失败: %s", results[2])
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
notes = results[3] if not isinstance(results[3], Exception) else []
|
||||
if isinstance(results[3], Exception):
|
||||
warnings.append("备注获取失败")
|
||||
logger.warning("App5 备注获取失败: %s", results[3])
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"task_suggestion": task_suggestion,
|
||||
"assistant_info": assistant_info or "⚠ 助教信息获取失败",
|
||||
"service_history": service_history or "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
"notes": notes or "暂无备注",
|
||||
},
|
||||
"reference": _build_reference(site_id, member_id, cache_svc),
|
||||
}
|
||||
|
||||
if warnings:
|
||||
payload["_data_warnings"] = warnings
|
||||
|
||||
return _truncate_payload(payload)
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""组装最近 2 条 App8 历史。"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
ref: dict = {}
|
||||
history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value,
|
||||
site_id,
|
||||
str(member_id),
|
||||
limit=2,
|
||||
)
|
||||
if history:
|
||||
ref["app8_history"] = [
|
||||
{"result_json": h.get("result_json"), "generated_at": h.get("created_at")}
|
||||
for h in history
|
||||
]
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
def _truncate_payload(payload: dict) -> str:
|
||||
"""按优先级截断 service_history → consumption_records → notes。"""
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) <= _MAX_PROMPT_LEN:
|
||||
return text
|
||||
|
||||
sh = payload.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
payload["service_history"] = sh[:5]
|
||||
payload["_truncated_service_history"] = f"服务记录已截断,原始 {len(sh)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
records = payload["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
payload["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
payload["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始 {len(records)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
n = payload["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
payload["customer_data"]["notes"] = n[:10]
|
||||
payload["customer_data"]["_truncated_notes"] = f"备注已截断,原始 {len(n)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
return text
|
||||
160
apps/backend/app/ai/prompts/app6_note_prompt.py
Normal file
160
apps/backend/app/ai/prompts/app6_note_prompt.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""应用 6 备注分析 Prompt 拼装。
|
||||
|
||||
助教提交备注后触发,AI 分析备注内容并评分(1-10)+ 提取维客线索。
|
||||
- 数据源:context.note_content + fetch_member_consumption_data + fetch_member_notes
|
||||
- 线索 category:6 选 1(含促销偏好/社交关系/重要反馈)
|
||||
- 线索 providers 标记当前备注提供人
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
返回:单个 prompt 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.services.runtime_context import as_runtime_business_now_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROMPT_LEN = 8000
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> str:
|
||||
"""构建 App6 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, note_content, noted_by_name, noted_by_created_at
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
note_content = context.get("note_content", "")
|
||||
noted_by_name = context.get("noted_by_name", "")
|
||||
noted_by_created_at = context.get("noted_by_created_at", "")
|
||||
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
member_data = _default_member_data()
|
||||
warnings.append("消费数据获取失败")
|
||||
logger.warning("App6 消费数据获取失败: %s", results[0])
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
all_notes = results[1] if not isinstance(results[1], Exception) else []
|
||||
if isinstance(results[1], Exception):
|
||||
warnings.append("备注获取失败")
|
||||
logger.warning("App6 备注获取失败: %s", results[1])
|
||||
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
reference["member_nickname"] = member_data.get("member_nickname", "")
|
||||
reference["consumption_data"] = {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
}
|
||||
reference["all_notes"] = all_notes
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
|
||||
"member_id": member_id,
|
||||
"current_note": {
|
||||
"content": note_content,
|
||||
"recorded_by": noted_by_name,
|
||||
"created_at": noted_by_created_at,
|
||||
},
|
||||
"providers_label": noted_by_name,
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if warnings:
|
||||
payload["_data_warnings"] = warnings
|
||||
|
||||
return _truncate_payload(payload)
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""组装 App3 客户线索最新 + App8 历史最近 2 条。"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
ref: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
app3_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, target_id,
|
||||
)
|
||||
if app3_latest:
|
||||
ref["app3_clues"] = {
|
||||
"result_json": app3_latest.get("result_json"),
|
||||
"generated_at": app3_latest.get("created_at"),
|
||||
}
|
||||
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
ref["app8_history"] = [
|
||||
{"result_json": h.get("result_json"), "generated_at": h.get("created_at")}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
def _truncate_payload(payload: dict) -> str:
|
||||
"""按优先级截断 consumption_records → all_notes。"""
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) <= _MAX_PROMPT_LEN:
|
||||
return text
|
||||
|
||||
cd = payload["reference"].get("consumption_data", {})
|
||||
records = cd.get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
cd["consumption_records"] = records[:5]
|
||||
cd["_truncated"] = f"消费记录已截断,原始 {len(records)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
notes = payload["reference"].get("all_notes")
|
||||
if isinstance(notes, list) and len(notes) > 10:
|
||||
payload["reference"]["all_notes"] = notes[:10]
|
||||
payload["reference"]["_truncated_notes"] = f"备注已截断,原始 {len(notes)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
return text
|
||||
165
apps/backend/app/ai/prompts/app7_customer_prompt.py
Normal file
165
apps/backend/app/ai/prompts/app7_customer_prompt.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""应用 7 客户分析 Prompt 拼装。
|
||||
|
||||
消费链 App8 完成后串行触发,生成客户全量分析与运营策略。
|
||||
- 数据源:fetch_member_consumption_data + fetch_member_notes
|
||||
- 备注内容标注【来源:XXX,请甄别信息真实性】
|
||||
- 输出字段:strategies 数组 + summary
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
返回:单个 prompt 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.services.runtime_context import as_runtime_business_now_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PROMPT_LEN = 5000
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> str:
|
||||
"""构建 App7 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id
|
||||
|
||||
Returns:
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
member_data = _default_member_data()
|
||||
warnings.append("消费数据获取失败")
|
||||
logger.warning("App7 消费数据获取失败: %s", results[0])
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
notes_raw = results[1] if not isinstance(results[1], Exception) else []
|
||||
if isinstance(results[1], Exception):
|
||||
warnings.append("备注获取失败")
|
||||
logger.warning("App7 备注获取失败: %s", results[1])
|
||||
|
||||
# 主观信息标注来源
|
||||
if notes_raw:
|
||||
annotated = []
|
||||
for note in notes_raw:
|
||||
recorded_by = note.get("recorded_by", "未知")
|
||||
n = dict(note)
|
||||
n["content"] = (
|
||||
f"{note.get('content', '')}"
|
||||
f"【来源:{recorded_by},请甄别信息真实性】"
|
||||
)
|
||||
annotated.append(n)
|
||||
subjective_notes: Any = annotated
|
||||
else:
|
||||
subjective_notes = "该客户暂无主观备注信息"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
|
||||
"member_id": member_id,
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
"objective_data": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"subjective_data": {
|
||||
"notes": subjective_notes,
|
||||
},
|
||||
"reference": _build_reference(site_id, member_id, cache_svc),
|
||||
}
|
||||
|
||||
if warnings:
|
||||
payload["_data_warnings"] = warnings
|
||||
|
||||
return _truncate_payload(payload)
|
||||
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""组装 App8 最新 + 最近 2 条历史。"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
ref: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if latest:
|
||||
ref["app8_latest"] = {
|
||||
"result_json": latest.get("result_json"),
|
||||
"generated_at": latest.get("created_at"),
|
||||
}
|
||||
|
||||
history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if history:
|
||||
ref["app8_history"] = [
|
||||
{"result_json": h.get("result_json"), "generated_at": h.get("created_at")}
|
||||
for h in history
|
||||
]
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
def _truncate_payload(payload: dict) -> str:
|
||||
"""按优先级截断 consumption_records → notes。"""
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) <= _MAX_PROMPT_LEN:
|
||||
return text
|
||||
|
||||
records = payload["objective_data"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
payload["objective_data"]["consumption_records"] = records[:5]
|
||||
payload["objective_data"]["_truncated"] = f"消费记录已截断,原始 {len(records)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if len(text) > _MAX_PROMPT_LEN:
|
||||
n = payload["subjective_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
payload["subjective_data"]["notes"] = n[:10]
|
||||
payload["subjective_data"]["_truncated_notes"] = f"备注已截断,原始 {len(n)} 条"
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
return text
|
||||
@@ -1,93 +1,52 @@
|
||||
"""应用 8:维客线索整理 Prompt 模板。
|
||||
"""应用 8 维客线索整理 Prompt 拼装。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的全部线索,
|
||||
整合去重后输出统一维客线索。
|
||||
- 数据源:context.app3_clues + context.app6_clues(dispatcher 已查好传入)
|
||||
- 分类标签 6 选 1(与 member_retention_clue CHECK 约束一致)
|
||||
- 合并规则:相似线索合并,providers 逗号分隔
|
||||
- system prompt 在百炼控制台配置
|
||||
|
||||
分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致):
|
||||
客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈。
|
||||
|
||||
合并规则:
|
||||
- 相似线索合并,providers 以逗号分隔
|
||||
- 其余线索原文返回
|
||||
- 最小改动原则
|
||||
返回:单个 prompt 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App8 维客线索整理 Prompt。
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: Any | None = None, # 兼容统一签名,App8 不用
|
||||
) -> str:
|
||||
"""构建 App8 prompt 字符串。
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int
|
||||
- member_id: int
|
||||
- app3_clues: list[dict] — App3 产出的线索列表
|
||||
- app6_clues: list[dict] — App6 产出的线索列表
|
||||
- app3_generated_at: str | None — App3 线索生成时间
|
||||
- app6_generated_at: str | None — App6 线索生成时间
|
||||
context: site_id, member_id, app3_clues(list), app6_clues(list),
|
||||
app3_generated_at(str|None), app6_generated_at(str|None)
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", ...}, {"role": "user", ...}]
|
||||
JSON 序列化后的 prompt 字符串
|
||||
"""
|
||||
member_id = context["member_id"]
|
||||
app3_clues = context.get("app3_clues", [])
|
||||
app6_clues = context.get("app6_clues", [])
|
||||
app3_generated_at = context.get("app3_generated_at")
|
||||
app6_generated_at = context.get("app6_generated_at")
|
||||
app3_clues = context.get("app3_clues") or []
|
||||
app6_clues = context.get("app6_clues") or []
|
||||
|
||||
system_content = {
|
||||
"task": "整合去重来自消费分析和备注分析的维客线索,输出统一线索列表。",
|
||||
"app_id": "app8_consolidation",
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"merge_strategy": (
|
||||
"相似线索合并为一条,providers 以逗号分隔(如 '系统,张三');"
|
||||
"不相似的线索原文保留,不做修改。最小改动原则。"
|
||||
),
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
"providers": "提供者(逗号分隔)",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
payload: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"input": {
|
||||
"app3_clues": {
|
||||
"source": "消费数据分析(App3)",
|
||||
"generated_at": app3_generated_at,
|
||||
"generated_at": context.get("app3_generated_at"),
|
||||
"clues": app3_clues,
|
||||
},
|
||||
"app6_clues": {
|
||||
"source": "备注分析(App6)",
|
||||
"generated_at": app6_generated_at,
|
||||
"generated_at": context.get("app6_generated_at"),
|
||||
"clues": app6_clues,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请整合会员 {member_id} 的维客线索。\n"
|
||||
"输入包含两个来源的线索:App3(消费数据分析)和 App6(备注分析)。\n"
|
||||
"规则:\n"
|
||||
"1. 相似线索合并为一条,providers 字段以逗号分隔多个提供者\n"
|
||||
"2. 不相似的线索原文保留\n"
|
||||
"3. category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一\n"
|
||||
"4. 每条线索包含 category、summary、detail、emoji、providers 五个字段\n"
|
||||
"5. 最小改动原则,尽量保留原始表述"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
137
apps/backend/app/ai/references.py
Normal file
137
apps/backend/app/ai/references.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""AI references 工具模块。
|
||||
|
||||
为 AI 输出(ai_cache.result_json / ai_messages.reference_card)
|
||||
注入数据来源引用元数据,便于前端渲染可点击引用卡片。
|
||||
|
||||
- App2~8:通过 dispatcher._write_cache 统一注入到 result['_references']
|
||||
- App1:通过 xcx_chat 在 assistant 消息写入时调用 build_app1_reference 生成单卡片
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_app_references(app_type: str, context: dict) -> list[dict]:
|
||||
"""为 App2~8 构建 references 列表,供前端消息卡片渲染。
|
||||
|
||||
引用结构:
|
||||
{
|
||||
"type": "member" | "task" | "assistant" | "finance",
|
||||
"id": int | str,
|
||||
"label": "卡片上的文字",
|
||||
"link": "/pages/xxx/xxx?param=val"(小程序页面路径),
|
||||
"source_page": 小程序页面 contextType
|
||||
}
|
||||
|
||||
Args:
|
||||
app_type: 应用名称
|
||||
context: 传给 build_prompt 的上下文(含 site_id / member_id 等)
|
||||
|
||||
Returns:
|
||||
refs 数组。无有效上下文时返回空数组。
|
||||
"""
|
||||
refs: list[dict] = []
|
||||
site_id = context.get("site_id")
|
||||
member_id = context.get("member_id")
|
||||
assistant_id = context.get("assistant_id")
|
||||
time_dimension = context.get("time_dimension")
|
||||
|
||||
if member_id is not None:
|
||||
refs.append({
|
||||
"type": "member",
|
||||
"id": member_id,
|
||||
"label": f"客户 #{member_id}",
|
||||
"link": f"/pages/customer-detail/customer-detail?customerId={member_id}",
|
||||
"source_page": "customer-detail",
|
||||
})
|
||||
|
||||
if assistant_id is not None:
|
||||
refs.append({
|
||||
"type": "assistant",
|
||||
"id": assistant_id,
|
||||
"label": f"助教 #{assistant_id}",
|
||||
"link": f"/pages/coach-detail/coach-detail?coachId={assistant_id}",
|
||||
"source_page": "coach-detail",
|
||||
})
|
||||
|
||||
if app_type == "app2_finance" and time_dimension:
|
||||
refs.append({
|
||||
"type": "finance",
|
||||
"id": time_dimension,
|
||||
"label": f"财务看板:{_label_for_dimension(time_dimension)}",
|
||||
"link": f"/pages/board-finance/board-finance?timeDimension={time_dimension}",
|
||||
"source_page": "board-finance",
|
||||
})
|
||||
|
||||
# 保留 site_id 作为兜底上下文(不单独成卡,但用于前端场景判断)
|
||||
if site_id is not None and refs:
|
||||
for r in refs:
|
||||
r.setdefault("site_id", site_id)
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def attach_references(app_type: str, result: dict | None, context: dict) -> dict | None:
|
||||
"""向 AI 输出 result 追加 _references 字段(非破坏性)。
|
||||
|
||||
- result 为 None 时原样返回(调用失败不注入)
|
||||
- result 为 dict 时追加 _references 字段;如果 result 已含 _references,保留原值
|
||||
"""
|
||||
if result is None or not isinstance(result, dict):
|
||||
return result
|
||||
if "_references" in result:
|
||||
return result
|
||||
refs = build_app_references(app_type, context)
|
||||
if refs:
|
||||
result["_references"] = refs
|
||||
return result
|
||||
|
||||
|
||||
def build_app1_reference_card(source_page: str | None, context_id: int | str | None) -> dict | None:
|
||||
"""为 App1(chat)assistant 消息构建单个 reference_card。
|
||||
|
||||
兼容前端 chat.wxml 已有的 {type, title, summary, data, dataList} 渲染结构,
|
||||
额外携带 link 字段供前端点击跳转详情页。
|
||||
|
||||
当用户在特定页面(customer-detail / coach-detail / task-detail)发起对话时,
|
||||
自动附加对应跳转卡片。普通浮窗对话(source_page='general')返回 None。
|
||||
|
||||
与 chat_service.build_reference_card 不同:本函数不查 DB,仅按 source_page 构造链接。
|
||||
"""
|
||||
if not source_page or not context_id:
|
||||
return None
|
||||
|
||||
mapping: dict[str, tuple[str, str, str]] = {
|
||||
"customer-detail": ("customer", "客户", "customerId"),
|
||||
"coach-detail": ("assistant", "助教", "coachId"),
|
||||
"task-detail": ("task", "任务", "taskId"),
|
||||
}
|
||||
entry = mapping.get(source_page)
|
||||
if entry is None:
|
||||
return None
|
||||
ref_type, label_prefix, param = entry
|
||||
|
||||
return {
|
||||
"type": ref_type,
|
||||
"title": f"{label_prefix} #{context_id}",
|
||||
"summary": f"点击查看{label_prefix}详情",
|
||||
"data": {},
|
||||
"link": f"/pages/{source_page}/{source_page}?{param}={context_id}",
|
||||
"source_page": source_page,
|
||||
}
|
||||
|
||||
|
||||
def _label_for_dimension(dimension: str) -> str:
|
||||
"""8 个财务维度 → 中文标签。"""
|
||||
mapping = {
|
||||
"this_month": "本月",
|
||||
"last_month": "上月",
|
||||
"this_week": "本周",
|
||||
"last_week": "上周",
|
||||
"this_quarter": "本季度",
|
||||
"last_quarter": "上季度",
|
||||
"last_3_months": "近三个月",
|
||||
"last_6_months": "近六个月",
|
||||
}
|
||||
return mapping.get(dimension, dimension)
|
||||
@@ -14,12 +14,17 @@ from typing import Callable
|
||||
|
||||
import psycopg2.extensions
|
||||
|
||||
from app.services.runtime_context import LIVE_INSTANCE_ID, MODE_LIVE, MODE_SANDBOX, get_runtime_context
|
||||
|
||||
# prompt 最大存储长度
|
||||
_MAX_PROMPT_LENGTH = 2000
|
||||
# 2026-04-22:2000→8000。app2_finance 真实 prompt 约 4-8KB(72 组合财务看板 + 中文 key 膨胀),
|
||||
# 2000 字符截断会丢掉 optimization-critical 字段(如 discount_items 含团购折扣明细),
|
||||
# admin-web 调用详情页无法完整审阅 → 提高到 8000 覆盖绝大部分场景
|
||||
_MAX_PROMPT_LENGTH = 8000
|
||||
|
||||
|
||||
def _truncate_prompt(prompt: str | None) -> str | None:
|
||||
"""截断 prompt 为前 2000 字符。None 原样返回。"""
|
||||
"""截断 prompt 为 _MAX_PROMPT_LENGTH 字符上限。None 原样返回。"""
|
||||
if prompt is None:
|
||||
return None
|
||||
return prompt[:_MAX_PROMPT_LENGTH]
|
||||
@@ -54,17 +59,21 @@ class AIRunLogService:
|
||||
truncated = _truncate_prompt(request_prompt)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
ctx = get_runtime_context(site_id, conn=conn)
|
||||
runtime_mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
|
||||
sandbox_instance_id = ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_run_logs
|
||||
(site_id, app_type, trigger_type, member_id,
|
||||
request_prompt, session_id, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
request_prompt, session_id, status,
|
||||
runtime_mode, sandbox_instance_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, app_type, trigger_type, member_id,
|
||||
truncated, session_id),
|
||||
truncated, session_id, runtime_mode, sandbox_instance_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None, "INSERT RETURNING 应返回 id"
|
||||
|
||||
Reference in New Issue
Block a user